summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transfers.py
Commit message (Collapse)AuthorAgeFilesLines
* docs(code): drop the references to a plan file that no longer existsChristophe Besson2026-09-101-25/+20
| | | | | | | | | | | | | | | | Seven comments pointed at sections of `~/next/improve-downloads.md`, which is not in the tree and not anywhere a reader of this repository can follow. Each now states the thing it was citing: why a paused transfer holds nothing, why the lease is taken after the save target and not before, why a chunk request marks a lease alive, where the leaseless bound's number comes from. The leaseless comment also said "two files at a time" three paragraphs under `MAX_LEASELESS_IN_FLIGHT = 12`, left behind when the bound was raised. A comment that contradicts the constant beside it is worse than no comment: one of them is wrong and the reader cannot tell which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* fix(node): the leaseless bound refused the music playerChristophe Besson2026-09-091-4/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported the day MNP 3.0 shipped: playing a track answered "Too many files open at once without a transfer. Download this one instead of previewing it." §3.4.1's bound of two was reasoned about *viewers* — a photo viewer shows one photo, a preview modal one document, and the second is for prefetching the next. It forgot the music player, which warms a read-ahead window: `prefetchDepth()` returns 5 on Wi-Fi and 3 otherwise, so playing an album has six files in flight and the fourth was refused. Browsing a group is never subject to a transfer slot — that is a stated requirement, not a tuning parameter — and a constant nobody had checked against the client broke it. Twelve now: six for the music read-ahead at its widest, two for a photo viewer and its own prefetch in the same session, the rest as headroom. Generosity is cheap here and refusal is not — this is a fairness control among cooperating clients, not a security boundary, so a client that lies gets twelve files at a time instead of its member cap, bounded and audited, while refusing a legitimate read breaks the requirement outright. And the number is now derived rather than chosen: a test reads `prefetchDepth()` out of the shipped player and fails if the node's bound no longer covers it, so widening the client's read-ahead breaks the build instead of reaching a person. Checked by widening it: "the music player reads 21 files ahead and the node admits only 12". Three cases that hard-coded "two then refuse" now set their own limit — they are about the mechanism, and the shipped number moves with the client. Node suite 1210 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat: MNP 3.0 — a transfer needs a leaseChristophe Besson2026-09-091-0/+83
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory and a 2.x peer is refused at the handshake. **The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the rest mean anything.** Browsing a group is never subject to a transfer slot — that is an operator decision and a requirement: a member must be able to browse a group at capacity exactly as they browse an idle one. But "not leased" cannot mean "unbounded", or a client that simply omits `tr` transfers outside every cap and the caps are decoration. A session may now read two distinct files at once without a lease: one because a viewer looks at one file, two so that prefetching the next photo stays possible. A count of files and not a byte budget, because a RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and no size threshold separates them. Thumbnails, posters and cover art never reach this check at all — they resolve out of the node's own cache. It is a fairness control among cooperating clients, in the company of `max_concurrent_streams`, and is not a defence against a member determined to saturate a node's disk. That member is a member, and the answer to them is `member revoke`. **MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The messages are additive; the requirement is not. An opt-in switch would leave a leaseless branch reachable on every node, which is finding C6's lesson — a transport that accepted a bare JWT — one feature later. **The desktop client now checks before it connects.** The SPA is served by the hub and picks up a new client on reload; the application ships its own interface, so an un-updated one would sign in, list groups, and fail every connection with `version_too_old` — a refusal in a protocol vocabulary with nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and says so plainly instead. An unreachable hub is deliberately *not* "too old": a captive portal or a closed laptop must not make starting the application impossible. **Every package is aligned on 0.13.0.** `meshbay-client/package.json` had drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until something compared those numbers, and then load-bearing: an installed client announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant to stop it. That is stated in the code rather than left to be rediscovered; it is acceptable exactly once, because the operator is updating every client, node and hub by hand for this flag day. A new test fails if two packages ever disagree again, and another fails if the hub would refuse the client the tree builds. Node suite 1209 passed, hub suite 861 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(node): a running transfer keeps its slot, and a dead grant lets goChristophe Besson2026-09-081-4/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* feat(node): make the transfer caps settable, node-wide and per groupChristophe Besson2026-09-081-2/+32
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* feat(node): transfer leases, pools and a queue for downloads and uploadsChristophe Besson2026-09-081-0/+354
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