aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_connection_processes.py20
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py21
-rw-r--r--packages/meshbay-node/tests/test_proactor_udp.py150
-rw-r--r--packages/meshbay-node/tests/test_sctp_fix.py151
4 files changed, 335 insertions, 7 deletions
diff --git a/packages/meshbay-node/tests/test_connection_processes.py b/packages/meshbay-node/tests/test_connection_processes.py
index ab9dfd3..778da5a 100644
--- a/packages/meshbay-node/tests/test_connection_processes.py
+++ b/packages/meshbay-node/tests/test_connection_processes.py
@@ -13,6 +13,7 @@ not carry.
"""
import asyncio
+import re
import struct
from pathlib import Path
@@ -231,11 +232,21 @@ async def test_one_process_starts_none():
# ── the setting ─────────────────────────────────────────────────────────────
-def test_the_default_is_one_process(tmp_path):
- assert node_settings_defaults()["connection_processes"] == 1
+@pytest.mark.parametrize("cores, expected", [(1, 1), (2, 2), (4, 4), (28, 4)])
+def test_the_default_is_one_per_core_up_to_four(tmp_path, monkeypatch, cores, expected):
+ """It was 1, which starts none: the path was shipped and reached by no node
+ whose operator had not found the setting."""
+ monkeypatch.setattr("meshbay_node.config.max_processes", lambda: cores)
+ assert node_settings_defaults()["connection_processes"] == expected
conf = tmp_path / "node.toml"
conf.write_text('[hub]\nurl = "https://example.invalid"\nusername = "op"\n')
- assert load_config(conf).node.connection_processes == 1
+ assert load_config(conf).node.connection_processes == expected
+
+
+def test_a_new_node_toml_leaves_it_to_the_default(tmp_path):
+ from meshbay_node.config import EXAMPLE_CONFIG
+ assert not re.search(r"^connection_processes\s*=", EXAMPLE_CONFIG, re.M), (
+ "a value written by `init` is a value no later default reaches")
def test_node_toml_cannot_ask_for_more_than_the_cores(tmp_path):
@@ -296,4 +307,5 @@ async def test_a_number_the_machine_cannot_honour_is_refused(state, monkeypatch,
with pytest.raises(ops.OpError):
await ops.set_node_settings(state, {"connection_processes": value})
assert state["webrtc"].asked == []
- assert (await ops.get_node_settings(state))["connection_processes"] == 1
+ assert ((await ops.get_node_settings(state))["connection_processes"]
+ == state["config"].node.connection_processes)
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index 6ee4891..0110b6c 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -1497,6 +1497,27 @@ def test_msix_gives_the_cli_an_execution_alias_and_the_windows_it_needs():
assert 'xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"' in xml
+def test_msix_gives_the_command_line_client_an_alias_of_its_own_application():
+ """An Application may declare one windows.appExecutionAlias extension.
+ meshbay-node.exe has the app's; a second one beside it for meshbay-cli.exe
+ made makeappx refuse the whole package ("must only be declared once"), at
+ the very end of a Store build. The client is an Application of its own,
+ kept out of the Start menu, with its alias and its firewall rule."""
+ xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8")
+ ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8")
+ assert ext.count('Category="windows.appExecutionAlias"') == 1
+ apps = re.findall(r"<Application\b.*?</Application>", xml, re.S)
+ for app in apps:
+ assert app.count('Category="windows.appExecutionAlias"') <= 1, app[:80]
+ [cli] = [a for a in apps if 'Executable="app\\resources\\node-runtime\\meshbay-cli.exe"'
+ in a.split(">", 1)[0]]
+ assert 'AppListEntry="none"' in cli
+ assert 'Alias="meshbay-cli.exe"' in cli
+ package_level = xml.split("</Applications>", 1)[1]
+ rule = package_level.split('Executable="app\\resources\\node-runtime\\meshbay-cli.exe"', 1)
+ assert len(rule) == 2 and 'IPProtocol="UDP"' in rule[1].split("</desktop2:", 1)[0]
+
+
def test_the_node_runtime_has_a_windowless_daemon_beside_the_cli():
spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8")
windowless = spec.split('name="meshbay-nodew"', 1)
diff --git a/packages/meshbay-node/tests/test_proactor_udp.py b/packages/meshbay-node/tests/test_proactor_udp.py
new file mode 100644
index 0000000..0b22b4e
--- /dev/null
+++ b/packages/meshbay-node/tests/test_proactor_udp.py
@@ -0,0 +1,150 @@
+"""
+On Windows the node's datagrams go straight through the socket, not one
+overlapped operation per datagram and per turn of the loop (`proactor_udp.py`).
+
+Run on a real ProactorEventLoop, which only Windows has.
+"""
+
+import asyncio
+import socket
+import sys
+import time
+
+import pytest
+from meshbay_node.transport.webrtc import proactor_udp
+
+pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="the proactor is Windows'")
+
+N = 2000
+
+
+class _Collect(asyncio.DatagramProtocol):
+ def __init__(self, raise_on: int = -1):
+ self.got: list[int] = []
+ self.raise_on = raise_on
+ self.all = asyncio.get_running_loop().create_future()
+
+ def datagram_received(self, data, addr):
+ self.got.append(int.from_bytes(data[:4], "big"))
+ if len(self.got) == N and not self.all.done():
+ self.all.set_result(None)
+ if len(self.got) == self.raise_on:
+ raise RuntimeError("a protocol's own bug")
+
+
+class _Refusing(socket.socket):
+ """A socket that refuses a send every so often, as a full buffer does, and
+ can answer one read with the reset an ICMP port unreachable leaves. The
+ overlapped path works by handle and never calls these."""
+
+ def __init__(self, every: int = 0, reset_once: bool = False):
+ super().__init__(socket.AF_INET, socket.SOCK_DGRAM)
+ self.every, self.calls, self.reset_once = every, 0, reset_once
+
+ def sendto(self, data, addr):
+ self.calls += 1
+ if self.every and self.calls % self.every == 0:
+ raise BlockingIOError
+ return super().sendto(data, addr)
+
+ def recvfrom(self, n):
+ if self.reset_once:
+ self.reset_once = False
+ raise ConnectionResetError(10054, "port unreachable")
+ return super().recvfrom(n)
+
+
+def _busy_turns(ms: float):
+ """A loop with other work to do: every turn takes `ms` longer."""
+ async def busy():
+ while True:
+ end = time.perf_counter() + ms / 1000
+ while time.perf_counter() < end:
+ pass
+ await asyncio.sleep(0)
+ return asyncio.ensure_future(busy())
+
+
+async def _exchange(*, refuse_every=0, reset_once=False, raise_on=-1, busy_ms=0.0):
+ """N datagrams from one proactor transport to another on loopback: what
+ arrived in what order, and how many overlapped sends and reads it took."""
+ loop = asyncio.get_running_loop()
+ rsock = _Refusing(reset_once=reset_once)
+ rsock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 << 20)
+ rsock.bind(("127.0.0.1", 0))
+ rtr, rx = await loop.create_datagram_endpoint(lambda: _Collect(raise_on), sock=rsock)
+ tsock = _Refusing(refuse_every)
+ tsock.bind(("127.0.0.1", 0))
+ tx, _ = await loop.create_datagram_endpoint(asyncio.DatagramProtocol, sock=tsock)
+ counts = {"sendto": 0, "recvfrom": 0}
+ originals = {name: getattr(loop._proactor, name) for name in counts}
+
+ def counting(name):
+ def call(*a, **kw):
+ counts[name] += 1
+ return originals[name](*a, **kw)
+ return call
+ for name in counts:
+ setattr(loop._proactor, name, counting(name))
+ errors = []
+ loop.set_exception_handler(lambda _loop, ctx: errors.append(ctx))
+ busy = _busy_turns(busy_ms) if busy_ms else None
+ try:
+ dest = rsock.getsockname()
+ for i in range(N):
+ tx.sendto(i.to_bytes(4, "big") * 50, dest)
+ if i % 50 == 49:
+ await asyncio.sleep(0)
+ await asyncio.wait_for(rx.all, 20)
+ return rx.got, counts, errors
+ finally:
+ if busy:
+ busy.cancel()
+ for name, fn in originals.items():
+ setattr(loop._proactor, name, fn)
+ tx.close()
+ rtr.close()
+
+
+def _run(**kw):
+ return asyncio.run(_exchange(**kw), loop_factory=asyncio.ProactorEventLoop)
+
+
+def test_the_transport_is_patched():
+ cls = asyncio.proactor_events._ProactorDatagramTransport
+ assert cls.sendto is proactor_udp.sendto
+ assert cls._loop_reading is proactor_udp._loop_reading
+
+
+def test_datagrams_go_straight_out_in_order():
+ got, counts, _ = _run()
+ assert got == list(range(N))
+ assert counts["sendto"] == 0
+
+
+def test_what_the_socket_refuses_is_queued_and_order_is_kept():
+ """A refusal sends that datagram the overlapped way, and the ones after it
+ wait behind it rather than overtake it."""
+ got, counts, _ = _run(refuse_every=97)
+ assert got == list(range(N))
+ assert counts["sendto"] >= N // 97
+
+
+def test_a_busy_loop_reads_everything_waiting_in_order():
+ """Unpatched, this loop read one datagram a turn: about 2,000 a second
+ whatever was waiting, and the kernel dropped the rest."""
+ got, counts, _ = _run(busy_ms=2)
+ assert got == list(range(N))
+ assert counts["recvfrom"] < N // 10
+
+
+def test_a_port_unreachable_on_a_direct_read_is_skipped():
+ got, _, errors = _run(reset_once=True, busy_ms=2)
+ assert got == list(range(N))
+ assert not errors
+
+
+def test_a_protocol_that_raises_does_not_end_the_reading():
+ got, _, errors = _run(raise_on=10)
+ assert got == list(range(N))
+ assert [str(e["exception"]) for e in errors] == ["a protocol's own bug"]
diff --git a/packages/meshbay-node/tests/test_sctp_fix.py b/packages/meshbay-node/tests/test_sctp_fix.py
index 0b2536a..c3fe622 100644
--- a/packages/meshbay-node/tests/test_sctp_fix.py
+++ b/packages/meshbay-node/tests/test_sctp_fix.py
@@ -1,4 +1,5 @@
-"""aiortc's SCTP sender must not leak bytes in flight (see sctp_fix.py).
+"""aiortc's SCTP stack as sctp_fix.py leaves it: no bytes leaked in flight,
+reassembly in linear time, one SACK every second packet.
Replayed against aiortc's own RTCSctpTransport: three chunks sent, the first
lost and the other two acknowledged in a gap block, T3 expires and they are
@@ -8,10 +9,20 @@ enough of those stop the node from sending anything on that connection.
"""
import asyncio
-
-from aiortc.rtcsctptransport import RTCSctpTransport, SackChunk
+import random
import meshbay_node.transport.webrtc.core # noqa: F401 (applies the fix)
+from aiortc.rtcsctptransport import (
+ SCTP_DATA_FIRST_FRAG,
+ SCTP_DATA_LAST_FRAG,
+ SCTP_DATA_UNORDERED,
+ DataChunk,
+ InboundStream,
+ RTCSctpTransport,
+ SackChunk,
+ serialize_packet,
+)
+from meshbay_node.transport.webrtc import sctp_fix
class _Dtls:
@@ -57,3 +68,137 @@ def test_nothing_outstanding_means_nothing_in_flight():
s = asyncio.run(_replay())
assert not s._sent_queue
assert s._flight_size == 0
+
+
+# ── Reassembly (see sctp_fix.py) ─────────────────────────────────────────────
+
+
+def _fragments(messages: list[int], *, unordered: bool = False, tsn: int = 1000):
+ """DATA chunks for messages of the given fragment counts, in TSN order."""
+ out = []
+ for seq, n in enumerate(messages):
+ for i in range(n):
+ c = DataChunk()
+ c.flags = ((SCTP_DATA_FIRST_FRAG if i == 0 else 0)
+ | (SCTP_DATA_LAST_FRAG if i == n - 1 else 0)
+ | (SCTP_DATA_UNORDERED if unordered else 0))
+ c.tsn, c.stream_id, c.stream_seq, c.protocol = tsn, 1, seq, 53
+ c.user_data = f"{seq}.{i};".encode()
+ tsn += 1
+ out.append(c)
+ return out
+
+
+class _Original:
+ """aiortc's own reassembly, untouched, for the same arrivals."""
+
+ def __init__(self):
+ self.reassembly, self.sequence_number = [], 0
+
+ def add(self, chunk):
+ sctp_fix._add_chunk(self, chunk)
+ return list(sctp_fix._pop_messages(self))
+
+
+def test_reassembly_gives_what_aiortc_gives_for_any_arrival_order():
+ rng = random.Random(7)
+ for trial in range(300):
+ messages = [rng.randint(1, 12) for _ in range(rng.randint(1, 8))]
+ chunks = _fragments(messages, unordered=trial % 3 == 0)
+ # Reordered within a short window, as a network reorders.
+ for i in range(len(chunks)):
+ j = min(len(chunks) - 1, i + rng.randint(0, 4))
+ chunks[i], chunks[j] = chunks[j], chunks[i]
+ fixed, original = InboundStream(), _Original()
+ got, want = [], []
+ for c in chunks:
+ fixed.add_chunk(c)
+ got.append(list(fixed.pop_messages()))
+ want.append(original.add(c))
+ assert got == want, (trial, messages)
+
+
+def test_a_large_message_is_walked_once_not_once_per_fragment(monkeypatch):
+ walks = []
+ original = sctp_fix._pop_messages
+
+ def counted(stream):
+ walks.append(len(stream.reassembly))
+ return original(stream)
+
+ monkeypatch.setattr(sctp_fix, "_pop_messages", counted)
+ s = InboundStream()
+ out = []
+ for c in _fragments([1000, 1000]):
+ s.add_chunk(c)
+ out += list(s.pop_messages())
+ assert len(out) == 2
+ assert walks == [1000, 1000], "one walk per message, when its last fragment is in"
+
+
+def test_pruning_a_last_fragment_does_not_stall_the_next_message():
+ s = InboundStream()
+ first, second = _fragments([3]), _fragments([2], tsn=1003)
+ second[0].stream_seq = second[1].stream_seq = 1
+ for c in first[1:]: # the first fragment never comes
+ s.add_chunk(c)
+ assert not list(s.pop_messages())
+ s.prune_chunks(first[-1].tsn) # abandoned (FORWARD TSN)
+ s.sequence_number = 1
+ for c in second:
+ s.add_chunk(c)
+ assert [m[2] for m in s.pop_messages()] == [b"0.0;0.1;"]
+
+
+# ── Delayed acknowledgement (see sctp_fix.py) ────────────────────────────────
+
+def _receiver():
+ s = RTCSctpTransport(_Dtls())
+ s._association_state = s.State.ESTABLISHED
+ s._last_received_tsn = 999
+ sacks = []
+
+ async def send_sack():
+ sacks.append(s._last_received_tsn)
+ s._sack_needed = False
+ s._sack_duplicates.clear()
+
+ async def deliver(*_a):
+ pass
+
+ s._send_sack = send_sack
+ s._receive = deliver
+ return s, sacks
+
+
+def _packet(s, chunk) -> bytes:
+ return serialize_packet(5000, 5000, s._local_verification_tag, chunk)
+
+
+def test_one_sack_every_second_packet_of_data():
+ async def go():
+ s, sacks = _receiver()
+ for c in _fragments([1] * 10):
+ await s._handle_data(_packet(s, c))
+ return sacks
+ assert asyncio.run(go()) == [1001, 1003, 1005, 1007, 1009]
+
+
+def test_a_gap_is_acknowledged_at_once():
+ async def go():
+ s, sacks = _receiver()
+ chunks = _fragments([1] * 4)
+ await s._handle_data(_packet(s, chunks[0]))
+ await s._handle_data(_packet(s, chunks[2])) # 1001 missing
+ return sacks
+ assert asyncio.run(go()) == [1000], "the gap is the loss signal the sender waits for"
+
+
+def test_a_lone_packet_is_acknowledged_within_the_delay():
+ async def go():
+ s, sacks = _receiver()
+ await s._handle_data(_packet(s, _fragments([1])[0]))
+ assert sacks == []
+ await asyncio.sleep(sctp_fix.SACK_DELAY_S + 0.1)
+ return sacks
+ assert asyncio.run(go()) == [1000]