<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/src/meshbay_node, 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-17T00:16:02Z</updated>
<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>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>chore: release 0.5.0</title>
<updated>2026-08-16T13:29:30Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:29:30Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e4a98ced73541f8d892ed7959cb5c0fefd4dba57'/>
<id>urn:sha1:e4a98ced73541f8d892ed7959cb5c0fefd4dba57</id>
<content type='text'>
Two rounds of features and one long hunt.

The hub gained leaving a group, a cap of ten live public groups per owner, and
the rule that a group is listed only once a node has announced it — with
`prune-groups` to collect the ones that never got one. Presence rides on the
group list, from the registry the hub already keeps for signaling. The web
client speaks ten languages, splits Profile from Settings, and reads chat the
way it is written: newest first, paging backwards.

The rest was one symptom — "close the viewer, the next video hangs" — with three
independent causes underneath, none of which the test suite or e2e.py could see.
A background task the loop only weakly referenced, collected while it held a
transcode slot. A connection-state handler that forgot a peer without stopping
it. And `await proc.wait()` deadlocking on ffmpeg's own unread output, which no
amount of SIGKILL resolves. Found by instrumenting the node and reading the log,
after two confident fixes that addressed real but different bugs.

MNP goes to 0.2: PING/PONG and backward chat paging, both additive.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): stop losing transcode slots, and reap ffmpeg without deadlocking</title>
<updated>2026-08-16T13:28:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:28:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=188a76f52d2f30609147b1beee7754f2cbd1e778'/>
<id>urn:sha1:188a76f52d2f30609147b1beee7754f2cbd1e778</id>
<content type='text'>
Reported from a phone: play a video, close the viewer, open another — the second
hangs and the third is refused. Three separate causes, found by instrumenting
rather than guessing, after two fixes that addressed real but different bugs.

A task nobody holds can be collected mid-flight. asyncio keeps only a weak
reference, so `ensure_future` with the result discarded may be garbage-collected
while running — "Task was destroyed but it is pending!" — and `_stream_video`
never reached the exit of its `async with sem`. `_spawn` holds every background
task; all nineteen call sites go through it.

Losing the peer must stop its work. The connectionstatechange handler popped the
session from a dict and nothing else, so a closed tab went on transcoding for
the full 120 s credit timeout. Measured in the log: 91 s of ffmpeg after the
connection closed. `shutdown_tasks()` now runs on the way out, and the credit
wait checks the channel before sleeping and polls in slices instead of once.

And `await proc.wait()` after `kill()` still deadlocks. ffmpeg outruns a
credit-paced viewer and fills the stdout pipe; stop reading it and the transport
cannot finish closing, SIGKILL or not. Measured against the live node with a
169 MB video, closing the viewer after 20 segments and asking for the next one:
15.1 s then "Server busy" before, 0.1 s / 0.0 s / 0.0 s after.

Chunk replies wait for room on the channel. Eight megabyte-sized chunks answered
as they arrived queued 8 MB with nothing watching — measured at 7.3 MB of
bufferedAmount in milliseconds. Fine on a LAN, minutes of head-of-line delay on
a busy link.

Upload names accept any script. The rule was ASCII-only, so `été.txt` was
refused — and so was `rapport (1).pdf`, which is the form `_free_name` produces
itself, meaning the node rejected names it had chosen. Widened to Unicode with
the C5a and H2 protections intact, plus a refusal of names that lie about
themselves: trailing space or dot, and the right-to-left override. Errors now
name the file, so one bad name no longer fails every upload in flight.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(common): MNP 0.2 — liveness, and chat history read the way it is written</title>
<updated>2026-08-16T13:28:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:28:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=fd770dc6293be67298f582de5800f2ca6fe24a8b'/>
<id>urn:sha1:fd770dc6293be67298f582de5800f2ca6fe24a8b</id>
<content type='text'>
`get_messages` pages forward from the oldest message. That is the right shape
for "what happened since I last looked" and the wrong one for opening a
conversation, and the browser asked it for `since=0, limit=200` — so a group
with more than two hundred messages showed its first two hundred and the
exchange anyone came for was unreachable. Demonstrated on 300 messages: the
newest was simply absent from the answer.

`get_recent` and `get_before` page backwards, cursored on the row id rather than
the timestamp. Nothing makes a `time.time()` float unique, and a cursor on a
value two rows can share eventually skips a message or repeats it.

PING/PONG covers liveness on an already-open channel: a DataChannel whose peer
vanished without closing still reads as connected, and nothing noticed until a
real request hung. It is not a discovery mechanism — opening a connection to
ping costs a full ICE/DTLS handshake, measured at 0.6-7 s across two ISPs — so
presence in the group list comes from the hub's registry instead.

Both additions are backward compatible: an 0.1 peer sends no `before` and is
answered with the newest page, which is what it wanted.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>chore: release 0.4.0</title>
<updated>2026-08-15T22:22:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T22:22:01Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0231d240b92a2a11042fa62c0222d4c4b96a859d'/>
<id>urn:sha1:0231d240b92a2a11042fa62c0222d4c4b96a859d</id>
<content type='text'>
The web client speaks ten languages instead of one: French, Spanish,
Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch
and Polish, all formal, with `hub`, `node` and `GEK` deliberately left in
English so the interface still matches the CLI and the docs. Catalogues
are fetched per language rather than shipped together, plural forms go
through Intl.PluralRules because Polish needs four of them, and locale
matching keeps the region so pt-BR and zh-CN resolve to the files written
for them.

Splitting one module into a loader and ten catalogues gave the SPA a
version dependency it did not have before, and the hub was serving static
assets with no explicit freshness at all. A browser that cached half a
deploy either rendered every string as its own key or, in the other
direction, failed to link the module graph and showed nothing. Static
responses now carry no-cache, which costs one conditional request and
answers 304 with no body.

The node and common packages carry no functional change; they move with
the version because the three are released together.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>chore: release 0.3.0</title>
<updated>2026-08-15T17:19:40Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:19:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=75065cc1ed5d9cd733871d478dc4f915663d795c'/>
<id>urn:sha1:75065cc1ed5d9cd733871d478dc4f915663d795c</id>
<content type='text'>
Twenty-one commits since 0.2, and enough of them change what the thing
does that moving the old tag would have been the wrong description.

Node: video streaming paced by the client rather than pushed at it, and a
stream that ends when the viewer closes instead of holding a transcode
slot for two minutes. An operator can remove an empty directory and
revoke a member over MNP. `meshbay-node group add` attaches another hub
group without hand-editing node.toml. The node.toml operator key is gone;
the roster is the only source of authority.

Hub and web client: transfers outlive the page that started them, with a
widget that shows the rate and can cancel them; downloads stream to disk
in every browser, through the File System Access API where it exists and
a service worker where it does not; a folder can be taken as a zip built
in the browser. A group owner can remove a member and edit the
description. Nodes are recorded at the address their signed announcement
arrived from, not the one STUN told them about. Deleted accounts stop
being counted while the connection log keeps their name.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: stop a stream on close, count only real users, record where a node is</title>
<updated>2026-08-15T17:01:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:01:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=05f4feab641740c944d636f29a03f8c0dd1328c7'/>
<id>urn:sha1:05f4feab641740c944d636f29a03f8c0dd1328c7</id>
<content type='text'>
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.

While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.

**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.

**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.

Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): remove a member, and keep gigabytes out of the tab</title>
<updated>2026-08-15T15:23:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T15:23:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=dd3927a661273734493f65a593755b95aecf5f09'/>
<id>urn:sha1:dd3927a661273734493f65a593755b95aecf5f09</id>
<content type='text'>
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.

It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.

**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.

Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.

Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(files): download a folder as a zip, and remove an empty one</title>
<updated>2026-08-15T10:19:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T10:19:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8cd7e467ebec987f66c4fe93a8d87dfbc57304d2'/>
<id>urn:sha1:8cd7e467ebec987f66c4fe93a8d87dfbc57304d2</id>
<content type='text'>
Two things a Files panel needs and did not have.

**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.

**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.

zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.

Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.

One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
