summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_search_connect_deadline.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_search_connect_deadline.py')
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py124
1 files changed, 124 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_search_connect_deadline.py b/packages/meshbay-hub/tests/test_search_connect_deadline.py
new file mode 100644
index 0000000..d41cbb5
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py
@@ -0,0 +1,124 @@
+"""
+Search gave up on a connection that was still getting somewhere.
+
+Opening a group from the sidebar has no deadline of its own: it gets the
+transport's, which allows 30 s for the DataChannel to open and 30 s for each
+request after that. Search wrapped the same `connect()` in a flat 10 s — and
+that 10 s has to cover the hub round trip, ICE gathering (capped at 4 s in
+transport.js), DTLS, the channel opening and the handshake's own round trips.
+On a phone on 4G the budget is met by luck rather than by margin, and the same
+group then fails in Search and opens from the sidebar, against the same node.
+
+Raising the number is the wrong repair. `fetchAllIndexes` fans out in batches of
+three and waits for the slowest of each, so a page of unreachable groups costs
+(batches x the deadline) of spinner: a bigger number makes every dead group
+slower to report for the sake of the live ones.
+
+So the deadline measures **stalling** instead of elapsed time. A node that is
+not there produces no progress and still fails in `SEARCH_STALL_MS`, unchanged;
+a node that answers ICE and opens a channel buys another window each time, up to
+`SEARCH_MAX_MS`, which exists because a deadline that only ever resets has none.
+
+These run the shipped `connectToGroup`, lifted out of search-page.js as text,
+against a fake clock and a transport whose progress the scenario dictates — see
+harness/search_connect_harness.mjs. A real clock would make each of these a
+minute and would blur the one thing worth asserting, which is *when* the
+deadline fires.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SEARCH_PAGE = STATIC / "search-page.js"
+HARNESS = Path(__file__).parent / "harness" / "search_connect_harness.mjs"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SEARCH_PAGE.exists(),
+ reason="node or the SPA sources are not available")
+
+STALL_MS = 10000
+CAP_MS = 30000
+
+
+def _attempt(**cfg) -> dict:
+ proc = subprocess.run(
+ ["node", str(HARNESS), str(SEARCH_PAGE), json.dumps(cfg)],
+ capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_the_constants_are_what_these_scenarios_assume():
+ """
+ Read back, not restated: every number below is a multiple of these two, and
+ a change to either without a change here would leave the scenarios asserting
+ about a deadline the code no longer has.
+ """
+ code = SEARCH_PAGE.read_text(encoding="utf-8")
+ assert f"const SEARCH_STALL_MS = {STALL_MS};" in code
+ assert f"const SEARCH_MAX_MS = {CAP_MS};" in code
+
+
+def test_a_node_that_never_answers_still_fails_in_one_window():
+ """
+ The fan-out's cost, and the reason the repair is not a bigger number.
+
+ Nothing reports progress, so this is the whole budget a dead group spends —
+ and with batches of three it is what a page full of them costs.
+ """
+ out = _attempt(progressAt=[], resolveAt=None)
+ assert out["result"] == "failed"
+ assert out["at"] == STALL_MS
+ assert out["closed"] == 1, "the transport of a failed attempt must be closed"
+
+
+def test_a_slow_link_that_keeps_moving_is_not_cut_off():
+ """
+ The defect. ICE answers at 8 s, the channel opens at 16 s, the handshake
+ lands at 20 s — every step late, none of them stalled. The flat deadline
+ failed this at 10 s; the sidebar, with no deadline of its own, did not.
+ """
+ out = _attempt(progressAt=[8000, 16000], resolveAt=20000)
+ assert out["result"] == "connected"
+ assert out["at"] == 20000
+ assert out["closed"] == 0
+
+
+def test_a_node_that_answers_and_then_stops_fails_one_window_later():
+ """Progress buys a window, not an exemption."""
+ out = _attempt(progressAt=[8000], resolveAt=None)
+ assert out["result"] == "failed"
+ assert out["at"] == 8000 + STALL_MS
+
+
+def test_progress_that_never_finishes_is_bounded_by_the_cap():
+ """
+ Without the cap this is the connection that never ends: something reports
+ progress just often enough that the stall window never expires.
+ """
+ out = _attempt(progressAt=list(range(5000, 55000, 5000)), resolveAt=None)
+ assert out["result"] == "failed"
+ assert out["at"] == CAP_MS
+
+
+def test_the_walk_over_several_nodes_pays_one_window_each():
+ """
+ Two nodes, neither answering: the deadline is per attempt, so the group
+ costs two windows and both transports are closed. A deadline that leaked
+ between attempts would show up here as one window, or as an open transport.
+ """
+ out = _attempt(progressAt=[], resolveAt=None, nodes=2)
+ assert out["result"] == "failed"
+ assert out["at"] == 2 * STALL_MS
+ assert out["closed"] == 2
+
+
+def test_a_fast_connection_is_unaffected():
+ out = _attempt(progressAt=[], resolveAt=3000)
+ assert out["result"] == "connected"
+ assert out["at"] == 3000