summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_connect_never_hangs.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_connect_never_hangs.py')
-rw-r--r--packages/meshbay-hub/tests/test_connect_never_hangs.py91
1 files changed, 91 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py
new file mode 100644
index 0000000..d6f1369
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py
@@ -0,0 +1,91 @@
+"""
+Joining a group must fail, or succeed — never wait forever.
+
+Reported: a first attempt to connect hung with nothing on screen, and the same
+account connected a few minutes later. That is the shape of a network wait with
+no deadline, not of a refusal, and there were two of them.
+
+**ICE gathering.** Signaling here is non-trickle — the offer carries its
+candidates, so it is not sent until gathering says it is done. A STUN server
+that is slow, filtered, or resolved through a DNS that is not answering means
+`icegatheringstatechange` never reaches `complete`, and `connect()` never
+returns. Same shape as the `fullscreen` denial: a promise that never settles
+produces no error to find.
+
+**The hub call in the desktop client.** Node's `fetch` has no default timeout,
+so a host that accepts a connection and then says nothing holds the request for
+as long as the OS allows. `hub:probe` had a deadline; `hub:fetch`, which carries
+signaling, did not.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+MAIN = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "main.js")
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _gathering_block() -> str:
+ """The wait on ICE gathering, and only it."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ start = source.index("iceGatheringState")
+ return source[max(0, start - 900):start + 700]
+
+
+def test_ice_gathering_has_a_deadline():
+ block = _gathering_block()
+ assert "setTimeout" in block, (
+ "the wait on ICE gathering can never end, and connect() with it")
+ assert "ICE_GATHER_TIMEOUT_MS" in block
+
+
+def test_the_deadline_is_long_enough_for_a_stun_round_trip():
+ """Cutting gathering off too early drops the reflexive candidate and breaks
+ every connection that is not on the same network."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ match = re.search(r"const ICE_GATHER_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert 2000 <= int(match.group(1)) <= 10000
+
+
+def test_a_timed_out_gathering_still_sends_the_offer():
+ """Host candidates are already gathered, which is enough on a LAN. Giving
+ up instead would turn a slow STUN server into a refusal to connect."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ block = _gathering_block()
+ # The deadline resolves the promise; it does not reject it.
+ assert "reject" not in block.split("setTimeout", 1)[1][:300]
+ # And the offer is still posted afterwards.
+ assert "webrtc/offer" in source[source.index("iceGatheringState"):]
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_every_hub_call_from_the_client_has_a_deadline():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "AbortSignal.timeout" in block, (
+ "a hub that accepts the connection and says nothing holds this for "
+ "as long as the OS allows")
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_the_deadline_outlasts_the_hubs_own_longest_call():
+ """Signaling waits fifteen seconds for a node to answer an offer. A client
+ deadline under that would abort calls that were about to succeed."""
+ source = MAIN.read_text(encoding="utf-8")
+ match = re.search(r"const HUB_FETCH_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert int(match.group(1)) > 15000
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_a_timeout_says_so_rather_than_saying_fetch_failed():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "TimeoutError" in block and "did not answer" in block