aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_transfers.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_transfers.py')
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py213
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 3316615..e805afc 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -222,3 +222,216 @@ def test_a_folder_name_carries_no_trailing_slash():
assert "dir-row" in row, "the anchor no longer lands on the directory row"
assert "${d}/" not in row, "the folder name is rendered with a trailing slash"
assert "${d}" in row
+
+
+# ── Transfer slots, client side ─────────────────────────────────────────────
+#
+# A queue can lie in two directions, and both are worse than no queue: a
+# transfer that shows "waiting" on a node that already granted it, and a slot
+# the page holds after it has stopped using it. Everything below is one of
+# those two.
+
+def _lease_stub():
+ """A Lease as the store sees it, driveable from the test."""
+ return """
+class L {
+ constructor() {
+ this.state = 'queued'; this.ahead = 2; this.closed = false;
+ this.released = []; this.tr = 'tr1';
+ this._wait = new Promise(r => { this._go = r; });
+ }
+ acquire() { return this._wait; }
+ release(reason) { if (!this.closed) { this.closed = true; this.released.push(reason); } }
+ grant() { this.state = 'granted'; if (this._onState) this._onState(this); this._go(); }
+ push(state, ahead) { this.state = state; this.ahead = ahead; if (this._onState) this._onState(this); }
+}
+"""
+
+
+def test_a_transfer_waiting_for_a_slot_is_queued_not_running(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran'); } });
+ say(t.list()[0].status, t.list()[0].ahead);
+ await new Promise(r => setTimeout(r, 0));
+ say('still:' + t.list()[0].status);
+ """, tmp_path)
+ assert out[:2] == ["queued", 2]
+ assert "ran" not in out, "the work started before the slot was granted"
+ assert out[-1] == "still:queued"
+
+
+def test_the_grant_starts_the_work(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran:' + t.list()[0].status); } });
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('after:' + t.list()[0].status);
+ """, tmp_path)
+ assert out[0] == "ran:running"
+ assert out[1] == "after:done"
+
+
+def test_the_slot_comes_back_however_the_transfer_ends(tmp_path):
+ """A slot not returned is a member who cannot transfer again until the node
+ times it out — so this must hold for a throw as much as for a success."""
+ out = _run(_lease_stub() + """
+ for (const mode of ['ok', 'throw']) {
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { if (mode === 'throw') throw new Error('x'); } });
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say(mode + ':' + lease.released.join(',') + ':' + t.list()[0].status);
+ }
+ """, tmp_path)
+ assert out == ["ok:done:done", "throw:done:failed"]
+
+
+def test_cancelling_while_queued_gives_the_slot_back(tmp_path):
+ """The transfer somebody is most likely to give up on is the one that has
+ not started. Its queue entry has to go, or the node grants a slot to a
+ transfer that will never use it."""
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ const id = t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran'); } });
+ t.cancel(id);
+ say(t.list()[0].status, lease.released.join(','));
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('ran?', out.includes('ran'));
+ """, tmp_path)
+ assert out[0] == "cancelled"
+ assert out[1] == "cancelled"
+ assert out[-1] is False, "a cancelled transfer ran anyway once granted"
+
+
+def test_a_queue_position_update_reaches_the_view(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ const seen = [];
+ t.subscribe(items => seen.push(items[0].ahead));
+ t.start({ kind: 'download', name: 'f', total: 10, lease, run: async () => {} });
+ lease.push('queued', 1);
+ lease.push('queued', 0);
+ say(seen.join('>'));
+ """, tmp_path)
+ assert out[0].endswith("1>0"), "the widget never learns it is moving up"
+
+
+def test_a_transport_with_a_queued_transfer_is_not_closed(tmp_path):
+ """Closing it would leave the transfer waiting for a grant that can never
+ arrive — waiting for ever, with nothing left to answer."""
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ let closed = false;
+ const transport = { close() { closed = true; } };
+ t.start({ kind: 'download', name: 'f', total: 10, transport, lease,
+ run: async () => {} });
+ t.releaseWhenIdle(transport);
+ say('closed while queued:', closed);
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('closed after:', closed);
+ """, tmp_path)
+ assert out[1] is False
+ assert out[3] is True
+
+
+def test_clearing_finished_keeps_what_is_waiting(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'waiting', total: 1, lease, run: async () => {} });
+ t.start({ kind: 'download', name: 'done', total: 1, run: async () => {} });
+ await new Promise(r => setTimeout(r, 10));
+ t.clearFinished();
+ say(t.list().map(i => i.name + ':' + i.status).join(','));
+ """, tmp_path)
+ assert out[0] == "waiting:queued"
+
+
+def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path):
+ """
+ The transport reconnects on its own and re-asks for every live lease when it
+ does, so a closed channel at the moment a transfer starts is a wait, not a
+ failure. `_fetchChunkResilient` has always treated it that way — and before
+ leases existed a chunk request was the first thing to touch the channel, so
+ a download begun on a briefly dead connection simply retried.
+
+ Asking for a slot first made `_send` the first contact. It threw
+ "DataChannel not open (state: closed)" straight out of `downloadEntry`,
+ where nothing catches it: a download that used to recover became an error
+ with no row in the widget to show it. Found live, by downloading a file
+ just after a connection dropped.
+ """
+ module = tmp_path / "transport_lease.mjs"
+ # The real Lease, lifted out as text — the class is not exported, and a
+ # second copy of it here would agree with whatever it was copied from.
+ src = (STATIC / "transport.js").read_text()
+ # From the constant the class depends on, not from the class: lifting only
+ # the class left LEASE_WATCHDOG_MS undefined, which the class reads the
+ # first time it arms its watchdog.
+ start = src.index("const LEASE_WATCHDOG_MS")
+ end = src.index("\nclass MeshBayTransport")
+ module.write_text(src[start:end] + "\nexport { Lease };\n")
+
+ script = tmp_path / "case.mjs"
+ script.write_text(f"""
+import {{ Lease }} from '{module.as_posix()}';
+const out = [];
+const transport = {{
+ supportsTransferSlots: true,
+ _leases: new Map(),
+ _send() {{ throw new Error('DataChannel not open (state: closed)'); }},
+}};
+let threw = null;
+const lease = new Lease(transport, 'tr1', 'download', 10, 1, null);
+try {{ lease._request(); }} catch (e) {{ threw = e.message; }}
+out.push(threw);
+// And releasing one must be just as safe: a lease not released is a member who
+// cannot start another transfer until the node times it out.
+try {{ lease.release('cancelled'); out.push('release ok'); }}
+catch (e) {{ out.push('release threw: ' + e.message); }}
+clearTimeout(lease._watchdog);
+console.log(JSON.stringify(out));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ out = json.loads(proc.stdout)
+ assert out[0] is None, f"asking for a slot threw: {out[0]}"
+ assert out[1] == "release ok"
+
+
+def test_the_slot_is_asked_for_after_there_is_somewhere_to_write():
+ """
+ A granted slot has to be taken up within the node's acceptance deadline, so
+ it must not be asked for until the download can actually start.
+
+ Asking first reads better — the widget could draw a row while the target is
+ being chosen — and is wrong: opening a target takes thirty seconds of
+ streamed-download timeouts, or as long as somebody leaves a Save As dialog
+ open. The node revokes the grant, passes it to the next in the queue
+ (`transfer: reclaimed … (not_taken_up)` in its log), and the download then
+ fetches under a `tr` that is no longer granted. Three downloads started, one
+ arrived.
+
+ Source-reading, because the ordering is the whole property and it has no
+ behaviour of its own to drive: what matters is which call comes first.
+ """
+ src = (STATIC / "file-utils.js").read_text()
+ fn = src[src.index("async function downloadEntry"):]
+ fn = fn[:fn.index("\n}\n")]
+ assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), (
+ "downloadEntry asks for a transfer slot before it has anywhere to "
+ "write — the grant expires before the download can use it")