diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_search_connect_deadline.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_connect_deadline.py | 124 |
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 |