1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
|
"""
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
from spa_source import search_argv, search_source
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), search_argv(), 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_source()
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
|