From 4b94468d24913c3071b48eeefb43367f4f5cd523 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 14:53:03 +0200 Subject: feat(node): make the transfer caps settable, node-wide and per group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-node/tests/test_cli_dispatch.py | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'packages/meshbay-node/tests/test_cli_dispatch.py') diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index 6f43772..87b8550 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -46,6 +46,11 @@ VERBS = [ # that no longer takes one. ["member", "upload"], ["operator", "pair"], + ["transfers"], # defaults to show + ["transfers", "show"], + ["transfers", "set", "4", "2"], + ["transfers", "set", "4"], # only one number: usage, then exit + ["transfers", "set", "0", "2"], # zero is not "unlimited": refused ["file", "list"], ["file", "rm", "abc", "--yes"], ["video", "rematch", "--yes"], -- cgit v1.2.3 From b3ea222ca8b6218e0187070fe94bc8fed7059a90 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 01:15:41 +0200 Subject: fix(node): give the per-member transfer cap a door anyone can open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-node/src/meshbay_node/daemon.py | 58 +++++++++++++++++++++--- packages/meshbay-node/src/meshbay_node/ui/app.py | 10 ++++ packages/meshbay-node/tests/test_cli_dispatch.py | 3 ++ 3 files changed, 65 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-node/tests/test_cli_dispatch.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f7c1b33..e1cab61 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1826,8 +1826,9 @@ def main() -> None: "| chat status|rotate|encrypt-history|prune " "| denylist show|clear " "| stun list|add|remove|reset " - "| transfers show|set: live transfer slots, and " - "the node-wide download/upload caps " + "| transfers show|set|per-member: live transfer " + "slots, the node-wide caps, and how many one " + "member may run at once in a group " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " "(systemd unit, the Windows autostart launcher, or the " @@ -1845,7 +1846,7 @@ def main() -> None: "init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " - "show|set for transfers; " + "show|set|per-member for transfers; " "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", @@ -2586,8 +2587,20 @@ def main() -> None: out = _daemon_api(cfg, "/api/transfers") for kind, pool in out.get("pools", {}).items(): print(f" {kind:<9} {pool['in_use']}/{pool['cap']} in use, " - f"{pool['queued']} queued, " - f"{pool['per_member']} per member") + f"{pool['queued']} queued (node-wide)") + # Per group, because that is the cap that decides how many one + # person runs at once — and it is not the node-wide number. An + # operator raising `transfers set 8 8` and still seeing two at a + # time is looking at this line, which used to print the node's + # default and say nothing about where it came from. + groups = out.get("groups") or [] + if groups: + print("\n per member, per group " + "(meshbay-node transfers per-member
    --group X):") + for g in groups: + how = "set" if g["set"] else "default" + print(f" {g['name']:<20} {g['download']} download(s), " + f"{g['upload']} upload(s) [{how}]") leases = out.get("leases", []) if not leases: print("\n nothing transferring") @@ -2624,7 +2637,40 @@ def main() -> None: f"(applied now, and kept in node.toml)") return - print("usage: meshbay-node transfers show|set ") + if sub == "per-member": + # How many transfers ONE member may run at once in this group. Not + # the same knob as `set`, which is the machine's total — and the + # reason "I set 8 8 and still only get two" is the commonest + # confusion here: per-member is checked first, by design. + values = [v for v in (args.target, args.value) if v] + if len(values) != 2: + print("usage: meshbay-node transfers per-member " + " [--group NAME]") + sys.exit(1) + try: + downloads, uploads = int(values[0]), int(values[1]) + except ValueError: + print("error: both values must be whole numbers") + sys.exit(1) + if downloads < 1 or uploads < 1: + print("error: a cap below 1 is not 'unlimited'; it would stop " + "every transfer for that member. Revoke them instead.") + sys.exit(1) + group_id = _resolve_group(cfg, args.group) + out = _daemon_api(cfg, f"/api/groups/{group_id}/transfer-limits", + method="PUT", + body={"downloads": downloads, "uploads": uploads}) + got = out.get("limits", {}) + started = out.get("started") or [] + print(f"each member of this group may now run " + f"{got.get('download')} download(s) and " + f"{got.get('upload')} upload(s) at once") + if started: + print(f"{len(started)} waiting transfer(s) started at once") + return + + print("usage: meshbay-node transfers show|set |" + "per-member [--group NAME]") sys.exit(1) if args.command == "file": diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 9b9307d..4ad3787 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -515,4 +515,14 @@ def create_ui_app(state: dict) -> FastAPI: async def get_transfers(): return await _op(lambda: ops.list_transfers(state)) + @app.put("/api/groups/{group_id}/transfer-limits") + async def set_transfer_limits(group_id: str, payload: dict): + # The same `ops.set_transfer_limits` the signed MNP handler calls. The + # op existed with only that one door, and nothing anywhere opened it — + # so the per-member cap sat at its default of 2 with no way to change + # it, which from outside is indistinguishable from a hardcoded 2. + return await _op(lambda: ops.set_transfer_limits( + state, group_id, + int(payload.get("downloads", 0)), int(payload.get("uploads", 0)))) + return app diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index 87b8550..64f96f1 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -51,6 +51,9 @@ VERBS = [ ["transfers", "set", "4", "2"], ["transfers", "set", "4"], # only one number: usage, then exit ["transfers", "set", "0", "2"], # zero is not "unlimited": refused + ["transfers", "per-member", "4", "2"], + ["transfers", "per-member", "4"], # only one number: usage, then exit + ["transfers", "per-member", "0", "2"], # zero is refused here too ["file", "list"], ["file", "rm", "abc", "--yes"], ["video", "rematch", "--yes"], -- cgit v1.2.3