diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:20:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:20:35 +0200 |
| commit | 7662484cae8e74b7d9aa383bd6cd0dad4690aadc (patch) | |
| tree | 057609311e339c839dcad04b33de62e2541a9943 /packages/meshbay-node/tests/test_platform.py | |
| parent | 326d7796c79f616f5a0f2058386df2bded657c78 (diff) | |
| download | meshbay-7662484cae8e74b7d9aa383bd6cd0dad4690aadc.tar.gz | |
fix(node): a Windows daemon that stops properly, starts honestly and runs once
Found by installing the builds and driving every startup mode live:
- Stop through the node's own control API first (POST /api/shutdown, loopback
and per-run token): the one channel that reaches a daemon in any session
without elevation -- a service node runs in session 0 -- and the one that
runs its shutdown. Then Task Scheduler, then a forced stop. Nine stops in a
row used to log no shutdown at all: each was a TerminateProcess.
- The forced stop spares the command running it. The frozen meshbay-node.exe
is the daemon and every CLI verb, so `taskkill /IM meshbay-node.exe` killed
`autostart stop` and `restart-daemon` themselves: exit 1, no output, and no
node after a restart. It excludes its own pid and its parent's, and /T takes
a venv launcher's python child and a daemon's ffmpeg children with it.
- Start and restart report the version that answered, never "started" about a
node nobody asked; `service start` says so when no node answered, and where
the log is.
- A second instance fails before it touches anything. The daemon wrote
ui-token, then failed to bind inside uvicorn's task and exited with the
reason on a hidden console; the node still running then refused every stop
and status, its token file naming a dead process. The control port is now
bound first (exclusively on Windows, where SO_REUSEADDR would share it), and
a refusal is logged and exits 2. Linux had the same order.
- The daemon logs to %LOCALAPPDATA%\meshbay\state\node.log: Task Scheduler
discards its stderr. Only the daemon run opens it, never a CLI verb.
- Hub sign-in waits are interruptible, a stop requested before the node is up
is honoured, and a hub that answers 429 or restarts leaves the node in
waiting_for_hub rather than looking dead.
- operator_paired is null until the roster is read, instead of a false that
showed "No operator paired" about a node whose pairing was intact.
The node test conftest also points HOME, USERPROFILE, LOCALAPPDATA and APPDATA
at a throwaway directory for every test, and keeps log_file() away from the
developer's own node: redirecting HOME alone isolates nothing on Windows, and
the CLI tests had been writing invite and pairing codes into the real profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_platform.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_platform.py | 252 |
1 files changed, 164 insertions, 88 deletions
diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index bebe8d9..8856962 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -8,6 +8,7 @@ suite happens to run on. import asyncio import os import sys +import time from pathlib import Path from unittest.mock import Mock @@ -168,10 +169,10 @@ def test_autostart_install_refuses_off_windows(monkeypatch): plat.autostart_install(exe="/usr/bin/meshbay-node") -def test_autostart_run_launches_the_resolved_exe_windowless_and_records_its_pid( - win_startup, monkeypatch, tmp_path): +def test_autostart_run_launches_windowless_and_inherits_nothing(win_startup, monkeypatch): + """close_fds: the desktop app starts its node through here because a child + of Electron inherited Electron's sockets and held them after the app quit.""" monkeypatch.setattr(plat, "_node_exe", lambda: r"C:\x\meshbay-node.exe") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) # state_dir() -> pidfile location calls = {} def fake_popen(argv, **kw): @@ -181,13 +182,23 @@ def test_autostart_run_launches_the_resolved_exe_windowless_and_records_its_pid( monkeypatch.setattr(plat.subprocess, "Popen", fake_popen) plat.autostart_run() assert calls["argv"] == [r"C:\x\meshbay-node.exe"] - flags = calls["kw"]["creationflags"] - assert flags & 0x08000000 # CREATE_NO_WINDOW - assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP - assert not flags & 0x00000008 # not DETACHED_PROCESS -- that has no - # console at all, so CTRL_BREAK_EVENT - # would have nothing to signal - assert plat._pid_file().read_text(encoding="utf-8") == "4242" + assert calls["kw"]["creationflags"] & 0x08000000 # CREATE_NO_WINDOW + assert not calls["kw"]["creationflags"] & 0x00000008 # a console of its own: + # CTRL_LOGOFF/SHUTDOWN reach install_console_close_handler + assert calls["kw"]["close_fds"] is True + + +def test_the_launcher_is_this_very_executable_when_frozen(monkeypatch, tmp_path): + """PATH came first, and with two copies on it `autostart start` from one + install launched the other -- found on a machine with an MSIX build on PATH.""" + stray = tmp_path / "stray" + stray.mkdir() + (stray / "meshbay-node.exe").write_text("", encoding="utf-8") + monkeypatch.setenv("PATH", str(stray)) + monkeypatch.setattr(plat.sys, "frozen", True, raising=False) + frozen = r"C:\Programs\MeshBay\node-runtime\meshbay-node.exe" + monkeypatch.setattr(plat.sys, "executable", frozen) + assert plat._node_exe() == frozen def test_autostart_run_refuses_off_windows(monkeypatch): @@ -196,100 +207,165 @@ def test_autostart_run_refuses_off_windows(monkeypatch): plat.autostart_run() -# ── Graceful stop (CTRL_BREAK_EVENT + taskkill fallback) ──────────────────── +# ── Stopping ──────────────────────────────────────────────────────────────── # -# autostart_end() references signal.CTRL_BREAK_EVENT, which genuinely does not -# exist in the `signal` module off Windows -- monkeypatching sys.platform -# cannot manufacture it, unlike the pure-Python behaviour tested above. Skip -# rather than mock around it, matching test_configure_event_loop_selector_opt_in. +# The CTRL_BREAK_EVENT path these tests used to pin was the bug: aimed at a +# node with a console of its own, GenerateConsoleCtrlEvent reached every process +# on the caller's console, so `autostart stop` and `restart-daemon` killed +# themselves. The tests mocked os.kill, so they agreed with it by construction. -@pytest.mark.skipif(sys.platform != "win32", - reason="signal.CTRL_BREAK_EVENT exists only on win32") -def test_autostart_end_stops_gracefully_when_ctrl_break_is_enough(monkeypatch, tmp_path): +def test_autostart_end_force_stops_by_image_name(monkeypatch): monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - plat._pid_file().parent.mkdir(parents=True, exist_ok=True) - plat._pid_file().write_text("4242", encoding="utf-8") - - kill_calls = [] - monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig))) - # Alive (our exe) on the pre-signal check, gone by the first poll after -- - # a plain constant can't tell those two calls apart. - seen = {"n": 0} - - def fake_check(pid): - seen["n"] += 1 - return seen["n"] == 1 - - monkeypatch.setattr(plat, "_pid_is_meshbay_node", fake_check) run_calls = [] monkeypatch.setattr(plat.subprocess, "run", lambda argv, **kw: run_calls.append(argv)) - plat.autostart_end() - - assert kill_calls == [(4242, plat.signal.CTRL_BREAK_EVENT)] - assert run_calls == [] # no taskkill needed - assert not plat._pid_file().exists() + [argv] = run_calls + assert argv[:5] == ["taskkill", "/F", "/T", "/IM", "meshbay-node.exe"] -@pytest.mark.skipif(sys.platform != "win32", - reason="signal.CTRL_BREAK_EVENT exists only on win32") -def test_autostart_end_falls_back_to_taskkill_when_the_pid_never_exits( - monkeypatch, tmp_path): +def test_the_forced_stop_spares_the_command_that_runs_it(monkeypatch): + """The CLI is meshbay-node.exe too: `taskkill /IM meshbay-node.exe` killed + `autostart stop` and `restart-daemon` themselves -- found on a real install.""" monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - plat._pid_file().parent.mkdir(parents=True, exist_ok=True) - plat._pid_file().write_text("4242", encoding="utf-8") - - monkeypatch.setattr(plat.os, "kill", lambda pid, sig: None) - monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: True) # never exits - monkeypatch.setattr(plat.time, "sleep", lambda s: None) # don't really wait - clock = iter([0.0, 1.0, 6.0]) # deadline = 0.0 + 5.0; third read is past it - monkeypatch.setattr(plat.time, "monotonic", lambda: next(clock)) run_calls = [] monkeypatch.setattr(plat.subprocess, "run", lambda argv, **kw: run_calls.append(argv)) - plat.autostart_end() + [argv] = run_calls + assert f"PID ne {os.getpid()}" in argv and f"PID ne {os.getppid()}" in argv - assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] - assert not plat._pid_file().exists() +@pytest.mark.skipif(sys.platform != "win32", reason="taskkill") +def test_the_forced_stop_really_spares_its_caller(tmp_path): + """For real: a process with the node's image name runs the forced stop, and + must survive it while another process of that name does not. Under a name of + its own, or it would also kill the developer's running node.""" + import shutil + import subprocess + import textwrap + import uuid -def test_autostart_end_falls_back_to_taskkill_without_a_pidfile(monkeypatch, tmp_path): - """No CTRL_BREAK_EVENT dependency here -- there is no pid to signal, so - this one runs everywhere, same as the pre-existing behaviour it replaces.""" - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - run_calls = [] - monkeypatch.setattr(plat.subprocess, "run", - lambda argv, **kw: run_calls.append(argv)) - plat.autostart_end() - assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] + image = f"mb-test-{uuid.uuid4().hex[:8]}.exe" + base = Path(getattr(sys, "_base_executable", sys.executable)) + env = {**os.environ, "PYTHONHOME": sys.base_prefix} + def renamed_python(where: str) -> Path: + d = tmp_path / where + d.mkdir() + shutil.copy(base, d / image) + for dll in base.parent.glob("*.dll"): + shutil.copy(dll, d) + return d / image -def test_autostart_end_ignores_a_stale_pid_reused_by_another_process(monkeypatch, tmp_path): - """The recorded pid is alive but is not meshbay-node.exe -- Windows reused - it after the daemon exited. Must not send CTRL_BREAK_EVENT to whatever - that is; falls straight to taskkill (by image name, so harmless here).""" - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - plat._pid_file().parent.mkdir(parents=True, exist_ok=True) - plat._pid_file().write_text("4242", encoding="utf-8") + caller = renamed_python("caller") + victim = renamed_python("victim") # a "daemon" with a "transcode" + child_pid_file = tmp_path / "child.pid" + src = Path(plat.__file__).resolve().parents[1] + script = textwrap.dedent(f""" + import sys; sys.path.insert(0, {str(src)!r}) + from meshbay_node import platform as p + p.NODE_IMAGE = {image!r} + p.autostart_end() + print("survived") + """) + other = subprocess.Popen( + [str(victim), "-c", + "import subprocess, sys; p = subprocess.Popen(['ping', '-n', '300', '127.0.0.1']," + " stdout=subprocess.DEVNULL); open(sys.argv[1], 'w').write(str(p.pid)); p.wait()", + str(child_pid_file)], env=env) + child_pid = 0 + try: + for _ in range(100): + if child_pid_file.exists() and child_pid_file.read_text(encoding="utf-8"): + break + time.sleep(0.1) + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + r = subprocess.run([str(caller), "-c", script], capture_output=True, text=True, + timeout=60, env=env) + assert "survived" in r.stdout, (r.returncode, r.stdout, r.stderr) + assert other.wait(timeout=10) is not None, "the other process must be stopped" + assert not plat._pid_alive(child_pid), "and its children with it" + finally: + if other.poll() is None: + other.kill() + if child_pid: + subprocess.run(["taskkill", "/F", "/PID", str(child_pid)], capture_output=True) - monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: False) - kill_calls = [] - monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig))) - run_calls = [] - monkeypatch.setattr(plat.subprocess, "run", - lambda argv, **kw: run_calls.append(argv)) - plat.autostart_end() +def test_nothing_signals_a_console_group_any_more(): + """Read as code, not text: the docstrings say why CTRL_BREAK went.""" + import ast + import inspect + tree = ast.parse(inspect.getsource(plat)) + for node in ast.walk(tree): + assert not (isinstance(node, ast.Attribute) and node.attr == "CTRL_BREAK_EVENT") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + assert not (node.func.attr == "kill" and getattr(node.func.value, "id", "") == "os") + + +def _serve_node(tmp_path, pid): + """A control API that answers status and shutdown, as the daemon's does.""" + import json + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + asked = [] + + class H(BaseHTTPRequestHandler): + def _send(self, body): + data = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): # noqa: N802 + self._send({"version": "x", "status": "running", "pid": pid}) + + def do_POST(self): # noqa: N802 + asked.append(self.path) + self._send({"stopping": True}) + + def log_message(self, *a): + pass + + server = HTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=server.serve_forever, daemon=True).start() + (tmp_path / "ui-token").write_text("tok", encoding="utf-8") + return server, asked + + +def test_a_graceful_stop_asks_the_node_and_waits_for_its_process(monkeypatch, tmp_path): + server, asked = _serve_node(tmp_path, pid=4242) + alive = iter([True, True, False]) + monkeypatch.setattr(plat, "_pid_alive", lambda pid: next(alive)) + try: + assert plat.request_graceful_stop(tmp_path, server.server_address[1], timeout=10) + finally: + server.shutdown() + assert asked == ["/api/shutdown?t=tok"] + + +def test_a_node_that_does_not_exit_is_reported_for_the_caller_to_force(monkeypatch, tmp_path): + server, _ = _serve_node(tmp_path, pid=4242) + monkeypatch.setattr(plat, "_pid_alive", lambda pid: True) + try: + assert not plat.request_graceful_stop(tmp_path, server.server_address[1], timeout=1) + finally: + server.shutdown() + + +def test_nothing_to_stop_when_no_node_answers(tmp_path): + (tmp_path / "ui-token").write_text("tok", encoding="utf-8") + assert not plat.request_graceful_stop(tmp_path, 1, timeout=1) + - assert kill_calls == [] # never signalled the reused pid - assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] - assert not plat._pid_file().exists() +@pytest.mark.skipif(sys.platform != "win32", reason="tasklist") +def test_pid_alive_sees_this_process_and_not_a_dead_one(): + import os + assert plat._pid_alive(os.getpid()) + assert not plat._pid_alive(9_999_991) def test_autostart_end_is_a_noop_off_windows(monkeypatch): @@ -413,7 +489,7 @@ def test_frozen_build_finds_default_env_beside_the_executable(monkeypatch, tmp_p """Where build-node-runtime.ps1 puts it, alongside ffmpeg.""" exe = tmp_path / "meshbay-node.exe" exe.write_bytes(b"") - (tmp_path / "default.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJtest\n") + (tmp_path / "default.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJtest\n", encoding="utf-8") monkeypatch.setattr(sys, "frozen", True, raising=False) monkeypatch.setattr(sys, "executable", str(exe)) assert plat.packaged_default_env() == tmp_path / "default.env" @@ -434,7 +510,7 @@ def test_load_node_env_sets_names(monkeypatch, tmp_path): "\n" "MESHBAY_TMDB_DEFAULT_TOKEN=eyJloaded\n" 'QUOTED="value"\n' - ) + , encoding="utf-8") monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) monkeypatch.delenv("QUOTED", raising=False) assert plat.load_node_env(tmp_path) == 2 @@ -445,7 +521,7 @@ def test_load_node_env_sets_names(monkeypatch, tmp_path): def test_load_node_env_does_not_override_the_environment(monkeypatch, tmp_path): """systemd may have loaded the same file already, and an operator export must win over a packaged default.""" - (tmp_path / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJfromfile\n") + (tmp_path / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJfromfile\n", encoding="utf-8") monkeypatch.setenv("MESHBAY_TMDB_DEFAULT_TOKEN", "eyJfromenv") assert plat.load_node_env(tmp_path) == 0 assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJfromenv" @@ -460,7 +536,7 @@ def test_load_node_env_reads_the_packaged_default_without_a_node_env(monkeypatch """A node onboarded by the desktop client has no node.env: nothing ran `init` to copy one. The packaged token must reach it anyway.""" src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n", encoding="utf-8") monkeypatch.setattr(plat, "packaged_default_env", lambda: src) monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) cfg = tmp_path / "config" @@ -472,12 +548,12 @@ def test_load_node_env_reads_the_packaged_default_without_a_node_env(monkeypatch def test_load_node_env_prefers_the_operator_node_env(monkeypatch, tmp_path): src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n", encoding="utf-8") monkeypatch.setattr(plat, "packaged_default_env", lambda: src) monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) cfg = tmp_path / "config" cfg.mkdir() - (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n") + (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n", encoding="utf-8") plat.load_node_env(cfg) assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJoperator" |