diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:24:50 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:24:50 +0200 |
| commit | 1c8eb6577e36e1e4a150afd0cdda8d283172385e (patch) | |
| tree | bf7dde66f00490023e028c5a23e2ef353b16e4fd /packages/meshbay-node/tests/test_stun_multi.py | |
| parent | fe30860c58e0f1b1efd457ff5eb5146d1e592da0 (diff) | |
| parent | b3b48a43a33f936be18a3daa22d307c144b60e22 (diff) | |
| download | meshbay-1c8eb6577e36e1e4a150afd0cdda8d283172385e.tar.gz | |
Merge branch 'fix/node-stun-fanout'
Node-side WebRTC STUN fallback now queries every configured server instead
of collapsing to the first (aiortc/aioice limitation). See stun_multi.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSsQhfxEAhwi4nqc4hASmq
Diffstat (limited to 'packages/meshbay-node/tests/test_stun_multi.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_stun_multi.py | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_stun_multi.py b/packages/meshbay-node/tests/test_stun_multi.py new file mode 100644 index 0000000..119071d --- /dev/null +++ b/packages/meshbay-node/tests/test_stun_multi.py @@ -0,0 +1,138 @@ +""" +transport/stun_multi — the node must query every configured STUN server, not +just the first (which is all aiortc/aioice keep on their own). +""" + +import asyncio +import time + +import aioice.ice as _ice +import pytest +from meshbay_node.transport import stun_multi + + +@pytest.fixture(autouse=True) +def _restore(): + """Each test gets a clean, un-patched module.""" + saved = (_ice.server_reflexive_candidate, stun_multi._installed, + list(stun_multi._servers)) + stun_multi._installed = False + stun_multi._servers = [] + yield + (_ice.server_reflexive_candidate, stun_multi._installed, + stun_multi._servers) = saved[0], saved[1], saved[2] + + +@pytest.fixture +def _identity_dns(monkeypatch): + monkeypatch.setattr(stun_multi.socket, "gethostbyname", lambda host: host) + + +class _Local: + host, port, component, transport = "192.168.1.50", 54000, 1, "udp" + + +class _Response: + def __init__(self, ip, port): + self.attributes = {"XOR-MAPPED-ADDRESS": (ip, port)} + + +class _Protocol: + """A fake StunProtocol whose .request() behaviour is scripted per address.""" + + def __init__(self, behaviour): + self.local_candidate = _Local() + self._behaviour = behaviour # {(host, port): callable | value} + self.cancelled = 0 + + async def request(self, message, addr): + action = self._behaviour.get(addr) + try: + if action is None: + raise OSError(f"unreachable {addr}") + if callable(action): + return await action(addr) + return action, addr + except asyncio.CancelledError: + self.cancelled += 1 + raise + + +@pytest.mark.parametrize("url,expected", [ + ("stun:stun.l.google.com:19302", ("stun.l.google.com", 19302)), + ("stun:stun.cloudflare.com:3478", ("stun.cloudflare.com", 3478)), + ("stun:example.org", ("example.org", 3478)), + ("stun:example.org?transport=udp", ("example.org", 3478)), +]) +def test_parse(url, expected): + assert stun_multi._parse(url) == expected + + +def test_set_servers_parses_and_drops_empty(): + stun_multi.set_servers(["stun:a:1", "stun:b", "stun:", "stun:c:3478?transport=udp"]) + assert stun_multi._servers == [("a", 1), ("b", 3478), ("c", 3478)] + + +def test_install_patches_and_is_idempotent(): + stun_multi.install(["stun:a:3478"]) + assert _ice.server_reflexive_candidate is stun_multi._fanout_server_reflexive_candidate + first = _ice.server_reflexive_candidate + stun_multi.install(["stun:b:3478", "stun:c:3478"]) + assert _ice.server_reflexive_candidate is first # not re-wrapped + assert stun_multi._servers == [("b", 3478), ("c", 3478)] # list still updated + + +async def test_fanout_first_answer_wins(_identity_dns): + async def slow(addr): + await asyncio.sleep(30) + + proto = _Protocol({ + ("slow", 3478): slow, + ("fast", 19302): _Response("203.0.113.7", 5555), + # ("broken", 3478) -> not in map -> OSError + }) + stun_multi.set_servers(["stun:slow:3478", "stun:fast:19302", "stun:broken:3478"]) + + t0 = time.monotonic() + cand, extra = await stun_multi._fanout_server_reflexive_candidate(proto, ("x", 1)) + elapsed = time.monotonic() - t0 + + assert extra is None + assert cand.type == "srflx" + assert (cand.host, cand.port) == ("203.0.113.7", 5555) + assert cand.related_address == _Local.host + assert elapsed < 1.0 # did not wait on `slow` + await asyncio.sleep(0) # let cancellations propagate + assert proto.cancelled >= 1 # the slow query was torn down + + +async def test_all_servers_fail_raises_without_long_hang(_identity_dns, monkeypatch): + monkeypatch.setattr(stun_multi, "_QUERY_TIMEOUT", 0.3) + proto = _Protocol({}) # every addr -> OSError + stun_multi.set_servers(["stun:a:3478", "stun:b:3478"]) + + t0 = time.monotonic() + with pytest.raises(OSError): + await stun_multi._fanout_server_reflexive_candidate(proto, ("x", 1)) + assert time.monotonic() - t0 < 2.0 + + +async def test_empty_list_falls_back_to_passed_server(_identity_dns): + proto = _Protocol({("fallback.example", 3478): _Response("198.51.100.4", 9)}) + stun_multi.set_servers([]) # no fan-out configured + + cand, _ = await stun_multi._fanout_server_reflexive_candidate( + proto, ("fallback.example", 3478)) + assert (cand.host, cand.port) == ("198.51.100.4", 9) + + +async def test_unresolvable_names_raise(monkeypatch): + def boom(host): + raise OSError("name resolution failed") + + monkeypatch.setattr(stun_multi.socket, "gethostbyname", boom) + proto = _Protocol({}) + stun_multi.set_servers(["stun:a:3478", "stun:b:3478"]) + + with pytest.raises(OSError): + await stun_multi._fanout_server_reflexive_candidate(proto, ("x", 1)) |