diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-08 23:13:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-08 23:13:14 +0200 |
| commit | f31aaca046911697cbb8873b27929ac10efa9e18 (patch) | |
| tree | b01507ece9a1f75f6325373763de976d93455bf9 /packages/meshbay-node/tests/transfer_probe.py | |
| parent | 4c2c5d037ebc5c4c7ec7dfcd3c816c8b7c67f252 (diff) | |
| download | meshbay-f31aaca046911697cbb8873b27929ac10efa9e18.tar.gz | |
test(node): the two checks that need the operator's own CLI
`--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
Diffstat (limited to 'packages/meshbay-node/tests/transfer_probe.py')
| -rwxr-xr-x | packages/meshbay-node/tests/transfer_probe.py | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/transfer_probe.py b/packages/meshbay-node/tests/transfer_probe.py index fb14533..bf18a5e 100755 --- a/packages/meshbay-node/tests/transfer_probe.py +++ b/packages/meshbay-node/tests/transfer_probe.py @@ -241,6 +241,113 @@ async def pull_together(client, entries, CHUNK) -> int: return 1 if failures else 0 +def _cli(*argv) -> str: + """Run `meshbay-node …` the way the operator does, and return its output.""" + import subprocess + out = subprocess.run(["meshbay-node", *argv], capture_output=True, + text=True, timeout=30) + if out.returncode != 0: + raise RuntimeError(f"meshbay-node {' '.join(argv)}: {out.stderr.strip()}") + return out.stdout + + +async def operator_checks(client, ack, group, node_id) -> int: + """The two things only the operator's side can answer. + + Both are about a promise made elsewhere: draft-v6 §2.11 says these settings + apply without a restart, and §5 of the transfer-slots plan says a lost peer + gives its slots back through a hook rather than a timeout. Neither can be + checked from inside the client, and both were wrong at some point today — + the live cap because the hot-swap wrote to an attribute that never existed, + and the operator's view because it reported the module defaults. + """ + import asyncio as _a + import json as _json + import re as _re + + failures = 0 + member_cap = int((ack.get("transfer_limits") or {}).get("download") or 0) + + # The queue has to be held by the *node* cap, not by this member's own. + # + # With both at 2, one account holding two transfers hits both at once, and + # raising the node-wide cap then correctly changes nothing — per-member is + # checked first, by design. An earlier version of this check set it up that + # way and reported the design working as a failure. So: node cap to 1, well + # under the member cap, and the third transfer is waiting on the machine. + was = _cli("transfers", "show") + prior = int(_re.search(r"download\s+\d+/(\d+)", was).group(1)) + _cli("transfers", "set", "1", "1") + + granted_tr, first = await _open_transfer(client) + tr_waiting, waiting = await _open_transfer(client) + ok = first.get("state") == "granted" and waiting.get("state") == "queued" + _line(ok, "with the node cap at 1, the second transfer waits on the machine " + f"rather than on this member's own cap of {member_cap}") + failures += not ok + + # ── 1. the operator can see it ──────────────────────────────────────── + shown = _cli("transfers", "show") + # The lease table only: "queued" also appears in each pool's summary line, + # and counting those reported three queues where there was one. + rows = [ln for ln in shown.splitlines() if " download " in ln + or " upload " in ln] + seen = sum(1 for ln in rows if " granted " in ln) + queued = sum(1 for ln in rows if " queued " in ln) + ok = seen == 1 and queued == 1 + _line(ok, f"`transfers show` lists {seen} granted and {queued} queued lease(s)") + failures += not ok + if not ok: + print(" the operator's only window into a stuck queue is wrong") + print(" " + shown.replace("\n", "\n ")) + + # ── 2. raising the cap live starts what was waiting ─────────────────── + _cli("transfers", "set", "4", "4") + try: + started = await _a.wait_for( + _wait_for_grant(client, tr_waiting), timeout=20) + except _a.TimeoutError: + started = False + _line(started, "raising the cap started the waiting transfer, with no " + "restart and no reconnection") + failures += not started + if not started: + print(" draft-v6 §2.11 promises this applies live; the setting " + "was accepted and nothing moved") + + # ── 3. a vanished peer's slots are back before anyone asks ──────────── + await client.close() + await _a.sleep(1.0) + after = _cli("transfers", "show") + ok = "nothing transferring" in after + _line(ok, "every slot came back when the peer vanished, with no timeout") + failures += not ok + if not ok: + print(" " + after.replace("\n", "\n ")) + + # Put the operator's cap back where it was found — not at a default, at + # whatever this node was running before the probe touched it. + _cli("transfers", "set", str(prior), str(prior)) + print(f"\n (node cap restored to {prior})") + + print() + print("all operator checks passed" if not failures + else f"{failures} operator check(s) failed") + return 1 if failures else 0 + + +async def _wait_for_grant(client, tr: str) -> bool: + """The node pushes the grant; nothing here polls for it. + + A grant that is decided and never sent is the "stuck at waiting" report the + whole design exists to prevent, and it looks perfectly correct in the pool. + """ + while True: + msg = await client.recv_type("transfer_state", timeout=30) + if msg.get("tr") == tr and msg.get("state") == "granted": + return True + + async def probe(args) -> int: cfg = env() hub = cfg["HUB_URL"] @@ -285,6 +392,9 @@ async def probe(args) -> int: cap = int(limits.get("download") or 0) print(f"node reports this member may run {cap} download(s) at once\n") + if args.operator: + return await operator_checks(alice, ack, group, node_id) + if args.pull: return await pull(alice, ack, args.pull, args.parallel) @@ -406,6 +516,10 @@ def main() -> int: ap.add_argument("--pull", type=int, default=0, metavar="N", help="actually download N files to completion, under a " "lease, and report where they stop") + ap.add_argument("--operator", action="store_true", + help="the two checks that need the operator's CLI: a cap " + "raised live starts what was waiting, and a vanished " + "peer's slots are back before anyone asks") ap.add_argument("--parallel", action="store_true", help="pull them at the same time on one connection, the " "way a browser does — which is when it goes wrong") |