<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/src/meshbay_node/daemon.py, branch 0.14</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.14</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.14'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-14T19:45:53Z</updated>
<entry>
<title>feat: a group can be left out of Search, and Search tries every node</title>
<updated>2026-09-14T19:45:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T19:45:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=cd2745cecff12e894e0dfa702bff6a90f0e8734e'/>
<id>urn:sha1:cd2745cecff12e894e0dfa702bff6a90f0e8734e</id>
<content type='text'>
`search_listed` is a per-group setting on the node, changed by a signed
operator op and carried in the sealed handshake ack. Search reads it after
the handshake and stops there: no index is fetched, cached or merged, in any
of the four views, and the page says how many groups it left out. The switch
is a "Search" section in the group's settings, shown to the operator.

Absent means listed, at every layer: roster default, ack default, and the
client only drops a group on an explicit `false` — so an upgrade or an older
node removes nothing from anyone's Search.

It is a listing preference and protects nothing: the node serves the same
index to Search and to the group page and cannot tell them apart, every
member lists the group by opening it, and a client that ignores the flag
lists it in Search too. Design §9.11 says so, so it is never described as
private. The cost is one handshake per unlisted group, because only the node
knows the setting.

Search also took `nodes[0]` twice — for the index and for the pooled
connection — the defect 4cce50f fixed on the group page only. One
`connectToGroup` now walks the list the same way: a refusal about this
browser stops, `not_hosted` or a failed connection moves on.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>feat(node): progress names the root under way and the roots waiting</title>
<updated>2026-09-14T09:08:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T09:08:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=a294c1d338ba4c20d66873d593d1c101e69c5a40'/>
<id>urn:sha1:a294c1d338ba4c20d66873d593d1c101e69c5a40</id>
<content type='text'>
`IndexProgress` said "scanning, this many bytes of that many" and nothing
more. A group's roots are walked one after another, so a second directory
added during a large scan showed as the bar jumping back to 0 %. It now also
carries the root being walked and its position in the roots table, the kind
of walk (scan, rescan, reconcile, watch), file counts, and the roots
waiting for the scan lock in order: queued by the initial scan, by a
retarget, and by a plug; dropped when a root is removed.

`GET /api/index-status` answers for every group at once, including a group
still in its initial scan, so a client can show indexing on any page. It
names roots: loopback only, like `current_dir`.

`index_progress` and the handshake ack gain the same counters, still naming
nothing (decision D3): the root is a position in the roots table the member
already opened from the sealed index, and the queue is a count. The pusher
keeps speaking while a root only waits for the lock.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
</content>
</entry>
<entry>
<title>fix(node): a reload and a plug rescan outlive the session that asked</title>
<updated>2026-09-14T07:40:23Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T07:40:23Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d4b37774118a2689c67b2ad802b9763f2ef448fd'/>
<id>urn:sha1:d4b37774118a2689c67b2ad802b9763f2ef448fd</id>
<content type='text'>
A root added from the client arrives over MNP, and _retarget_indexer
started the daemon's reload with the session's own _spawn. When that
session closed - a client reconnecting 47 s into the scan of a 900 GB
root - shutdown_tasks() cancelled the reload mid-scan, and the reload
queued behind it, without a line in the log. The new root was in
node.toml and in the indexer's set but never in the group's context; the
lock was free and nothing retried, so the node served the old roots table
for hours while reconcile hashed the whole drive as missed events. One
loopback reload fixed the live node in 9 ms.

_reload_config now runs the work in a node-owned task and awaits it
through asyncio.shield, so a caller that goes away only stops waiting; a
cancelled reload is logged. plug_root does the same for its rescan, which
drops the root's entries before walking the disk and so left the root
empty when its admin op's session closed.

The existing MNP test replaced _spawn with a list and could not cancel
anything; the new tests close the session for real and fail without this.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
</content>
</entry>
<entry>
<title>fix(node): an added root is served before it is scanned</title>
<updated>2026-09-14T00:35:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T00:35:45Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=211ace6cc0168647e00bcc20a909aead50b8ad0a'/>
<id>urn:sha1:211ace6cc0168647e00bcc20a909aead50b8ad0a</id>
<content type='text'>
Adding a large directory to a running group made the reload await the
scan of the new root before putting the new RootSet in the group's
context, holding _reload_lock the whole time. For the hours a large drive
takes to hash, the node served the old set:

- a file request under the new root got None from entry_abs_path and the
  handler died on None.exists() without replying;
- a writable/removable toggle answered with the live table, still the
  old one, so the directory vanished from the operator's settings;
- reconcile saw every file the scan had not reached as a missed event and
  hashed it again on the same executor, rewriting progress under the scan.

retarget now applies the set, the roots table and the watcher first, and
with wait=False scans the added roots in the background; the daemon swaps
ctx["roots"] before calling it. A scan lock shared by the initial scan,
added-root scans and reconcile makes the reconcile loop sit out a running
scan without backing off. Every transport site that resolves an entry
answers ROOT_NOT_SERVED instead of crashing, and a delete keeps the entry.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
</content>
</entry>
<entry>
<title>fix: hold every background task, in both codebases</title>
<updated>2026-09-13T13:40:50Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T13:40:50Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f2d9a026db453899e5bb50f101b1f5f1a9f91ddf'/>
<id>urn:sha1:f2d9a026db453899e5bb50f101b1f5f1a9f91ddf</id>
<content type='text'>
asyncio keeps only a weak reference to a task, so a coroutine started with
`asyncio.ensure_future(...)` whose result is discarded can be collected while
it is still running: the loop logs "Task was destroyed but it is pending!" and
the work simply does not happen. No error reaches the caller, and what is lost
is whatever that coroutine was in the middle of.

The node already had a guard for this, written after an abandoned stream task
lost a transcode slot for good — and it read one file, `webrtc_server.py`,
because that is where the defect was found. Outside that file there were
nineteen sites: the hub's `chat_notify` (a notification for every member of a
group), the indexer's debounce (every real-time index update), eleven in
`daemon.py` including the SIGHUP reload and each enrichment pass, two in
`ops.py`, and five in the loopback API.

`meshbay_common.background.spawn()` is the one door. It holds the task, drops
it when it finishes, and logs what it raised under the coroutine's own name —
an exception in a task nobody awaits was otherwise reported by asyncio at
collection time, out of context or not at all. A peer session's `_spawn` stays
as it is: that one can also *cancel* what it holds, which a module-level holder
cannot, because a session ends and a process does not.

`test_background_tasks.py` walks every package's source and refuses a discarded
handle. It parses rather than greps, so an assignment, a comprehension or an
await is not mistaken for one, and it was checked against a deliberate
reintroduction. A guard that stops at the edge of the file where the bug was
found is a guard against that bug, not against its class.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix(node): an uploaded file records who sent it</title>
<updated>2026-09-13T13:40:34Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T13:40:34Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d917bb61e42336c38782b22da604d7ca923d484a'/>
<id>urn:sha1:d917bb61e42336c38782b22da604d7ca923d484a</id>
<content type='text'>
`_register_uploader` walked the index for the entry it had just written, at a
moment when no such entry can exist: the file was a `.part` until the rename on
the line above, which is not indexable, and the watchdog that will index it
debounces for two seconds and then hashes. The walk matched nothing, silently,
so every uploaded file in every group was owned by nobody — and `file_delete`
refuses a caller with no admin authority when the entry records no uploader, so
a member could not delete what they had just sent. MESHBAY_DESIGN.md §5.4
grants that to any non-revoked device of the uploading account.

The record is now written when the last chunk lands (`indexer.record_upload`)
and the entry is stamped from it in `_hash_or_cached`, the one funnel every
entry passes through — initial scan, watchdog, reconcile and replug alike. It
lives in the index cache rather than on the entry alone, because the index is
rebuilt from disk at every start and an owner the node forgets on restart is a
right quietly taken away. It is validated against a live `stat()`, so whatever
later occupies that path inherits nothing; and `_rescan_root`'s carry-over no
longer copies over it, or memory would beat the durable record.

§5.4 also claimed ownership was *provable* — a transcript the uploader signs,
stored with the entry. No such signature has ever existed; `meshbay:upload:v1`
in the code is the groupbox purpose that seals the envelope. The section now
states what the code does, and the transcript is an open item in §15.3.

`test_upload_attribution.py` drives the real handler and a real indexer across
that seam. Against the previous source its two positive cases fail on the
property, not on a missing method — an upload, then a rebuild from disk, then
a different file at the same path inheriting nothing.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix(node): the handshake ack dropped one app's directories</title>
<updated>2026-09-10T17:26:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T17:26:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=32a86417d0edc3bf5c4cf859243c9e34b29bf1ef'/>
<id>urn:sha1:32a86417d0edc3bf5c4cf859243c9e34b29bf1ef</id>
<content type='text'>
The ack was assembled from its own tuple of application names, a copy of the
daemon's `APP_DIR_KEYS`, and the two had drifted: the copy was missing
`helloworld`. So the reference application — the one that exists to prove a new
application needs no special-casing — was the single application whose
configured folders never reached a client, which made the plugin claim false
exactly where it is demonstrated.

Fixed by removing the copy rather than syncing it. The ack now emits whatever
`&lt;app&gt;_directories` the group context carries, and `_app_directories_ctx` is the
only thing that puts one there, so the two cannot disagree again. The transport
names an application in one place, `ALLOWED_APPS`, which is enforcement rather
than a directory list.

The client had the same fault one layer up: `group-page.js` read three names by
hand from the ack while the live-update path beside it was already generic. It
derives the map from the ack's own keys now, so the fix reaches the settings
pane instead of stopping at the wire.

A first attempt moved the list to `roster.py`, where directory *storage* lives,
and `test_helloworld_proves_the_plugin_claim.py` refused it: the roster, the ops,
the config and the root set must name no application at all. That test is the
architecture's own guard and it was right — the list belongs on the daemon, which
is what wires a group's context, and everything downstream is derived from it.

Two new tests, both verified to fail against the previous shape: the ack carries
an application the node names nowhere else, and the ack keeps no list of its own.
`test_the_lists_are_read_under_one_name_each` now asserts the shell names no
application rather than that it names exactly three.

Two stale comments went with it — the ack's, which described scalars removed in
07ff8b4, and the client's, which said those scalars still rode the wire for
MNP 1.0 peers that can no longer connect.

Full suite: 2258 passed, 4 skipped.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
</content>
</entry>
<entry>
<title>docs(node): the ack comment described scalars that are gone</title>
<updated>2026-09-10T16:54:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T16:54:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2baebeebecd1c1a201d1ce5649ab5d0e04c3617b'/>
<id>urn:sha1:2baebeebecd1c1a201d1ce5649ab5d0e04c3617b</id>
<content type='text'>
The handshake ack's app-directories entry still said "the same three answers in
one shape" and "the scalars above are derived from these and kept for MNP 1.0
clients". Neither is true since the per-app ops were folded into one: there are
no scalars above, and a 1.0 client cannot reach this code at all — the floor
moved to 3.0 with the lease flag day.

A comment that contradicts the code beside it is worse than no comment, because
one of them is wrong and the reader cannot tell which. This is the same fault
the leaseless-bound comment had, one commit earlier.

What it says instead is what is actually load-bearing: `&lt;app&gt;_directories` is
the only form on the wire, and `chat_directory` below is safe as a second name
for one of them because `_app_directories_ctx` derives it on every build rather
than storing it alongside — which is precisely what the removed scalars did not
do.

daemon.py had the same stale reference three lines from the code that produces
these, pointing at `video_root` for the shape a per-group signed setting takes.

Comments only; no behaviour change. Node suite green.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
</content>
</entry>
<entry>
<title>refactor(mnp)!: one operation for an app's folders, not one per app</title>
<updated>2026-09-10T15:49:58Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:49:58Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07ff8b4f6143039fcc74b8cf7c423282bce093c1'/>
<id>urn:sha1:07ff8b4f6143039fcc74b8cf7c423282bce093c1</id>
<content type='text'>
`video_root`, `audio_root` and `photo_roots` are gone — the messages, the
signed operations, the handlers, the `ops` wrappers, the three scalars on the
handshake ack, and the client's handlers for their acks. `app_directories`
does the same thing for every application, keyed by the app's own registry
name, and it is what the SPA has been sending.

The three were the same instruction three times, differing only in the key they
wrote and whether they carried a string or a list. That shape is what made
adding an application mean adding a message type, an op, a handler and a widget;
it also meant three validation paths, and the older ones validated nothing —
a typo was stored and then quietly matched no entry, an app showing an empty tab
with no way to tell "misconfigured" from "no files yet".

**What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and
friends out of `group_settings`: that is a key on an operator's disk, not on the
wire, and a node upgraded into this must find its own configuration. The Search
page still reads its own older cache keys, for the same reason — the cache
outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose
second name something still reads.

The two per-app policy test files go with the messages. What only they held —
the real challenge/response path from message to database, which no other test
exercises — is retargeted at `app_directories` in
`test_app_directories_signed.py`, and the handler's own refusals (unknown app,
malformed `directories`, nobody to authorize it) join `test_app_directories.py`.

Node and common suites 1368 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>feat(node): uploads outlive their connection, and their leftovers are reaped</title>
<updated>2026-09-09T09:26:16Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T09:26:16Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d62b6a4e8985d0504e1825f6f8f663ccd64489ae'/>
<id>urn:sha1:d62b6a4e8985d0504e1825f6f8f663ccd64489ae</id>
<content type='text'>
Stage 8 of ~/next/improve-downloads.md, first half. Two defects that are the
same defect seen from two sides.

An upload's progress lived on the session, keyed by `rel_dir/filename`. A
dropped connection threw it away and the client's next chunk was refused with
`not_started`: an upload interrupted at 99% could only be started again from
zero, on a link flaky enough to have interrupted it once. It now lives in the
group context, keyed by member as well -- a shared directory means two people
can be sending IMG_1234.jpg at the same moment and neither may inherit, or
overwrite the position of, the other's.

What the lost state left behind was a `.part` nothing would ever finish, delete
or look at again. It is not an index entry, so it is invisible to every member
and to the operator's own file list: one abandoned film is a gigabyte of their
disk, kept for ever. That leak predates this branch.

A `.part` is deleted only when **both** hold: no upload is writing it, and
nothing has been written to it for 24 hours. Waiting costs disk; being wrong
costs somebody their upload, and is not reversible -- so a read-only root is
never walked (it cannot have received an upload), an unavailable one is never
walked (an unmounted drive reporting "nothing found" is how a careless janitor
deletes a library), and a file whose mtime is in the future is left alone (a
clock that went backwards is not evidence). The reaper matches whole paths and
the state records the path it is writing, rather than both sides rebuilding one
from a root name -- two implementations of one rule whose failure mode is
deleting a live upload.

The rules are in `uploads.py`, pure logic with no asyncio and no transport, the
same shape as `transfers.py` and for the same reason.

23 cases, four of them checked against the unfixed source. Node suite 1195
passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</content>
</entry>
</feed>
