| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from testing 7a: pause worked in the desktop app and no button
appeared in Chrome. That is the design working — without a granted download
folder, "save automatically" means the service worker, and that target is a
download the browser already owns — but nothing anywhere said so, and choosing
a folder looked like a question of where files land.
So the Settings line now says what it costs not to choose one, in all ten
catalogues. It renders only where a folder can be chosen at all, which is
exactly the browsers the advice applies to.
Also pins the tier table the pause button is drawn from: a granted folder, a
save dialog and the desktop sink can be paused, a service-worker stream cannot.
Four cases through the real `_openDownloadTarget`, and one more that reads the
value off the real `downloads.js` rather than a stub of it -- the first version
of these stubs did not carry the field at all, so the cases would have passed
while checking nothing.
Hub suite 847 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the
targets that can actually do it. Resuming across a reload is 7b.
A paused transfer holds **nothing**. Its slot goes back to the node the moment
it stops and resuming rejoins the queue at the tail, because anything else lets
one member close a node by pausing four downloads and going to lunch. So the
lease is taken inside the run loop rather than before it, and pause is refused
outright for a transfer that could not ask for another one.
Resuming is exact rather than approximate: the pipeline stops between two
chunks and never inside one, so what is on disk is always a whole number of
chunks and `fromChunk` is a verified position. The failure mode being avoided
is a file that looks complete and is quietly corrupt.
The target has to survive it, so a pause no longer reaches the `abort()` that a
failure does -- that would delete Electron's `.part` or the file just created in
the granted folder, leaving nothing to continue. And the in-memory fallback
keeps its accumulated chunks rather than starting a second array.
The button is drawn only where the target says it can. A service-worker stream
says no, in its own code and for its own reasons: the browser is already writing
an HTTP response into its own download folder, not feeding it stalls that
download where we cannot see or resume it, and an idle worker is terminated
within seconds. Firefox and Safari therefore keep cancel and get no pause, which
is the decision recorded in §6.5.
Cancelling a paused transfer ends it. A paused run is parked on a promise;
without waking it the row said "cancelled" over work that had not stopped and a
target that was still open.
Six cases, each checked against the unfixed source. Hub suite 842 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The repair worked but arrived too late to help: about thirty seconds after a
hard reload, by which time four downloads had been started and hung, and the
page reloading under them read as an unexplained refresh.
Two delays, both removed.
`_claimController` waited its whole control budget before asking for the claim.
A page that is uncontrolled while an active worker exists will never be claimed
on its own -- a document fetched by a hard reload is exactly that shape -- so
the fifteen seconds were spent waiting for something that was not coming. The
claim is now asked for first; waiting is the fallback, not the opening move.
Measured in the harness: 6042ms of a 6000ms budget before, milliseconds after.
And a download that starts while the self-test is still running now waits for
it rather than racing it. Otherwise the click spends both its attempts failing
on a path that is about to be repaired, which is what put four frozen rows on
screen.
Hub suite 836 passed. Both new cases were checked against the unfixed source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Downloads on Firefox failed with "the worker did not answer the download within
15s", every time, for one operator, while the same profile driven from here
succeeded every time. Their own test sequence found it: a freshly started
browser downloaded four files out of four, twice; one Ctrl+F5 and every
attempt afterwards failed; restart, fine again; Ctrl+F5 before any attempt and
the very first one failed.
A document fetched by a hard reload is loaded with the service worker bypassed.
It can still be claimed afterwards, so `navigator.serviceWorker.controller`
comes back and every check in `_claimController` passes — but the navigations
that document starts keep missing the worker, and the hidden iframe a streamed
download needs is a navigation. On Firefox and Safari that is the only way to
write a file too large to hold in memory, so the download cannot happen at all,
for the life of the page.
Being controlled is not being servable, so priming now asks the question
directly instead of inferring it: a four-byte stream and a hidden iframe,
exactly as a real download would, torn down completely so nothing lands in the
download folder. When it goes unanswered the page reloads once, ordinarily,
which puts it back under the worker. The flag lives in sessionStorage rather
than a variable because it has to survive the reload it triggers, and because a
page that is still unservable afterwards must stop rather than loop.
Also stops telling people to change browser. The message said "use the desktop
app, or Chrome or Edge" for a state an ordinary reload undoes, on the one path
Firefox has no alternative to; all ten catalogues now say to reload first.
The hard reloads were on my instruction: the SPA's HTML is served `no-store`,
so a plain reload has always picked up a new build and Ctrl+F5 was never needed.
Hub suite 834 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"The node saw no transfer" was concluded twice from a journal that could not
have shown one: the open was logged at DEBUG, and the daemon runs at INFO. Two
diagnoses were built on that non-observation, and both were wrong.
Turning the root logger up to DEBUG is not the answer either — aiortc logs every
SCTP chunk, which on a 2 GB download is both unreadable and slow. One line per
transfer is not a volume problem, and it is the line that answers "did the
client ever ask for a slot, and what was it told".
Node suite 1172 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Four downloads on Firefox sat at "preparing" indefinitely, with the target queue
already bypassed there, so each opening was hanging on its own. The node journal
showed `d=0/8(q0) u=0/8(q0)` — no transfer had been asked for yet.
`_claimController` had two waits with no deadline at all,
`navigator.serviceWorker.register()` and `navigator.serviceWorker.ready`, while
SW_CONTROL_BUDGET_MS bounded only the wait that comes after them. `_swPromise`
is shared, so one unsettled wait left every download on the page suspended on
the same promise for the life of the tab.
Measured on Firefox 154, against a local 127.0.0.1 site so no hub was involved:
a worker that installs gives register() in 8ms and ready in 0ms; a worker whose
install handler rejects gives register() in 7ms and a `ready` that never settles
— still pending past ten seconds. register() resolves as soon as the
registration object exists, carrying nothing but an *installing* worker; ready
is what waits for an active one.
Every wait is now inside one budget, with two carve-outs so that a deadline
never costs a capability. A `ready` that times out while registration.active is
set is not fatal: ready may be waiting on a newer worker that cannot install
while an older one serves perfectly well. And the mbdl-claim recovery keeps its
own budget outside the deadline, because giving up there would cost Firefox the
only unbounded way it has to write a download to disk.
A deadline alone would have been a better-explained failure rather than a fix: a
registration stuck with nothing but an installing worker does not heal, and
every later visit finds the same one. So when ready times out with no active
worker, the registration is discarded and asked for once more with a fresh
budget, and the page repairs itself instead of needing developer tools.
Four cases pinned, each checked against the unfixed source. Hub suite 830
passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Four downloads on Firefox all sat at "preparing", with the node journal showing
`d=0/8(q0) u=0/8(q0)` — not one transfer opened, so nothing had got past the
client's target opening.
Serialising those openings was new in d6c4808, and on Firefox it regressed what
had always worked: four openings that ran at the same time began waiting on the
slowest. `_targetQueue` is module-level and never reset, so an opening that
never settles leaves the page unable to start any download again until it is
reloaded.
Two bounds, both narrowings of the queue rather than of any capability:
Only an opening that could actually show a dialog joins it. Firefox and Safari
have no `showSaveFilePicker` at all, so nothing there can race anything and the
queue bought nothing while costing everything; they now bypass it entirely,
which restores the previous behaviour by construction rather than by tuning.
And no opening waits behind another for longer than TARGET_QUEUE_BUDGET_MS
(90s) — generous enough never to cut in front of a real dialog, finite because
the alternative is a download panel that only a reload can fix. Releasing early
is safe: whatever is ahead is still the only unbatched opening, so the released
one takes the streamed path and opens no second dialog.
Measured on Firefox 154 against the deployed hub before writing any of this:
`register` and `ready` return instantly, the page is controlled, and four
serialised openings are served in 5-18 ms. The streamed path was never the
delay; the queue was.
Both new cases were checked against the unfixed source: without the bypass the
peak concurrency is 1 instead of 4, and without the budget the stuck-opening
case hangs. Hub suite 826 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Selecting four files on Chrome produced a Save As dialog for the first, then
one for the second only after that file had finished, while the last two timed
out; on a later attempt the three remaining transfers appeared frozen.
Two things were going on. Opening the target inside `prepare` had removed the
accidental serialisation that `for (…) await downloadFile(e)` used to provide,
so `_openTargetInTurn` now queues the openings — but a queue whose head is an
unanswered dialog is a head-of-line block, which is what the "freeze" was.
The code already recovered from a picker with no gesture behind it by streaming
instead, on the `SecurityError` Chrome throws. That branch was never reached:
Chrome does not throw, it shows the dialog anyway and waits for a human. So
anything that has to wait its turn is now marked `batched`, and a batched
opening prefers the streamed path whatever the download mode says. The first
file of a batch — the one actually holding the gesture — still gets its dialog,
so the preference is honoured where it can be. For the rest there is no gesture
left to spend and nothing is lost by streaming: the file still lands on disk,
in the browser's own download folder. Only the choice of folder goes, and it
was not on offer. If the worker does not answer, a batched download falls back
to the dialog rather than failing.
Also logs which path led to a dialog. A dialog is the one outcome nobody can
diagnose after the fact — it looks the same whether it was asked for or fallen
back to — and the report this fixes needed three test cycles to narrow. The two
harnesses that lift `_openDownloadTarget` as text now route console.info to
stderr, since they parse stdout as JSON.
Measured against the deployed hub in Chrome 152: the streamed path serves the
hidden iframe in 2-3 ms on a normal load, after a hard reload (via the
`mbdl-claim` recovery already in `_claimController`), and twice in the same
document. Hub suite 824 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three defects in the probe, found while extending it to cover the per-member
hot-swap. The third is the one worth keeping.
`transfer_state` is the reply to an open, the acknowledgement of a close, and
the push that carries a grant minutes later. Reading "the next one" therefore
returns somebody else's answer as soon as more than one transfer is in play —
the probe took two stale `closed` acks as the replies to two opens and reported
a working cap as broken. That is exactly the defect `req_id` exists for in this
protocol, committed inside the tool written to check it. Replies are matched on
`tr` now.
The other two: the `transfers show` parser counted the pool summary line as a
lease once that command grew a per-group section (a probe that reads a
human-facing format signs up for this), and the per-member check began with a
member who already held several leases, which measures nothing. It waits for
the operator's own view to go quiet first — waited for, not slept through.
Both probes written today reproduced a bug already recorded in CLAUDE.md: this
one, and yesterday's timer with no strong reference. A tool that verifies the
code is not exempt from the code's rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Raising the per-member cap from 2 to 4 left both waiting transfers on
"waiting". The pool granted them correctly and nobody told the peers:
`ops.set_transfer_limits` computed `granted` and never sent a `transfer_state`,
where the node-wide path (`WebRTCTransport.set_capacity`) does.
That is the first row of §5.2 of ~/next/improve-downloads.md — "node granted a
slot, the push was lost" — reached by writing the decision and forgetting the
send. It is the same omission as the missing `touch()` call one layer up, on the
same day: a mechanism that is right everywhere except at the seam where it has
to reach somebody.
The client recovered after its 60-second watchdog re-asked, which is why this
looked like a slow queue rather than a lost message.
`transfer_probe.py --operator` covers it now, separately from the node-wide
hot-swap it already covered — different door, different code path, and only one
of them was tested. Verified failing with the push removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported as "the slots seem hardcoded to 2": `meshbay-node transfers set 8 8`
and still only two downloads at a time. Not hardcoded — that is the *per-member*
cap, which is a group's setting and is checked before the node's, so raising the
machine's total cannot move it. But the diagnosis was right in the way that
matters: nothing could change it.
`OP_TRANSFER_LIMITS` shipped with exactly one front door, the signed MNP
handler, and nothing anywhere opened it — no client call, no CLI verb, no
loopback route. So the cap sat at its default of 2 for ever, which from outside
is indistinguishable from a constant. CLAUDE.md states the rule this missed:
operator operations are one implementation with several front doors.
- `PUT /api/groups/{id}/transfer-limits`, calling the same
`ops.set_transfer_limits` the signed handler calls;
- `meshbay-node transfers per-member <downloads> <uploads> [--group X]`;
- `transfers show` now separates the node-wide pools from the per-group
per-member caps, and marks each `[set]` or `[default]`. It printed "2 per
member" with no indication of where the 2 came from, which is half of why
this looked like a constant.
Zero is refused here as everywhere else: it is not "unlimited", and a member who
may not transfer at all is a member the operator revokes.
Verified on a live node: the cap changes, survives a daemon restart, and
`transfer_probe.py --want 6` measures 4 granted against a cap of 4 where it
measured 2 before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Clicking Download produced nothing — no row, no icon, no panel — for as long as
it took to open somewhere to write, and then several rows at once. The streamed
path waits for the worker twice; a Save As dialog waits for a person. The row
was created after that, so the slowest part of a download happened with nothing
on screen to say it had begun.
The store gains a `prepare` step, distinct from `run`, and the order is now:
row, then target, then slot.
That last part is why the obvious fix was wrong. Taking the slot first would let
the row appear immediately, and it was tried this morning: a granted slot has to
be taken up within the node's deadline, opening a target can outlast it, and
three downloads became one. (The diagnosis at the time blamed that ordering for
revocations which were in fact a missing `touch()` call — the revert was right
for the wrong reason.) `makeLease` is called after `prepare` succeeds, never
before.
Three behaviours fall out, each with a test:
- a dismissed dialog leaves nothing behind. `prepare` returning false drops
the row: nothing started, so nothing should remain on screen to explain it;
- the row takes the name the file was actually saved under, once known;
- a refusal above the memory ceiling fails the row that is already there,
rather than creating one to kill it.
`preparing` counts as live everywhere — badge, cancel, clearFinished, and
`_busy`, since closing a transport under a preparing transfer strands it exactly
as under a queued one. Six places asked "is this finished?" and were drifting
apart; there is one definition now.
Two mistakes in the tests, worth the note: one counted positions in an output
array by hand and was one out, which reads exactly like a failing assertion
about the code — the values are tagged now, not indexed. And
test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the
size check the file is about; it now behaves like the real store.
819 hub, 1169 node, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`--operator` closes the last two items of the live pass, from the side the
client cannot see:
- **a cap raised live starts what was waiting**, with no restart and no
reconnection. Draft-v6 §2.11 promises this and it was false for months:
`ops.set_node_settings` hot-swapped by assigning `webrtc._stream_sem`, an
attribute that has never existed. Now it goes through `set_capacity`, and
this is what says so from outside;
- **a vanished peer's slots are back before anyone asks.** §5 of the plan
makes that a hook on the connection rather than a timeout, and the
difference is two minutes of a node that looks full.
Plus the operator's view of the queue itself, which is the only window into a
transfer stuck at "waiting" — and which reported the module defaults instead of
the operator's values until this afternoon.
Two mistakes in the check, none in the code, and the second is worth keeping:
the first version set the node cap and the member cap both to 2, so one account
holding two transfers hit both at once. Raising the node-wide cap then correctly
changed nothing — per-member is checked first, by design — and the probe
reported the design working as a failure. It now puts the node cap below the
member cap so the queue is held by the machine, which is the only arrangement
where this can be measured at all.
The cap is restored to whatever the node was running before the probe touched
it, not to a default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It lived in `QE/`, which is deliberately not versioned — credentials and test
artefacts go there — so a tool that found several defects no test in the suite
could reach existed on exactly one machine.
What it found, none of it reachable from pytest: a cap that was never enforced,
a queue that granted a slot and never told the peer waiting on it, leases that
outlived the session holding them, and `transfers show` reporting the module
defaults instead of the operator's own values.
Not collected: the filename does not match `test_*.py`, and that is the point.
It talks to a real hub with real credentials and takes minutes; what belongs in
the suite is already there.
It still needs two things from `QE/`, which stay out of the repo: `e2e.py`, the
second implementation of the client whose `Client` speaks MNP over a real
WebRTC DataChannel, and `demo.env`. Both are located at run time and their
absence is explained in a sentence rather than raised as an ImportError from
four frames down.
Verified from the new location against the live node: 4/4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots
since step 2 and nothing asked for one; now the client does, and the panel shows
what is happening.
`transport.openTransfer()` returns a Lease: `acquire()` resolves when the node
grants, `release()` gives it back exactly once, and nothing else in the client
speaks to the node about slots. Whether a node hands out slots is read from the
handshake ack rather than guessed from a timeout — "no answer yet" and "this
node will never answer" are indistinguishable in time, and guessing wrong either
stalls every download or defeats the cap.
Two things exist only because a queue can lie: a watchdog re-asks when a pushed
grant does not arrive (the node is idempotent on `tr`, so asking again is free),
and a grant for a transfer the page has forgotten is handed straight back rather
than held until the node's deadline.
The slot is asked for **after** there is somewhere to write, and that ordering
is load-bearing: opening a target takes thirty seconds of streamed-download
timeouts, or as long as somebody leaves a Save As dialog open, and a grant not
taken up in time is revoked. Moving it earlier looked better and broke three
downloads into one. Pinned by a test.
The panel groups by state — running, waiting, finished — rather than re-sorting a
flat list, so a row moves only when its own state does. The ETA is withheld until
the speed window holds real measurement: a figure from the first two chunks
swings between four seconds and an hour, and people plan around the first number
they see. One live region announces state changes and not progress.
Three silent paths closed on the way: a download refused for want of a user
gesture (a browser grants one file picker per gesture, and downloading three
files is one gesture) now falls back to the streamed path, which needs none; a
click with no connection says so instead of doing nothing at all; and a queued
transfer counts as busy, so a transport is never closed under one that is
waiting for a grant that could then never arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects in the lease machinery, both found in the node's own log, neither
reachable from any test on either side.
**`touch()` was never called.** The node ignored `tr` on `file_req` entirely, so
`used` stayed False for every download ever made and the sweeper revoked each
grant thirty seconds in — while the file was transferring at 20 MB/s. The pool
was correct and the handlers were correct; the call between them was missing,
which is why neither side's tests could see it.
**The requeue was a permanent cycle.** A revoked grant went back in the queue,
was granted again a millisecond later because there was room, and was revoked
again thirty seconds on. The node logged the same two reclaims every thirty
seconds for as long as it ran — minutes after the transfers involved had
finished. Three chances now, then the lease is closed and the peer told.
`test_the_counter_never_drifts` could not have caught it: nothing drifted, the
same lease simply never left.
A lease that starts being used forgets its earlier misses: a client that took
two grants to get going is slow, not abandoned.
Also `transfers show` reported the module defaults rather than the operator's
values until something had transferred, so `transfers set 2 2` answered
"applied now" and the next line said 0/8 — indistinguishable, from outside,
from the hot-swap that did nothing for months. The test asserted the defaults
and so agreed with the bug; found by typing the command.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A download froze part-way through, on Firefox, with an empty console and a node
that stayed perfectly healthy. Three separate measurements cleared the node
(615 MB pulled whole over MNP), the transport (three files interleaved on one
connection, 1.5 GB, all whole) and the service worker (three concurrent 150 MB
streams in real Firefox 154) — because none of them was wrong.
The empty console was the evidence. `_sendAndWait` logs every timeout, so no
chunk request had expired: the client was not waiting on the node. Of the three
awaits left on that path only one was unbounded.
**A service worker with no event for about thirty seconds is terminated**, and
`respondWith(new Response(stream))` does not extend its life while the response
is still being written. The reader vanished mid-file and `writable.write()` then
never resolved and never rejected — no error, no log, no failed transfer, just a
progress bar that stops. The first stress probe wrote 450 MB in two seconds and
passed: fast enough to hide it entirely.
Measured in Firefox 154, writing 1 MB every 2 s: without the ping it stalled at
17 MB after 59 s; with it, 40 MB in 80 s, complete.
- the page pings the worker every 10 s while it writes, and the worker
answers. Receiving a message is an event, and an event resets the timer;
- that interval stops itself after two minutes with no write. A target can be
opened and never written to — a transfer cancelled while it waits for a slot
never runs, so nothing calls close() or abort() — and a timer nobody clears
pings for the life of the page. It also kept the Node test process alive for
ever, which is the same defect wearing a louder symptom;
- `writable.write()` is bounded at 60 s and fails with a message naming the
chunk. That does not fix whatever stopped a sink; it turns an unexplainable
freeze into a failed transfer that says so, which is the difference between
a mystery and a bug report.
Also: `Content-Disposition` lost a filename to a single apostrophe.
`encodeURIComponent` leaves `'` alone and `'` is the delimiter in RFC 5987's
`filename*=<charset>'<lang>'<value>`, so the header became unparseable and
Firefox named the file after the URL — 449 MB of film arrived complete as
"mtsshk9w-ohqty535". `(`, `)` and `*` get the same treatment, and a plain ASCII
`filename=` rides alongside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three headers govern whether a page may be framed, and all three had to be
wrong for the streamed download to work — so fixing them one at a time cost an
afternoon of redeploys and retests. They were visible together in a single
`curl -I` against the deployed hub, which is where this should have started.
The streamed-download path navigates a hidden iframe to `/_mbdl/<id>` so the
service worker is asked for the response it is holding. On Firefox and Safari
that is the only way to write a large file to disk: neither has the File System
Access API, and OPFS is capped at 10% of the volume's size (measured on Firefox
154: 389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte),
which a film exceeds.
- `frame-src` was reCAPTCHA's two origins with no `'self'`, so the frame
could not be loaded at all. Added when the captcha needed a frame; nobody
connected the two.
- `frame-ancestors 'none'` forbids all framing, this origin included.
- `X-Frame-Options: DENY` says the same in an older dialect. The spec says a
browser must ignore it when frame-ancestors is present — relying on that
while shipping a header that contradicts our own policy is asking to be
surprised, and we were: the CSP was fixed and the download stayed broken.
`'self'` and `SAMEORIGIN` refuse every foreign origin exactly as `'none'` and
`DENY` do. The clickjacking property is untouched; what they additionally allow
is this origin framing itself, which is the only thing the download needed.
Pinned three ways: `frame-src` must carry `'self'`, `frame-ancestors` must be
`'none'` or `'self'` and never name an origin, and the two framing headers must
agree — the defect was the disagreement, and either one read as correct alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 3 of ~/next/improve-downloads.md. Step 2 built the pools with constants;
this gives them to the operator, in the two scopes they belong to.
**The pools are the machine's.** `[node] max_concurrent_downloads` and
`max_concurrent_uploads`, default 8, on the §2.11 pattern: node.toml for a
fresh install, a roster.db override for immediate effect, editable from the
Node page and from `meshbay-node transfers show|set`, applied live through the
one `set_capacity` step 1 fixed.
**The per-member cap is a group's.** How many transfers one member may run at
once here — on the node like every other group setting (not the hub, which
would have authority over someone else's disk; not node.toml, which is
hand-written and needs a restart), changed by a signed operator instruction
(`OP_TRANSFER_LIMITS`, subject "d=2,u=2" so what is signed names the outcome),
broadcast to the group, and read live by the pools.
That was the one thing step 2's shape could not express: `per_member` was a
single node-wide number. `group_limits` and `member_cap(kind, member)` make it
a lookup — the group's own value if it has one, the node's default otherwise —
and it is deliberately the only dimension that is not node-wide.
Three refusals, each with a test:
- **absent means the default (2), never "unlimited".** A group that predates
the setting coming back unlimited would leave the node-wide pool as the only
control, which is the situation slots exist to end;
- **zero is not "unlimited"**, and is not "this member may not transfer"
either: the floor is one everywhere, and the CLI says to revoke the member
instead;
- **an unreadable row reads as unset**, not as zero — the same discipline the
sealed messages follow, where a payload that does not open must never become
a default state on its own.
`handshake_ack` carries this member's own caps for this group, so the interface
can say "2 of your 2 slots are busy" instead of drawing a bare spinner. Absent
reads as "no limit known" and the hint is not drawn — never as "unlimited",
which would have the interface contradicting the node.
1164 node, 793 hub, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Step 2 of ~/next/improve-downloads.md. A download is invisible to the node: it
is a series of independent `file_req` messages, with nothing saying one started
or ended, so there is nothing to count and nothing to cap. The lease is that
missing object.
`meshbay_node/transfers.py` holds the decisions and has no asyncio and no
transport in it, on purpose. The failure modes this has to survive — a slot the
node never gets back, a client waiting on a grant the node has forgotten — are
races through a DataChannel and unprovable there; here the clock is a parameter
and every method returns what changed, so the caller does the I/O and the tests
drive the worst case directly.
What it decides:
- two pools, downloads and uploads, separate from the stream pool: different
resources with different costs, and merging them makes both caps meaningless;
- per-member cap checked *before* the node-wide one, so a member at their own
limit queues behind their own transfers rather than holding a slot a second
member has none of. Per account across their devices, or the cap becomes a
function of how many tabs somebody opens;
- a queue that skips a member at their cap instead of waiting for them —
granting strictly in arrival order lets one member's limit stall everyone;
- `tr` drawn by the client and idempotent, which is what makes a reconnect safe;
- bounded per member, because unbounded queues are how a node runs out of
memory politely.
Every way a slot comes back, with the session teardown as the one that matters
(a closed tab, a quit browser and a dead network all arrive at
`shutdown_tasks`, and none of them needs a timer): explicit close, session
gone, a grant nobody took up in 30 s passed to the next in line, and a granted
transfer silent for 120 s reclaimed with its peer told, so a widget can offer a
resume rather than sit on a lie.
`GET /api/transfers` is the operator's window: when somebody reports a transfer
stuck at waiting, it is the only thing that says whether the node ever had them
in a queue — a log cannot, when the symptom is that nothing is happening. It
carries no filename and no path, which a test pins, because this is exactly
where one would be tempting.
Three things found while writing it, two of them mine:
- the randomised property test rejected `in_use <= cap` at once, and it was
right to: lowering a cap never interrupts a running transfer, so the count
legitimately sits above the new value. The invariant is that a *new* grant
never happens past the cap;
- the sweeper was started with `self._spawn`, which ties a task to one
session's set. It died with whichever peer opened the first transfer, and
every other peer's abandoned lease then stopped being reclaimed — a node that
fills up over days with nothing in the log. It belongs to the node now, with
its strong reference on the transport context;
- the pools are node-wide while `_peer_registry` is per group (finding H1), so
a slot freed in one group can grant one in another and the peer to notify is
not in the notifier's registry. Silently wrong in the first version.
Nothing enforces a lease yet: `file_req` is untouched, no client asks, and the
node grants everything. That is step 4's flag day, and this lands alone.
1148 node, 793 hub, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`ops.set_node_settings` hot-swapped the stream pool by assigning
`webrtc._stream_sem`. That attribute has never existed on WebRTCTransport — the
pool is `ctx["_transcode_sem"]` — so `hasattr(webrtc, '_stream_sem')` was always
False and the branch never ran. The setting was accepted, written to roster.db
and node.toml, and applied only on the next restart, which is exactly what
draft-v6 §2.11 says it does not need. An operator lowering the cap on a
struggling machine, or raising it after "Server busy", saw nothing happen and
had no way to find out why.
`WebRTCTransport.set_capacity()` is the one implementation, on the object that
owns the state, so the download and upload caps the transfer-slots plan adds
next do not each grow their own copy of the mistake.
Resizing has semantics worth stating: the new cap governs new streams and never
interrupts one that is running, because a slot is held for the length of a film
and lowering a number must not take somebody's film away. The replacement pool
is built with the permits that remain (`new - in_flight`, floored at zero) — a
full set would briefly allow more concurrent viewers than either the old cap or
the new one.
That needs a count of slots in use, so `_stream_video` now maintains one instead
of the code reading the semaphore's private `_value`: a number this code keeps
itself survives the semaphore object being replaced underneath it, and the same
counter makes the "N of M in use" log lines mean something.
test_stream_capacity.py drives the real transport and the real `_stream_video`;
`test_ops_calls_the_real_mechanism` fails if the dead attribute comes back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Nine of the eleven were defects in the suite, two were assertions describing
behaviour the code had deliberately changed. None was a bug in the node.
Seven had one cause. `check_media_tools()` writes two module globals;
`monkeypatch` restores what a test patched and knows nothing about what the
call under test then wrote, so a test that pointed `shutil.which` at
"/opt/bin/{n}.exe" left `_ffprobe_path` there — a Windows path, on Linux — for
the rest of the session. Every later test that actually runs ffprobe died on
FileNotFoundError, in two files about video transcoding, for a reason nowhere
near themselves. Run those files alone and they passed; that is what made it
look like an environment problem for so long.
The autouse `_restore_media_tool_paths` fixture in conftest.py puts both back
after every test. That closes the class, not just this instance: any future test
that resolves media tools is undone whether it remembers to or not, which is the
only way an order-dependent suite stops being one. Verified by removing the
call-site guard entirely and running the whole suite — green, so the fixture is
carrying it, and the call site keeps a pointer rather than a second copy of the
explanation.
The other four:
- two service tests were the only ones in test_platform.py that never set
`sys.platform` to "win32", so they hit "service mode is Windows-only";
- test_apps_enabled_policy expected `["chat"]` where `roster.enabled_apps`
inserts "files" at the front on read (and `ops.set_enabled_apps` on write),
because Settings is the one way back if every app were turned off. The code
is right; the assertion predates the guard, and is now ["files", "chat"];
- test_invite_then_join_delivers_the_gek passed a bare Path as a group's
`roots` two lines below building a RootSet for the transport. The handshake
died on `'PosixPath' object has no attribute 'describe'` and answered `error`
— scaffolding that never followed the move to several named roots (draft v6,
change 1).
1081 passed, 4 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`thumbs` holds every generated thumbnail, every TMDB poster and backdrop, every
Cover Art Archive image and every cached audio transcode. Rows were removed only
when their source file left every group's index (`prune_file`), so a library
that merely changes over years grew this database with nothing to bound it.
Nothing in it is precious — every row is keyed off a value the node can
re-derive — which is what makes eviction the right answer rather than a bigger
disk.
512 MB, evicted on write (a cache only grows when written to; a timer is one
more thing to own and get wrong). `used_at` is marked on every read, including
the lookup by synthetic id that `_fetch_and_cache_poster` makes on every visit
to a poster grid — without that, the images shown most often would be the
coldest rows in the table. A single blob larger than the cap does not empty the
table for nothing.
The migration is the part that touches deployed nodes. `CREATE TABLE IF NOT
EXISTS` adds missing tables and never missing columns, so `used_at` would have
reached a fresh test database and never a real one. `_migrate()` does the
ALTER TABLE and seeds existing rows with "now" rather than 0 — otherwise the
first write after an upgrade evicts the whole cache, a correct-but-hostile
reading of "least recently used" for rows whose age nothing recorded.
The index on that column lives in `_migrate()`, not in `_SCHEMA`: run from the
schema script it executes before the ALTER on an existing database and fails,
which would have been every deployed node refusing to open its cache on the
first start after upgrading. Found by the migration test.
Verified against a real node's database, rebuilt into its pre-migration shape:
rows preserved, column present, seeded, index created, reopening harmless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`save:abort` deleted a cancelled download, but nothing covered the application
being quit, killed or crashing mid-transfer: the write stream was abandoned and
a truncated file kept the final name — the exact thing save:abort's own comment
calls worse than no file at all, because it looks complete to whoever opens it
next.
Downloads go to `<target>.part` and are renamed after the stream has flushed,
which is the convention the node already uses for uploads (`_do_file_upload`).
A crash now leaves a self-evidently unfinished file. `before-quit` also clears
any `.part` still open, synchronously — it does not wait for promises — so a
deliberate quit leaves nothing at all.
Verified by hand: .part during the transfer, survives SIGKILL, renamed on
completion, and gone after a cancel or a clean quit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`terminate()` signals the parent only. Chrome's zygote, renderer and gpu
children outlive it by a moment and go on writing into the profile, so rmtree
walked a directory that gained a file between its readdir and its rmdir and
raised "Directory not empty". The probe exited non-zero, and every test in the
file errored at setup — intermittently, roughly one run in three, for a reason
nowhere near the chat code they were testing.
TemporaryDirectory(ignore_cleanup_errors=True) in all four probes that own a
profile: a few bytes left in a throwaway directory are harmless, failing the run
is not. `proc.wait()` after `kill()` was also missing — a killed process still
has to be reaped.
layout_probe.py never cleaned up at all (mkdtemp, no removal) and never waited
for Chrome; it leaked a profile into /tmp on every run.
Ten consecutive runs of test_chat_send.py are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`pipelinedDownload` with no writable allocates `new Array(totalChunks)` and
keeps every decrypted chunk, so whatever `_openDownloadTarget` returns null for
is held whole in RAM. That floor had no upper bound: the
`!window.showSaveFilePicker` branch returned null at any size, so on a browser
without the File System Access API a 20 GB film went to memory whenever the
streamed path did not answer. Nothing logged, nothing refused; the symptom was
the tab dying, with no error attributable to this code.
MEMORY_CEILING is 100 MB and every `return null` in that chain now goes through
a guard that throws above it. The refusal names the size, the limit and why the
streamed path declined, and lands in the transfers panel as a failed transfer
rather than in a console nobody opens.
This is a guard, not a limit on what can be downloaded: with the streamed path
primed and retried (previous commit), a file of any size still goes to disk
progressively on every browser. Two things had to change for that to be true:
- the streamed path is now tried in "ask" mode too, for a file over the ceiling
on a browser with no Save As of its own. The mode decides whether to show a
dialog; it was silently deciding whether a film could be downloaded at all;
- FilePreview had no size check whatsoever — a multi-gigabyte PDF or .csv was
fetched whole, and the text branch decoded all of it to keep 500 000
characters. It refuses above the same ceiling and offers the download.
ZIP_MAX_BYTES (512 MB) and the ceiling do not contradict: the archive limit
bounds the archive, the ceiling bounds what may be built in the page, so a
400 MB zip is allowed when there is somewhere to stream it and refused when the
only route left is memory. The build-in-memory confirmation only appears below
the ceiling now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
On Firefox and Safari the service worker is the only unbounded way to write a
download to disk: neither has the File System Access API, and OPFS is not a
substitute — measured on Firefox 154, its quota is exactly 10% of the volume's
size (389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte),
which a film exceeds. So when this path declines, a large download has nowhere
left to go, which makes its reliability a correctness property.
Four ways it declined, all of them avoidable:
- it was registered inside the first click on Download, so that click paid
install, activate and claim while somebody watched a button do nothing;
- `_swReady` cached a null for the life of the page. One slow first click left
the tab unable to stream anything again, curable only by a reload nobody knew
to do. Only a successful controller is remembered now;
- control was waited for with a 3 s cap. It is 15 s, and a page that is active
but not controlled asks the worker to claim again (`mbdl-claim`) instead of
declaring the path unavailable;
- a missed navigation gave up at once. It gets a second attempt with a fresh id
and iframe, the failed one torn down completely first.
Also closes a MessagePort leaked per download, and gives the reason a name
(`lastStreamFailure`) so a refusal can say what happened. The timeouts became
parameters: the defaults are the production values, no caller passes any, and
the tests do not spend a minute waiting.
`openTarget` gets an unrelated but adjacent fix, in the same file: it creates
the destination with `getFileHandle({create: true})`, so an empty file exists
before the first byte, and `abort()` leaves the target untouched — every
cancelled download left a 0-byte file behind, and since `freeName` avoids
collisions, three cancels left film.mkv, film (2).mkv and film (3).mkv, all
empty. Its `abort()` now removes the entry. Safe here and only here, because
`freeName` guarantees the name was not taken: the `showSaveFilePicker` path
must not do the same, where the person may have picked an existing file whose
contents `abort()` correctly preserves. Verified by hand in Chrome.
test_streamed_download_reliability.py runs the real module under Node against a
stubbed browser — it fails if the null is cached again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|
| |
|
|
|
| |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ensureTray() was reachable only from the window:minimize-to-tray handler, so
the indicator did not exist until you had already hidden the window into it.
That is backwards on both desktops — most of what a tray is for is finding an
application that is not in front of you — and on Windows it read as the app
having no tray presence at all.
Created during app.whenReady(), after registerBridge() and before
createWindow(). The order matters: buildTrayMenu reads the nodeService that
registerBridge assigns, so the other way round puts the Start/Stop entry on
the menu one five-second poll late.
The menu's labels were the one thing that came *from* the minimise call, since
the main process has no i18n. A new tray:labels IPC (platform.setTrayLabels)
carries them instead, sent from the renderer's boot once initLocale() has a
catalogue; a language change reloads the page, so the same call covers it. The
window between launch and that first message shows TRAY_FALLBACK, in English.
§5.10's two platform gates become one — trayOS() in main.js, which every tray
path calls. test_desktop_shell.py's existing test is rewritten against it and
two are added: the launch ordering, and that no tray path tests
process.platform inline instead of calling the gate.
Windows still files a new tray icon under hidden icons until the person drags
it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11
rather than worked around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The flags were regional-indicator pairs, which Windows has never shipped a
glyph for: Segoe UI Emoji renders the pair as its two letters, so the menu
read "GB", "FR", "NL" down the left-hand side instead of a flag. It only ever
looked right on a system with an emoji font that draws them, such as GNOME's.
Names only, rather than vendoring ten flag images. A flag is a country and
not a language anyway — none of them is the right answer for "Português
(Brasil)" — and each name is already written in its own language, which is
the self-identifying signal the flag was standing in for.
The `flag` field is removed from LOCALES rather than left unread; app.js's
menu was its only consumer. The submenu keeps its indent from
.user-menu-sub's padding, not from the icon box that is now gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"private" and "invite" were on every card because they are the default for
every group on the hub — two badges that never varied and so never told
anyone anything. Who hosts a group does vary, and it is the thing that tells
two groups of the same name apart: the hub enforces name uniqueness per owner
account, not globally.
- The "My groups" cards (app.js) drop both badges and render the name through
GroupName, which puts the owner beside it as a smaller "@handle" — the same
way the group header and Explore already write it. "admin" stays; it varies.
/v1/groups/mine already returns owner_username, so no API change.
- Explore drops its join_policy badge for the same reason. An open group still
announces itself through the Join button, which is the useful half, and its
@host was already there.
- .group-card h3 .gn-owner is set to 0.8em: a card's h3 is reset to 1em, so
the 0.55em meant for a page heading rendered the handle at about half the
body size — smaller than the name, as it should be, but past readable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It is the one section in group settings that is open on arrival while having
nothing to say in the ordinary case: the device you are reading it on is
already linked, and the panel exists for two rare errands — approving another
device, or removing one.
Uses the CollapsibleSection the sections around it already use, with
defaultOpen={false}; the component renders the same .settings-section
wrapper, so nothing about the layout moves.
The prompt that *is* actionable — "this browser is not linked to this node
yet" (device.add_title) — lives in group-page.js and is untouched, so a
browser that genuinely needs linking still says so on arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An arbitrary ceiling, not a technical one: the zip writer streams and holds
one chunk plus a record per file, so it would happily produce a hundred
gigabytes. Past half a gigabyte the honest answer is a subfolder at a time,
or the files individually.
Enforced in file-utils.js's downloadDirectory, which is the one
implementation behind every zip button — Files' single folder, Files'
multi-folder selection, and the Photos album button (docs/photos.md §3).
- Per directory, not per selection: Files zips a whole multi-directory
selection in one click, so an oversized folder is refused and its siblings
still download.
- Before _openDownloadTarget, so no save dialog opens for an archive that is
never going to be written.
- The bound is strict, so a folder of exactly 512 MB still goes through.
- Counted in the 1024-based units formatSize already prints, so the number in
the refusal is the number in the constant.
group.zip_too_large in all ten catalogues. test_zip_size_limit.py runs the
module under Node and pins the refusal, the inclusive bound, and that nothing
is asked or started when a folder is over.
The user guide's "a 40 GB folder costs 40 GB of disk" is no longer true and
now documents the cap instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The dashed warning outline and the "?" flag say a TMDB lookup ran and came
back with nothing — that is how an operator finds the titles worth a "Fix
match". With TMDB switched off for the group no lookup is made at all:
media_meta_req answers confidence 0 for the whole library by design, so the
poster grid drew every card as a failure of the very thing the operator
chose, with no poster ever coming to clear it.
Poster mode now shows the file's own thumbnail plainly in that case, the
same as Flat does, and keeps the badge for what it was built for.
- PosterCard takes tmdbEnabled and gates both the outline and the "?" on
`tmdbEnabled && !confident`. The title still falls back to the parsed
filename exactly as before, and the ready/imageReady anti-flash gating is
untouched.
- VideoDetailModal gates its "no confident match" paragraph the same way. A
show still opens that modal with TMDB off — it is where the season list
lives — so the card alone would have left the claim one click deeper.
- Its operator-only "Fix match" / "Rematch" buttons are gated too: both act
on a match that does not exist, one opening a search the node answers
empty, the other dropping a cached match never made. Those buttons are the
only entry points to TmdbSearchOverlay and doRematch.
- Both props default to true, so a call site that forgets one keeps the badge
rather than silently losing it.
search-page.js is deliberately left passing `enabled: true`: a cross-group
view has no single group's switch to read, and it never showed the operator
buttons anyway (isNodeAdmin={false}).
Recorded as §10.7 in docs/mediacenter.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Found on two live machines within minutes of deploying: every message from the
person running the node arrived at every other member under "this account is
using a key you have not seen before".
An operator's authority is node-wide and is recorded in `members` with an
**empty** group_id — `is_authorized` has always said so, in a clause written for
exactly that. `group_devices` spelled the rule out a second time as
`WHERE m.group_id = ?`, which excludes them. So the operator was absent from the
roster relayed to members, no chain could reach their device key, and Tier 2
reported the most ordinary event there is — the operator talking in their own
group — as a key substitution.
A notice that fires on normal use is worse than no notice: it is the one people
learn to dismiss, and §4.8 budgets exactly one for the whole feature. That makes
this a defect in the property, not only in a query.
The clause now lives once, as `_MEMBER_OF_GROUP`, shared by both callers so they
cannot drift again. `DISTINCT` because an operator who is also an explicit member
of the group matches both halves of it. `get_member` is untouched: it is a raw
lookup and its callers already fall back to `get_member("", user_id)` themselves.
Three tests, and the first fails against the old query with the reported
symptom: the operator appears in the roster of a group they host and
`is_authorized` agrees; an operator who is also a member is listed once; a
revoked one comes back through neither.
No stored state to clean up — nothing was written to a client's pins when the
account was missing, so the notice stops as soon as the node serves the roster
correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages have been signed by the sending device since MNP 2.0, but a
reader had no way to know that the device belonged to the account the node
named: the signature proved *a device*, and `sender_id` was still the node's
word. This closes that for any account a client has already seen.
**What was blocking it was not effort — the evidence was not being kept.**
`_do_device_add` verified the countersignature that admits a second device and
stored only `added_by_pk`: *which* key approved, never the proof. And
`device_add_transcript` binds `nonce_node`, the approving connection's handshake
nonce, so even a stored signature was unverifiable by anyone who had not been on
that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added
before the migration's early return — which fires on every roster widened since
2026-08-18, i.e. all of them, so putting them inside it would have meant they
never arrived.
`group_roster_req`/`resp` relays, sealed under a new groupbox purpose and
answered to **any member of the group**, every live device of every active
member with the evidence that admitted it. The node decides nothing: it hands
over evidence and the client walks the chain from each account's root outwards
(`_verifyRoster`). That is deliberate — the node is the party the property holds
against, so it is not asked to assert trust.
Two holes the tests caught while this was being built:
- "no signature" was being treated as a trust root, so a node that writes the
roster could put any key in an account's row and have it laundered straight
into the verified set. A root is a device that names **no** countersigner.
- pinning only the verified subset at first sight raised "key changed" on
legitimate second devices whose countersignature predates this change. First
sight pins everything the node says, because that is what trust-on-first-use
means and an alarm that fires on normal events stops being read.
The property, and it must not be rounded up: **once a client has seen an
account, a node that later substitutes a key for it is detected. Nothing is
gained at first sight**, where there is nothing to compare against — the same
boundary `per-node-identity-v1.md` draws, unmoved.
The cost, stated because it is real: the roster is member-visible, so every
member learns how many devices the others hold and their public keys. It stays
inside the group, the hub is not involved, and it is scoped per group. A member
who cannot see the keys cannot check them.
User-visible surface: one notice, "this account is using a key you have not seen
before", in ten languages. Nothing else.
16 tests — 7 on the node (the evidence is stored, it verifies from the roster
alone, a fabricated device carries none, another group's members are not
disclosed), 9 running the shipped `_verifyRoster` under node against rosters
built by the shipped Python: a chain of three in any order, a signature by the
wrong key, one for another node, one for another account, and two fabricated
devices signing each other admitting nothing.
Tier 3 (operator-signed roster attestation) stays deferred, with nothing
depending on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`chatbox.py` said per-device subkey derivation means "two devices never share
an AES key". That is false in the deployment that exists, and stating it hid
the reason the design is actually safe.
Two clients of one account on one node normally hold the **same** identity key:
a second browser fetches the keypair bundle from the node and recovers the
existing key rather than minting a new one, and so does a fresh Electron
install. Device linking — a distinct key, countersigned — is the exception, not
the rule, which is why nobody is ever asked to pin anything when they open a
second browser. So two clients routinely share a device key and therefore its
chat subkey.
What makes that safe is the nonce, not the derivation: 96 random bits, never a
counter. Two independent senders under one key collide only on the birthday
bound, unreachable at chat volume; two independent senders advancing one
*counter* collide immediately, which is precisely what C1 and §15.0b are about.
The true property is "no mutable sending state at all" — the hazard removed
rather than partitioned — and the design therefore degrades correctly into the
deployment as it is, where a chain-based one would have failed silently on the
day someone opened a second tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |\
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Both sides landed a breaking MNP change and both called it 2.0, which is right:
the sealed upload, the removal of `stream_seg` and mandatory chat encryption
share one flag day. They are recorded as one version in `__init__.py` rather
than as a race between two.
The resolutions that were decisions rather than mechanics:
* **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a
*confined* break — a 1.x peer could still connect, browse, download, stream
and chat, with only its uploads refused by `upload_not_sealed` — so the floor
deliberately stayed at "1.0". Mandatory chat encryption ends that
confinement: a 1.x peer can neither produce a sealed chat message nor read
one, so it would connect, look fine, and be unable to say anything. Refusing
it at the handshake is the honest form. The per-message `upload_not_sealed`
path is untouched and still right if the floor is ever lowered.
* **`sendChat` throws on an `error` reply**, from origin, applied to the sealed
send. It matters more after this change, not less: the node now refuses a
stale epoch, a malformed envelope and a device claim that is not the
connection's own, so there are three new ways for a message to be rejected
and none of them may look like a message that was sent.
* **`req_id` supersedes the per-type routing** this branch added for
`chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the
existing `chat_hist_resp` one, for the same stated reason — a node too old to
stamp — and their comments no longer claim to be the mechanism that closes
the class. `req_id` is.
* **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two
scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending
request. The encrypted path is layered on — a real Ed25519 device key
generated in the page, and a `chat_keys_resp` sealed by the shipped Python,
because a payload the page built itself would prove only that the page agrees
with the page.
* **`test_reply_correlation.py` now sends a sealed message.** Its subject is
which of the two messages leaving that handler carries the id; plaintext chat
was only the fixture, and the node refuses one now.
* `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py`
keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of
the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when
it was written and is wrong differently now.
Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail
on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7
transcode tests that pass in isolation, and the WebRTC invite test that hangs
on its own).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Downloads have been encrypted under a GEK-derived key since the beginning:
`file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads
never were. `file_upload` carried the filename and the raw bytes in plain
msgpack, and `file_upload_ack` carried the name the node stored them under —
so the same file was ciphertext leaving a node and plaintext arriving at one.
There was no threat model behind that asymmetry.
Both halves now travel sealed under a third groupbox purpose,
HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and
the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in
clear, because the node routes and orders on them before it can decrypt. This
direction seals *towards* the node — it holds the GEK for its own group — and
it opens the payload before it picks a destination or touches the disk.
What that forced, and why none of it is optional:
- `filename` was the correlation key on both sides. It cannot be: matching an
ack to its request by name would hand back exactly what the seal hides.
`upload_id` replaces it — client-drawn, opaque to the node, unique within a
connection, never an authorization input. The property it guarded (one
refusal fails one upload, not every upload in flight) is unchanged.
- Refusals can no longer quote what they refused. `No directory named 'X'`
becomes `No such directory in this group` plus the `code` that was already
there; the client knows what it sent.
- No plaintext fallback. A path that still accepts plaintext is not a sealed
path, so an unsealed `file_upload` is refused with `upload_not_sealed`.
Hardened while here, because what comes out of a seal is authenticated but not
validated — a member can seal anything: `filename` and `data` have their types
checked before any upload state is created, and `chunk_index`/`total_chunks`,
which are outside the seal by necessity, can no longer raise where a refusal
was meant.
Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the
wire, tamper/wrong-key/wrong-group all refused with nothing written, and
multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the
shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real
frames to the real `_do_file_upload` — the file lands intact, and the ack the
node actually produced comes back with the name it chose for a collision, which
is the half a source-reading test cannot see. Both upload purposes join the
JS/Python groupbox parity vectors.
BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the
wire every deployed client speaks, which is MAJOR by the same rule 1.0 was —
but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a
1.x peer still connects, browses, downloads, streams and chats; only its
uploads are refused, with a message saying which side is old. The client checks
the node's version before sending a chunk, so neither side meets this as a
timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0
cost a flag day, 2.0 costs a refusal code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`stream_seg` answered with an MPEG-TS segment as base64 with no encryption at
all — the one message on the content plane that never went through a
GEK-derived key. Live on both transports, answering any authenticated member.
It predates `stream_data`, which does the same job properly (`chunk_ciphertext`,
keyed per segment, AES-256-GCM) and has since Phase 12. Its only browser caller,
`fetchStreamSegment`, was defined and never once invoked — a plaintext media
endpoint with no client. Removed rather than repaired.
Gone with it: `_extract_segment` and the ffmpeg semaphore in quic_server, the
`fetch_stream_segment` QUIC client method, and `_b64decode` in transport.js,
which had no other caller.
The H6 regression test lived on this handler — it pinned `_do_stream_segment_async`
to a coroutine so `subprocess.run` could not stall the event loop for thirty
seconds per request. It is replaced by the property that outlives the handler:
no transport carries media outside an AEAD, asserted on `stream_seg` and
`data_b64` across all three transport modules. The half of H6 that survives —
the live streaming path still spawns ffmpeg — keeps its own test.
BREAKING CHANGE: `stream_seg` is no longer answered on either transport. No
shipping client sends it. Recorded as part of MNP 2.0, whose other half — the
sealed upload — carries the version bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
MNP carried no correlation id. A reply named its own type and nothing
else, so a client with more than one request in flight worked out which
one a message answered from the message itself — and for the replies
that name nothing it could not. `_dispatch` fell through to matching by
arrival order, which is a guess. `_sendAndWait` had the right value all
along: it keys `_pending` by `this._seqId++` and never put it on the
wire.
The guess fails asymmetrically, which is why it hid. The victim is not
the request that was answered wrongly — it is the unrelated one that now
waits out its own 30s timeout for a reply already delivered elsewhere.
Live on 2026-09-06: five `music_meta_req` sat pending for over 100
seconds behind a failing MusicBrainz, and a `device_list_result` was
handed to one of them. The composer is disabled while a send is in
flight, so a chat message whose reply went astray the same way left the
Chat tab looking frozen for thirty seconds, then unfroze on its own.
The `ack` half of this was fixed on 2026-08-30 by matching on request
type. That closed the instance and left the class open: a refusal has no
type to match on either, and `_dispatch_message`'s catch-all answers
every unforeseen failure with `{"type": "error", "detail": "Request
failed"}` — 238 of this module's 240 error sends name nothing at all.
`req_id` now rides on the request and comes back on the reply. On the
node it is published for the whole handler in a ContextVar and stamped
by `_send`: a parameter would have meant threading an argument through
all 240 send sites, and asyncio copies the context into a task, so a
handler that `_spawn`s its real work still answers under the right id.
It is never stamped on a broadcast — those answer nothing, and the
owner check in `_send` is what keeps a chat broadcast or an index push
from reaching another peer looking like a reply.
On the client, `_dispatch` resolves on `req_id` first and the
arrival-order fallback is gone the moment a node proves it stamps
(`_correlates`, armed by the handshake's own reply). The fallback stays
for an MNP 1.0 node, unchanged and no wider: there it is the only thing
there is, and removing it would leave device_list_result, join_result
and the handshake replies reaching nobody.
Two things fall out. `sendChat` refuses an `error` reply like every
other request in the file — it returned it as success, which did not
matter while a refusal reached the wrong caller anyway and would now
show a rejected message as sent. And `_group_ctx` uses `.get`: a reload
pops a removed group while sessions connected to it are open, and every
request they had left raised KeyError into that same catch-all.
Sealed index messages are the one exception to the fast path. They
cannot be handed over until they are opened, which is asynchronous while
`_dispatch` is not — resolving on the id alone gave `fetchIndex` the
envelope and skipped `onIndexSync` entirely. Caught by extending
`index_seal_probe.mjs` to stamp a reply the way a current node does,
after the hub suite passed over it: the probe built its own frames and
had never seen one.
Tests, all failing before and passing after: `test_chat_send.py` drives
the real ChatPanel over the real transport for both shapes of reply with
an older request pending (3 of its 6 are new, and the 3 for `ack` pass
either way, so it discriminates); `test_reply_correlation.py` pins the
node's half — the refusals that name nothing else, the broadcast that
must not be stamped, and a late reply from a spawned task answering
under its own id rather than the most recent request's.
Full suite: 1897 passed, same 11 pre-existing failures as before.
QUIC keeps its own dispatch and is not stamped. It is disabled by
default and no browser request reaches it, but the asymmetry is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.
There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.
Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.
Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.
Three prerequisites this needed, each a live defect on its own:
* The peer registry was keyed by user_id, so one account's second device
evicted the first and the broadcast skipped recipients by account — a
person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
(additive, signed, refused unless the key is a live device of this account in
the node's own roster) is what lets the node refuse a member claiming
somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
device linking had already broken deleting your own file from your other
device. It now authorizes against any non-revoked device of `uploader_id`.
Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.
Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Some Wayland compositors never schedule a first paint for an unmapped
surface, but the window stays unmapped until show() runs, which was
gated entirely on that paint's ready-to-show event — a cycle with no
way out on its own. Observed under GNOME/Mutter on a VM whose
virtio-gpu device fails command-buffer creation. Add a bounded
fallback show(), guarded on isVisible() so it's a no-op once the
event has already fired normally and doesn't steal focus back from
whatever the person switched to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHKaCz2gq3ya8t13CvQ7Hp
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
e1dbdf0 made a replugged root re-enrich, which was correct and not enough:
the operator still watched their albums vanish. Measured on the reported
library with a cold metadata cache, the node broadcast twice — the first
delta stripped every album, the second put them back 14 seconds later.
Fourteen seconds of "no music found" is the bug, whatever happens after.
An entry's id is its content hash, so an entry that comes back under the
same id, name and path is the same bytes in the same place and everything
enrichment derived from it still holds. `_rescan_root` now carries those
fields across the drop-and-rescan that `reconcile` and `plug_root` share.
Re-enrichment stays as the fallback for what genuinely changed: a
different id is different content, and a different name or path can change
the folder and filename fallbacks that artist, album, display_title and
track_no rest on, so those entries are still handed to the daemon through
`rescanned_ids`.
`uploader_id`/`uploader_pk` ride along. They are the same shape of field —
set once on an entry, readable from nowhere on disk — and they decide who
may delete the file, so losing them to a replug quietly took a right away.
Verified on the running node: one broadcast 550ms after the plug, carrying
the albums, and no metadata lookups at all.
The tests now assert the field on the entry rather than a call to an
enricher. Counting calls is what let the previous version of this file pass
while the operator still saw an empty tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported live: a removable root ejected from Files and plugged back in
returned with its files and without its albums. Music showed "no music
found" and stayed there through a force reload — the loss was on the
node, not in the client.
`plug_root` drops the root's entries and rescans, which is right; the
drive may have changed while it was away. What comes back is a bare
IndexEntry: `_hash_or_cached` fills id/name/path/size/type and nothing
else. Every enrichment field goes with the old object, and the Music tag
fields are cached nowhere by design (enrich_audio.py re-reads them so a
rename can re-derive the filename fallback), so re-enrichment is the only
way back.
Two gates then made sure it never ran:
* enrichment is scheduled for `delta.additions`, and ejecting broadcasts
nothing, so `_last_broadcast_snapshot` still held those ids — the
rebuilt entries diffed as updates, not additions;
* `_enrich_new_*_entries` skips anything in `_enriched_attempted`, which
is only discarded for `delta.deletions` — and dropping and rescanning
inside one call broadcasts no deletion either.
A restart cleared both, since an empty snapshot makes every entry an
addition. Nothing short of one did.
The indexer now records the ids it rebuilt and the daemon drains them at
broadcast time: their "already attempted" mark is discarded and they
rejoin the entries offered to the three enrichment passes. Not
Music-specific — Videos lost durations and titles and Photos lost
thumbnails the same way; Music is just where an untagged file has no
album to file itself under, so the app goes empty rather than plain.
`reconcile()` does the same drop-and-rescan when a root reappears on its
own, so a USB drive that fell off and re-mounted hit this with nobody
touching the UI. Covered too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`test_a_folder_name_carries_no_trailing_slash` sliced from the first
`dir-row` in files-app.js. Since the parent-directory row was added, that
is the ".." row, whose only cell is an ellipsis — so the slice contained
no `${d}` and the test failed on a name it had never looked at. The
directory row itself has always rendered `${d}` with no trailing slash.
Anchored on `key=${full}` instead, with an assertion that the anchor
still lands on a `dir-row` so the next move fails loudly rather than
silently reading the wrong markup again.
Pre-existing: it fails the same way on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The hint was added alongside the .app-save control, when an inert Save
was indistinguishable from an enabled one and the reader had no way to
tell "nothing changed" from "this is broken". The control now reads as
disabled on its own, so the sentence beside it is noise.
Removing it also removes what only existed to carry it: the .app-save-row
wrapper, its two style rules, and settings_app.no_changes in all ten
locales.
test_the_disabled_state_is_visually_distinct sliced the stylesheet on
.app-save-row; it now stops at the closing brace of .app-save:disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two defects behind one report — "you cannot always click Save, you do not
notice, and it does not work".
**It did not work, for Chat, systematically.** `_dispatch` resolves an admin ack
against the pending request and returns, which is right for an op whose caller
already knows the value it chose. Chat's pane calls `transport.setChatDirectory`
itself, so nothing told `group-page` anything: the node saved it, every *other*
connected client learned it from the broadcast, and the one that asked went on
showing an unsaved-looking draft. Clicking Save again just re-sent it. Same
shape as the root-ack bug, in a different message — so the set is now
`BROADCAST_ACK_TYPES`, named for the property that makes it true, and covers
`chat_directory`, `chat_link_preview` and `app_directories`.
**You could not notice, because Save was not visibly a button.** It carried
`btn btn-small btn-secondary`, and there is no `.btn` rule in the stylesheet at
all — so it took `.btn-secondary`: no background, a transparent border, dim grey
text. Enabled it already looked like a disabled control; disabled it was the
same thing at 40% opacity. Measured on a real page: enabled is now accent on
white, disabled is grey text on a plain border, and an inert one says why
("No changes to save") rather than leaving the reader to guess what the pane
counts as a change. Videos' second Save — the TMDB one — is the same control,
because two Save buttons in one pane that do not look alike is worse than
either looking wrong.
Verified by driving the real pane in Electron through the whole cycle: inert,
pick a folder, live, save, and the node's answer coming back to disable it
again. Four earlier readings said the enabled button was transparent; all four
were taken inside python's directory-listing page, which the app never runs in
— my scaffolding, not the code.
Hub suite only: the node package is untouched by this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Caught in review, and the review was right. The previous commit documented the
flag as deprecated so that `--help` and the man page would agree. That solved
the wrong problem: the flag contradicts the model this whole refactor exists to
establish, and the coherent answer was to delete it.
It wrote `upload_dir` into a *brand-new* `[[groups]]` block, and
`GroupConfig.__post_init__` reads that key by forcing every other root
read-only and appending that path as the one writable one. So
`group add --dir X --writable --upload-dir Y` silently made X read-only — two
mechanisms deciding which directories accept uploads, one of them invisible, in
a group created after the model that replaced it.
Gone from the CLI, from `ops.attach_group`, from the loopback API and from the
MNP `group_attach` payload, which now carries `writable` instead. The *read*
path in `config.py` is deliberately untouched: an existing node.toml using
`upload_dir` must keep working, and that is the only legitimate use left. The
man page says so under the config key, and no longer lists an option.
The test that guarded the deprecation wording now guards its absence — and
earned itself immediately by finding a `group add` usage string still offering
the flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`member upload` reached the generic usage line for the other `member` verbs —
"usage: meshbay-node member upload <username>" — which advertises a removed
feature and sends the operator looking for a username it would then reject. It
names `root set --writable` now, and the man page carries the same. Three lines
between an operator finding the replacement and concluding the CLI is broken.
`--upload-dir` still works, so an existing script keeps working, but its help
and the man page say it is the old spelling and name what replaced it.
The Windows pass (§4.4) is what can be checked from here, made checkable:
drive letters and UNC through `as_posix()` into TOML, a drive root having no
basename to derive a name from — sharing a whole drive is ordinary there — and
a case-insensitive collision, which on NTFS and exFAT is one directory indexed
as two roots. `PureWindowsPath` throughout, for the reason the backslash test
earlier this branch got wrong.
What it cannot check is written down rather than glossed: ReadDirectoryChangesW
dropping events, MAX_PATH, and whether an eject actually lets a drive be
removed. §7d says so, along with two things the plan never considered — the
RO/RW asymmetry in `_do_dir_delete`, and `index_delta` carrying roots but not
`dirs`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
|