diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-13 00:30:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-13 00:30:39 +0200 |
| commit | efbcff05721e71f1fdfcc30b7ee1b44ca1670762 (patch) | |
| tree | 23018295b653510900ac24b71a780d74e32a01a2 | |
| parent | e9a93c21ff28daa6f4017849b42fe073a5ee2005 (diff) | |
| download | meshbay-efbcff05721e71f1fdfcc30b7ee1b44ca1670762.tar.gz | |
test(node): run the connection processes on Windows, source and frozen
Connection processes and the larger UDP receive buffer were written and
measured on Linux; nothing had run on Windows, and `meshbay-node.exe
webrtc-worker` had never run from a frozen build on any system.
- test_connection_processes.py failed on Windows (8 tests): the selector
loop the suite uses there cannot start a process. A test marked
`daemon_loop` now runs on the daemon's own loop, chosen through
pytest-asyncio's loop factories.
- The build starts both frozen executables as connection processes and
carries a connection through each (smoke-connection-process.py). The
daemon's own smoke test never signs in to a hub, which is when the
processes start.
- The warning for a receive buffer below what was asked named
net.core.rmem_max on every system; it names it on Linux only. Windows
grants the 4 MB.
Measured on an installed build in service mode, docs/WINDOWS-PORT.md §5.12:
two processes at startup and on a live change, a browser's handshake, chat,
a 200 MB download over 8 connections across both processes, an upload, a
video stream, and stop, restart and start leaving no process behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 6 | ||||
| -rw-r--r-- | docs/WINDOWS-PORT.md | 47 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 11 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/conftest.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_connection_processes.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 24 | ||||
| -rw-r--r-- | packaging/win/README.md | 2 | ||||
| -rw-r--r-- | packaging/win/build-node-runtime.ps1 | 16 | ||||
| -rw-r--r-- | packaging/win/smoke-connection-process.py | 86 |
9 files changed, 221 insertions, 9 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index d0b5475..0703993 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1463,7 +1463,10 @@ dies takes its own connections with it, which their clients see as a dropped connection and reopen, and is replaced at the next offer. The loopback socket is TCP because that is the one transport the event loop offers identically on every platform; a process proves it is the one the node started by returning 32 random -bytes handed to it on its standard input. +bytes handed to it on its standard input. A frozen node (the Windows build) has +no interpreter to start one with and starts a copy of itself, +`meshbay-node.exe webrtc-worker`, which the build runs for both of its +executables (`packaging/win/smoke-connection-process.py`). **Uploads met the same wall from the other side.** A browser 22 ms away uploaded at 6.5 MB/s, with no core busy on either machine and no loss in the node's log. @@ -4465,7 +4468,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **A folder renamed on the node's disk empties until the next reconciliation** | Seen live (2026-10-10, ntfs3): renaming a 2,441-file folder inside a root made the watcher drop every entry under the old path and announce none under the new one, so the group read as empty, and the loop was busy long enough for the hub's keepalive to time out. A restart (full scan) brought them back. A directory moved within a root should rescan its new subtree at once, not wait for the periodic scan | | **A photo deleted from the group comes back after a reinstall** | The backup's memory of what it sent is the phone's ledger (§9.12); a reinstall or cleared data empties it, and the folder no longer holds the deleted photo, so it is sent again. Clean fix: the node keeps a tombstone per folder on `file_delete` (name, size, SHA-256 taken before deleting), the index carries them, and the backup records a match as sent. Not done: a protocol field and a node table for a rare case | | **`connection_processes` has no CLI verb** | Set from the Node page or `node.toml`; `meshbay-node` has no command for it, unlike the transfer caps | -| **Connection processes are untested on Windows and in a frozen build** | The frozen node starts them as `meshbay-node webrtc-worker`; written, never run there | | **A connection process does not slow a peer the node cannot keep up with** | What a process receives is forwarded to the node unbounded. An upload is bounded by its own rate, 5 to 6 MB/s a connection, so it would take a node stalled for seconds to matter | | **The home page says "No groups yet" when the hub cannot be reached** | An unreachable hub reads as an account with no groups, rather than as an error | | **`meshbay-node init` says "Settings → Link Node"** | The control is on the Profile page, as QUICKSTART says | diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md index 7e13aa0..840f22a 100644 --- a/docs/WINDOWS-PORT.md +++ b/docs/WINDOWS-PORT.md @@ -623,6 +623,53 @@ hidden-icons overflow until the person drags it onto the taskbar. No API promotes it, and an installer that edits the registry to force it is the kind of thing this project does not do. +### 5.12 Connection processes and the receive buffer (2026-10-12) + +**Scope:** `packaging/win/smoke-connection-process.py`, +`build-node-runtime.ps1`, `test_connection_processes.py`, +`transport/webrtc_server.py` (one log line) + +Both were written and measured on Linux (`docs/MESHBAY_DESIGN.md` §5.5) and +neither had run here. Measured on the VM, Windows 11, an installed build in +service mode: + +- **The receive buffer needs nothing.** Windows has no ceiling like + `net.core.rmem_max`: a socket asked for 4 MB gets 4 MB (and 1 GB when asked), + and a 3.6 MB burst sent at a socket nobody reads arrives whole, where the + default buffer keeps 66 KB of it. The node logs `UDP receive buffer: 4096 KB + per socket`. The warning for a smaller grant named the Linux sysctl whatever + the system; it names it on Linux only now. +- **A frozen node starts its connection processes.** With + `connection_processes = 2` the daemon in session 0 starts two copies of + itself (`meshbay-node.exe webrtc-worker`), at startup and when the setting + changes alike. The windowless `meshbay-nodew.exe` of the Store package works + as a connection process too (the build's check, below): its standard input + is a pipe, so it is there to read although the build has no console. +- **A browser's connection lands in one of them.** From the installed + application: handshake, chat, the index of a folder changed on disk, a 200 MB + download over 8 connections spread across both processes (12 MB/s, both ends + on this 2-core VM, no loss), an 80 MB upload, a video stream. Every file + arrived with the checksum it left with. +- **Stopping leaves nothing behind.** `autostart stop`, `restart-daemon` and + `autostart start` take one to three seconds with two processes running, and + no `meshbay-node.exe` is left after a stop. A frozen process whose parent is + killed outright ends within a second, because it ends when its socket to the + node does: an upgrade does not find the executable held by an orphan. Back + to 1, a process leaves when its last connection closes. + +Two things keep this from going unrun again. The build starts both executables +as connection processes and carries a connection through each +(`smoke-connection-process.py`): the daemon's own smoke test never signs in to +a hub, which is when the processes start. And `test_connection_processes.py` +runs on Windows, on the daemon's event loop: the selector loop the suite uses +there cannot start a process, and exists for two aiortc ends in one process, +which these tests do not have. A test asks for that loop with the +`daemon_loop` mark (`on_the_daemons_loop` in the node's `conftest.py`), which +the suite answers through pytest-asyncio's loop factories. + +A process the node ends reports exit code 1 on Windows however it left +(`TerminateProcess`), so the code says nothing about how a stop went. + --- ## 6. Execution order diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 1976ad4..fea642a 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -24,6 +24,7 @@ Signaling flow (handled externally by the hub): import asyncio import logging +import sys import time from typing import Any @@ -157,11 +158,15 @@ class WebRTCTransport: if got < udp_buffer.WANTED: # Said once, with what to change: nothing else would tell an # operator why uploads to this node stay around 6 MB/s. + # The remedy is Linux's: Windows grants what is asked (measured, + # 4 MB and beyond), and naming a sysctl there would send its + # operator looking for a setting the system does not have. + remedy = (f"; raise net.core.rmem_max to {udp_buffer.WANTED} to lift it" + if sys.platform.startswith("linux") else "") log.warning( "UDP receive buffer is %d KB per socket, below the %d KB an " - "upload needs to run at full speed; raise net.core.rmem_max " - "to %d to lift it", got // 1024, udp_buffer.WANTED // 1024, - udp_buffer.WANTED) + "upload needs to run at full speed%s", + got // 1024, udp_buffer.WANTED // 1024, remedy) else: log.info("UDP receive buffer: %d KB per socket", got // 1024) self._workers = WorkerPool( diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py index 32c9b3d..545528b 100644 --- a/packages/meshbay-node/tests/conftest.py +++ b/packages/meshbay-node/tests/conftest.py @@ -1,9 +1,11 @@ """Shared fixtures and helpers for node tests.""" +import asyncio import sys from pathlib import Path import pytest +import pytest_asyncio from meshbay_node.roots import RootSet # ffmpeg / ffprobe run via asyncio.create_subprocess_exec, which needs the @@ -17,6 +19,35 @@ needs_subprocess = pytest.mark.skipif( "SelectorEventLoop for aiortc", ) +# The other way round, for a test that starts processes and has no two aiortc +# ends in one process: on Windows it runs on the loop the daemon itself runs +# on. The loop is chosen per test through pytest-asyncio's loop factories; +# a version without them leaves the selector loop in place, where such a test +# cannot run. +_LOOP_FACTORIES = hasattr( + getattr(pytest_asyncio.plugin, "PytestAsyncioSpecs", None), "pytest_asyncio_loop_factories") +on_the_daemons_loop = [ + pytest.mark.daemon_loop, + pytest.mark.skipif( + sys.platform == "win32" and not _LOOP_FACTORIES, + reason="starts processes, which needs the ProactorEventLoop: asked for through " + "pytest-asyncio's loop factories, which this version does not have"), +] + + +def pytest_configure(config): + config.addinivalue_line( + "markers", "daemon_loop: on Windows, run on the event loop the daemon runs on") + + +@pytest.hookimpl(optionalhook=True) +def pytest_asyncio_loop_factories(config, item): + """The event loop of each async test here: the one it always had, except + for a test marked `daemon_loop` on Windows.""" + if sys.platform == "win32" and item.get_closest_marker("daemon_loop"): + return {"proactor": asyncio.ProactorEventLoop} + return {"default": asyncio.new_event_loop} + @pytest.fixture(autouse=True) def _no_log_file_in_the_developers_profile(monkeypatch): """A test that runs the daemon's entry point must not open its log file. diff --git a/packages/meshbay-node/tests/test_connection_processes.py b/packages/meshbay-node/tests/test_connection_processes.py index 47aded0..ab9dfd3 100644 --- a/packages/meshbay-node/tests/test_connection_processes.py +++ b/packages/meshbay-node/tests/test_connection_processes.py @@ -27,6 +27,13 @@ from meshbay_node.transport.webrtc.channel import _get_remote_ip, _pack from meshbay_node.transport.webrtc.workers import WorkerPool from meshbay_node.transport.webrtc_server import WebRTCTransport +from conftest import on_the_daemons_loop + +# The pool starts processes, which the loop the suite runs on under Windows +# cannot do; the node's end of each connection is in one of them, so that +# loop's reason (two aiortc ends in one process) does not apply here. +pytestmark = on_the_daemons_loop + async def _until(test, seconds: float = 15.0) -> None: for _ in range(int(seconds / 0.02)): diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 6f6388e..69eefc8 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -369,6 +369,30 @@ def test_the_build_starts_the_frozen_daemon_not_only_its_help(): assert 'url = "http://127.0.0.1:1"' in smoke +def test_the_build_starts_a_frozen_connection_process(): + """Above one `connection_processes` a frozen node starts copies of itself, + `meshbay-node.exe webrtc-worker`. No source checkout takes that path and + the daemon's smoke test stops short of it: it never signs in to a hub, + which is when the processes start.""" + build = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + step = build.split("smoke test: the frozen node as a connection process", 1)[1] + assert 'smoke-connection-process.py") $exe (Join-Path $OutDir "meshbay-nodew.exe")' in step + assert "if ($LASTEXITCODE -ne 0) { throw" in step + # The other end is the node's own modules, in the build interpreter: removed + # any earlier, the step has nothing to run with. + assert build.count("Remove-Item -Recurse -Force $BuildVenv") == 1 + assert "Remove-Item -Recurse -Force $BuildVenv" in step + + smoke = (WIN / "smoke-connection-process.py").read_text(encoding="utf-8") + assert 'workers._argv = lambda: [exe, "webrtc-worker"]' in smoke + # The words a frozen node starts itself with, and the ones it answers to. + node = ROOT / "packages" / "meshbay-node" / "src" / "meshbay_node" + assert 'return [sys.executable, "webrtc-worker"]' in ( + node / "transport" / "webrtc" / "workers.py").read_text(encoding="utf-8") + assert 'if sys.argv[1:2] == ["webrtc-worker"]:' in ( + node / "daemon.py").read_text(encoding="utf-8") + + def _run_stop_node(tmp_path, dummy_name, localappdata): """stop-node.ps1 as setup runs it, with the process and task names swapped for ones that belong to this test -- the real ones would stop the diff --git a/packaging/win/README.md b/packaging/win/README.md index eda728e..4ce1459 100644 --- a/packaging/win/README.md +++ b/packaging/win/README.md @@ -151,7 +151,7 @@ That runs [`build-win.ps1`](build-win.ps1): | 2 | `npm ci` + download Electron's Chromium | | 3 | bump Electron to the latest release (Chromium CVE policy; `-NoElectronBump` to skip) | | 4 | `npm run sync-ui` — copy the interface from `meshbay-hub/.../static` | -| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log | +| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log, and [`smoke-connection-process.py`](smoke-connection-process.py) starts both executables as connection processes (`connection_processes` above 1) and carries a connection through each | | 6 | `electron-builder --win nsis` → `packages/meshbay-client/dist/MeshBay-Setup-<version>.exe` | ### Video (ffmpeg) diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index aa5e759..c6a8af1 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -156,9 +156,6 @@ Step "default.env copied from QE\" # --- 6. publish ---------------------------------------------------- Move-Item $frozen $OutDir Remove-Item -Recurse -Force $pyiWork, $pyiDist -ErrorAction SilentlyContinue -if ($createdVenv -and -not $KeepBuildVenv) { - Remove-Item -Recurse -Force $BuildVenv -ErrorAction SilentlyContinue -} # --- 7. smoke test ----------------------------------------------- # Capture, do NOT pipe to Select-Object -First: that stops the native process @@ -192,6 +189,19 @@ $expect = [regex]::Match((Get-Content -LiteralPath $initPy -Raw), '__version__\s if (-not $expect) { throw "cannot read __version__ from $initPy" } & (Join-Path $WinDir "smoke-node-runtime.ps1") -Exe $exe -ExpectVersion $expect +# That daemon never signs in to a hub, so it never starts a connection process +# (connection_processes above 1): a frozen node starts those as copies of +# itself, a path no source checkout takes. Both executables, since the Store +# package's node is the windowless one. Run by the build interpreter, which +# has the node's modules for the other end -- hence the venv removed after. +Step "smoke test: the frozen node as a connection process" +& $Python (Join-Path $WinDir "smoke-connection-process.py") $exe (Join-Path $OutDir "meshbay-nodew.exe") +if ($LASTEXITCODE -ne 0) { throw "a frozen connection process did not carry a connection" } + +if ($createdVenv -and -not $KeepBuildVenv) { + Remove-Item -Recurse -Force $BuildVenv -ErrorAction SilentlyContinue +} + $mb = (Get-ChildItem $OutDir -Recurse | Measure-Object Length -Sum).Sum / 1MB Write-Host "" Write-Host ("OK node-runtime ready at {0} ({1:N0} MB)" -f $OutDir, $mb) -ForegroundColor Green diff --git a/packaging/win/smoke-connection-process.py b/packaging/win/smoke-connection-process.py new file mode 100644 index 0000000..a4a61b1 --- /dev/null +++ b/packaging/win/smoke-connection-process.py @@ -0,0 +1,86 @@ +"""Start a frozen meshbay-node as a connection process and carry bytes through it. + +With `connection_processes` above 1 the node starts copies of itself as +`meshbay-node.exe webrtc-worker` (transport/webrtc/workers.py). The daemon's own +smoke test never gets there: the processes start once the node has signed in to +a hub, and that test has none. This is the node's side of it, unchanged, with a +frozen executable as the process and an aiortc peer here as the browser. + +Run by build-node-runtime.ps1 with the build venv's interpreter, which has the +node's own modules. It takes any meshbay-node.exe, an installed one included +(`resources/node-runtime` under the application's directory). +""" + +import asyncio +import sys +import time + +from aiortc import RTCPeerConnection, RTCSessionDescription +from meshbay_node.transport.webrtc import workers + +CHUNK = bytes(48_000) +CHUNKS = 200 + + +async def until(test, what: str, seconds: float = 30.0) -> None: + deadline = time.monotonic() + seconds + while not test(): + if time.monotonic() > deadline: + raise SystemExit(f"FAILED {what}: not within {seconds:.0f}s") + await asyncio.sleep(0.02) + + +async def carry(exe: str) -> str: + workers._argv = lambda: [exe, "webrtc-worker"] + pool = workers.WorkerPool(2) + browser = RTCPeerConnection() + try: + await pool.ensure() + if pool.processes() != 2: + raise SystemExit(f"FAILED {pool.processes()} of 2 connection processes started") + + channel = browser.createDataChannel("mnp") + got: list[bytes] = [] + channel.on("message", got.append) + await browser.setLocalDescription(await browser.createOffer()) + peer = await pool.open(browser.localDescription.sdp) + ours: list = [] + peer.on("datachannel", ours.append) + await browser.setRemoteDescription( + RTCSessionDescription(sdp=await peer.negotiate(), type="answer")) + await until(lambda: channel.readyState == "open" and ours, "the data channel opens") + + heard: list[bytes] = [] + ours[0].on("message", heard.append) + channel.send(b"from the browser") + await until(lambda: heard == [b"from the browser"], "a message reaches the node") + + for _ in range(CHUNKS): + ours[0].send(CHUNK) + while ours[0].bufferedAmount > 2_000_000: + await asyncio.sleep(0.005) + await until(lambda: sum(map(len, got)) == CHUNKS * len(CHUNK), + "what the node sent arrives whole") + await until(lambda: ours[0].bufferedAmount == 0, + "the node's count of what is buffered comes back to zero") + finally: + started = [w.process for w in pool._workers] + await browser.close() + await pool.close() + # A process still there would be one an upgrade finds holding + # meshbay-node.exe. Its exit code says nothing: the node ends a process + # whose socket has closed, and on Windows that reads 1 however it left. + if any(p.returncode is None for p in started): + raise SystemExit("FAILED a connection process outlived the node's stop") + return f"{CHUNKS * len(CHUNK) // 1_000_000} MB through a connection process" + + +def main() -> None: + if len(sys.argv) < 2: + raise SystemExit(__doc__) + for exe in sys.argv[1:]: + print(f"OK {exe}: {asyncio.run(carry(exe))}") + + +if __name__ == "__main__": + main() |