<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git, branch 0.5</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.5</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.5'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-17T15:38:14Z</updated>
<entry>
<title>docs: sessions renew themselves, and two faults of the same shape</title>
<updated>2026-08-17T15:38:14Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T15:38:14Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f23e8e51e4b8bc03d6109081fe048af1782e1f4e'/>
<id>urn:sha1:f23e8e51e4b8bc03d6109081fe048af1782e1f4e</id>
<content type='text'>
USERGUIDE said an hour in five places and presented renewal as something the
reader does with curl. Both are now wrong: it is four hours, the web app renews
for itself, and the endpoint rotates — so anyone driving it by hand has to store
the refresh token that comes back, or their next call revokes the family.

Also corrects what the token's life actually bounds. It is not how long a
revocation takes: the hub reloads the account on every request and refuses a
suspended one at once, and it pushes signed revocations to nodes. What remains
is a leaked token on an account still in good standing, which is the reason to
keep the number small.

Two lessons in CLAUDE.md. A rotated refresh token has to be stored or it is
spent once. And an effect keyed on a value that used to be constant: the WebRTC
dial listed `token` among its dependencies, harmless while a token only ever
expired, fatal once the session renewed itself — it tore the connection down
mid-handshake and the node waited for ever. That and the hook declared after its
own dependency are the same shape, and worth naming as one: code that reads
correctly on its own and is wrong against the component lifecycle.
</content>
</entry>
<entry>
<title>fix(hub): a session that renews itself</title>
<updated>2026-08-17T15:38:00Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T15:38:00Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6562665c80f96f30fef95af83a0abcf71f41795f'/>
<id>urn:sha1:6562665c80f96f30fef95af83a0abcf71f41795f</id>
<content type='text'>
Reported: after an hour of watching a film, every action answers "token expired
or invalid", with signing out and back in as the only way on. Reopening the tab
the next day did the same.

The access token lasts an hour and the refresh token thirty days, and nothing
used the second one. `hubFetch` reported a 401 like any other error, and
watching a film is precisely an hour in which the hub hears nothing at all,
because the video travels over WebRTC. So the token aged out with no request to
notice, and a tab reopened the next morning presented a stale token with a
perfectly good refresh token sitting beside it in localStorage.

Underneath was the reason it could not be recovered from. The hub *rotates*:
the refresh endpoint revokes the token presented, returns a replacement, and
treats a revoked one presented again as theft, revoking the whole family. The
client kept only the access token out of that response. So the refresh token was
spent on first use and the second attempt did not merely fail — it destroyed the
family. Which is exactly the reported symptom.

Renewal now happens on a margin, on returning to the tab, on mount, and on a 401
with the request replayed. Concurrent renewals share one request: two 401s
racing would otherwise present the same refresh token twice, and the hub cannot
tell that from theft, so the remedy would have been worse than the fault. A
refusal signs out cleanly rather than leaving a session that fails every call
while looking signed in.

The lifetime goes to four hours, which is not what makes long sessions work —
renewal is — but is what someone has to notice by if renewal itself breaks. An
hour was less than a feature film. Twelve was considered and declined: it widens
the window in which a leaked token cannot be turned off, and it lets the renewal
path go a whole day between uses, which is how it came to be broken here without
anyone noticing. Production sets this in its own hub.toml, so both moved.

The tests run the shipped code against a hub that enforces rotation, because a
stub that accepted the same refresh token twice would have passed against the
broken client. Checked that dropping the rotated token reproduces the revoked
family, so the guard is guarding something.

Also widens the orphan-setter rule to ignore `setX` functions declared in the
module: `setAuth` is not a hook setter, and a rule that cries wolf is one
somebody eventually silences. Verified it still catches a real orphan.
</content>
</entry>
<entry>
<title>fix(hub): the transfers panel hung off the side of a phone</title>
<updated>2026-08-17T07:28:49Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T07:28:49Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=af30a10b83366416c25eaacec0b4df77526d0924'/>
<id>urn:sha1:af30a10b83366416c25eaacec0b4df77526d0924</id>
<content type='text'>
Reported: on mobile you see only the right-hand edge of the panel, without the
content. Measured, before anything was changed:

    320 px viewport -&gt; panel at -138..192, 138 px off the left
    360 px          -&gt;  -98..232
    412 px          -&gt;  -46..284

The panel is 330 px wide and anchored to the right edge of its button — but
that button is not at the right edge of the screen, since the bell and the user
menu come after it. What falls off is the left-hand side, which is where the
file names are, so what stayed on screen was a strip of progress bars belonging
to nothing.

Narrowing it would not have helped: the overflow comes from where the right
edge is pinned, not from the width. Below the existing 768 px breakpoint the
panel is anchored to the viewport instead, full width on a phone and capped at
420 px on a tablet, where stretching two filenames across 750 px would be
silly. Desktop keeps its 330 px against the button.

The interesting part is how it was found. The responsive tests read numbers out
of the stylesheet and said, in their own docstring, that a layout could not be
measured because the suite had no browser. It has one now — Chrome, from the
video work — so layout_probe.py renders the real stylesheet at a given width and
returns rectangles. `width: 330px` was never the thing worth asserting on.

An iframe carries the viewport, because a headless window will not go below
about 500 px, and one browser measures every width: launching one per test put
three minutes on the suite against twenty-six seconds for all of them. Checked
that the new tests fail with the rule removed — three of them do — and that
they pass with it back.
</content>
</entry>
<entry>
<title>test(hub): a hook that depends on one declared below it never runs</title>
<updated>2026-08-17T00:16:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T00:16:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=42047dac4041e72e09499e3adf145f1c0f83b284'/>
<id>urn:sha1:42047dac4041e72e09499e3adf145f1c0f83b284</id>
<content type='text'>
`const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b`
further down the component is still in its temporal dead zone. ReferenceError on
every render, before anything the component does can run — and the symptom is
the component simply not appearing. Clicking a video did nothing at all: no
picture, no error on screen, nothing in the node's log because nothing was ever
requested. It reached production.

Nothing caught it. `node --check` passes, the code is well-formed. Worse, the
MSE harness extracts the player functions into an order of its own and therefore
*reordered* them before running — quietly repairing the one class of defect it
was best placed to catch. It sorts by position in the file now, and
test_hook_ordering.py checks the property directly across the whole SPA. Both
the rule and the harness are checked against the layout that actually shipped.

test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the
race that made the third seek hang: the whole in-flight window arriving while
`reinitAt` is still awaiting. Before, the player is left believing eight
segments are in flight and grants nothing; after, the window comes back. A run
that happens to work proves nothing about a race, which is the point of forcing
the worst case rather than trusting a longer session.
</content>
</entry>
<entry>
<title>feat(hub): move about in a film, and pick it up where it was left</title>
<updated>2026-08-17T00:16:17Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T00:16:17Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f5c4c058aa7e91fbdbbdd8cf34042535a05df433'/>
<id>urn:sha1:f5c4c058aa7e91fbdbbdd8cf34042535a05df433</id>
<content type='text'>
Clicking the scrubber now restarts the stream there. A seek inside what is
already buffered never reaches the node. Dragging is debounced at 350 ms,
because `seeking` fires continuously and each one acted on kills an ffmpeg and
spawns another.

`SourceBuffer.mode` changes from 'sequence' to 'segments', and each stream sets
`timestampOffset` to where the node says it started — otherwise a stream
beginning at forty minutes is buffered at zero and the scrubber lies about
everything. Proved in Chrome before any of it was written: abort, remove, offset
600, append, and playback resumes at 600 with `readyState` 4.

A seek makes the buffer discontinuous, and everything reading `buffered` then
has to mean a particular range. "The last one" stops being "the one playing":
the read-ahead would report a full buffer across a gap while the player starves,
and the eviction would take out everything between the first range and the
playhead, including what is showing. Both work from the range around the
playhead now. `reinitAt` also clears the buffer outright rather than keeping two
ranges, and calls `abort()` first — ffmpeg was killed mid-fragment, so the
parser holds half of one and the next header would land on top of it.

The position is kept in localStorage, per file: no protocol, no storage anyone
else has to keep, and nothing new learns what you watch. Not below thirty
seconds, not past 97% of the film, and a pill offers the beginning back.

Two things this had to get right and did not at first, both found by running it
rather than reading it. Between asking for a seek and its `stream_init`,
everything on the channel belongs to the film being left — same file, so
`file_id` cannot separate them, but ordering can. And a segment that arrived is
no longer in flight whatever is then done with it: discarding one before
decrementing the window leaked a slot every time, and `reinitAt` is
asynchronous, so a whole window could arrive while it waited. The player then
believed eight segments were in flight, granted nothing further, and the node
waited for credit that could not come — a race, which is why the same seek
worked twice and hung on the third.
</content>
</entry>
<entry>
<title>feat(node): seeking, as a stream restarted somewhere else</title>
<updated>2026-08-17T00:16:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T00:16:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58'/>
<id>urn:sha1:3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58</id>
<content type='text'>
The scrubber was drawn the length of the film — `ms.duration` has always been
the real duration — and then `onSeeking` quietly clamped every target back into
whatever happened to be buffered. The bar invited a click and refused it.

`stream_req` gains a `start`. The session's previous stream is retired by the
path that already exists for switching films, and ffmpeg is spawned again with
`-ss` **before** `-i`: an index lookup rather than decoding and discarding up to
the point, which is milliseconds on a 500 MB film instead of tens of seconds.
Measured over real MNP: 0s -&gt; 492 MB, 600s -&gt; 418 MB, 3000s -&gt; 179 MB.

A seek at or past the end is pulled back, because ffmpeg would produce nothing
and the player would wait for segments that are never coming.

`stream_init` reports the position actually used. It has to: ffmpeg restarts its
output timestamps at zero however far in it seeks — `-copyts` does not change
that for this input, measured — so the client is the one that puts the fragments
back on the film's timeline, and it cannot guess by how much. The value is also
not what was asked for, since `-c copy` lands on the keyframe at or before it.

The diagnostics that found the rest of this are here too: a seek, a first init
and a re-init are each one line at INFO, which is rare enough to keep on. The
five-second client report stays at DEBUG.
</content>
</entry>
<entry>
<title>docs: what this session cost to find</title>
<updated>2026-08-16T18:58:17Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:58:17Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f57b2f6c0c2770fe0f456128d985e29427d2432f'/>
<id>urn:sha1:f57b2f6c0c2770fe0f456128d985e29427d2432f</id>
<content type='text'>
Five lessons, and the first is the expensive one: a test that models a fix
agrees with it by construction. The buffer-ceiling test passed against a player
that still hung, because the model and the fix had the same author and the same
misunderstanding.

Also: `no-cache` only binds a browser that asks; redeploying during someone
else's test kills their session and truncates the log holding the reproduction;
`updateend` fires for `remove()`; and flow control on a media stream is a
window, not a debt.

USERGUIDE section 7 rewritten — it still described 24 segments in flight and
two transcode slots, and said "transcode" where ffmpeg does a `-c copy` remux,
which is exactly why a slot costs little and why 500 MB really does go on the
wire.
</content>
</entry>
<entry>
<title>feat(node): the stream capacity is the operator's to set, and a log that says who stopped</title>
<updated>2026-08-16T18:58:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:58:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e012b7e9ce55079f411943c7a1f6ccbfbd629a5f'/>
<id>urn:sha1:e012b7e9ce55079f411943c7a1f6ccbfbd629a5f</id>
<content type='text'>
Bounding the client's read-ahead changed what a transcode slot is. It used to
be a burst — the browser took segments as fast as it could append them, so a
slot came back within the minute whatever the length of the film. Now it is
held for as long as someone is watching, so the cap counts simultaneous
viewers, and two of them meant the third was refused for the next hour and a
half.

The right number depends on the machine, so it belongs to the operator:
`[node] max_concurrent_streams` in node.toml, or
MESHBAY_MAX_CONCURRENT_STREAMS. Default 8 — one ffmpeg per viewer, remuxing
rather than encoding, idle on a pipe for most of the film. Zero, a negative
number, a non-number and a bool are refused with a warning naming the setting:
`Semaphore(0)` is not "no limit", it is a node where no video ever plays and
nothing says why, and TOML `true` would have become 1 by way of `int()`.

A stream also ends on the peer's silence now rather than on its stinginess. A
viewer buffered well ahead deliberately grants nothing for minutes, and the
old budget accumulated over the whole wait, so a keepalive that granted no
credit could not keep a paused film alive.

The rest is diagnosis, which is what this cost. `client_diag` carries the
player's own view — readyState, refused appends, buffered ranges, the video
element's error — into the node's log at DEBUG, next to the node's view of the
same stream. It is the only window into a phone, and every field is
stringified and cut short because all of it is peer-controlled. The node also
logs the first keepalive, which distinguishes a paced client from an unpaced
one at a glance, and progress every hundred segments, whose last line says
where a stream stopped and which side stopped it.
</content>
</entry>
<entry>
<title>fix(hub): serve the SPA under a fingerprint of what it is</title>
<updated>2026-08-16T18:57:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=5dea19d9950518887be7eb14696f600ee023dbbd'/>
<id>urn:sha1:5dea19d9950518887be7eb14696f600ee023dbbd</id>
<content type='text'>
`Cache-Control: no-cache` requires a browser to revalidate, but it only binds
one that asks. A browser that cached app.js before that header existed applies
heuristic freshness instead — a fraction of the file's age, which for a file
dated weeks ago is days — and never asks. It then runs an old player against a
new node.

That cost most of a session. A phone kept a player without the read-ahead bound
and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound
had been written to remove, for an hour after the bounded player went live. A
fix that is written, tested, deployed and served, and still not what runs, is
indistinguishable from a fix that does not work.

The whole module graph now lives under `/a/&lt;content-hash&gt;/`. A path prefix
rather than a query string, because relative imports inherit it: `app.js`
reaching for `./i18n.js` gets the build it was written against, and never a
mixture of two — which does not render a stale page, it fails to link. The URL
changes with the content, so those may be cached hard.

`sw.js` stays at the root. Its scope is its own path, and under the prefix it
would no longer control the pages whose downloads it exists to intercept.
</content>
</entry>
<entry>
<title>fix(hub): bound the video read-ahead by the playhead, not by the network</title>
<updated>2026-08-16T18:57:41Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:41Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=373f83236816aaff536dfa2f9589801acac44012'/>
<id>urn:sha1:373f83236816aaff536dfa2f9589801acac44012</id>
<content type='text'>
A 500 MB film loaded about 100 MB and hung on "buffering" for good. 100 MB is
not a number in our code: it is where the browser stops. ffmpeg remuxes with
`-c copy`, so the bytes on the wire are the file's own, and credit granted per
append meant taking them as fast as the network allowed — which for a film is
very much faster than watching it. The SourceBuffer ceiling arrived in the
first minute.

Past it every append was refused, and the refusal was unrecoverable: a refused
append fires no `updateend`, `updateend` was where credit was granted, so the
node sent nothing and no segment arrived to retry the append. Every wakeup the
pipeline had was downstream of the append that had just failed. Playback
continuing — the one thing that frees room — woke nothing at all.

Credit now follows the buffer instead of the writes. `pump()` is the only
place it is granted, it keeps `STREAM_WINDOW` segments in flight while less
than `BUFFER_AHEAD_S` of film is held past the playhead, and it is driven by a
one-second clock and by playback progress, never by arriving data. Buffering
by time makes a two-hour film cost what a two-minute clip costs.

A window rather than a debt, and this took a second measurement to get right:
accumulating a credit per append and releasing the balance when the buffer
finally drained sent six megabytes in one burst, overshot by a minute of film,
then said nothing for forty-six seconds. Measured in Chrome against real
fragmented MP4.

Two smaller things found on the way. `updateend` fires for `remove()` as well
as `appendBuffer()`, so crediting from it paid the node for the player's own
evictions. And a viewer that is deliberately far enough ahead grants nothing
for minutes, which the node read as a closed tab — it now sends `stream_more`
with n=0, which grants no room but proves someone is there.

The first version of the test modelled the credit loop and passed while the
player still hung: a model written by whoever wrote the fix agrees with it by
construction. `tests/harness/mse_harness.mjs` lifts the real functions out of
app.js as text and runs them against a SourceBuffer that has a ceiling. What is
modelled is the browser.
</content>
</entry>
</feed>
