diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_windows_node_lifecycle.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_windows_node_lifecycle.py | 235 |
1 files changed, 235 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py new file mode 100644 index 0000000..241f583 --- /dev/null +++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py @@ -0,0 +1,235 @@ +"""The node's life on a Windows desktop: set up at sign-in, stopped for real. + +Two defects from the first Windows beta tester, each found again live on a +clean install (2026-10-07): + +- After a service-mode install the node was never set up for the account that + signed in, the Node section stayed out of the sidebar, and the way out was + `meshbay-node init` in a terminal plus the key pasted on the profile page. +- A Stop left meshbay-node.exe running while the Node page said "Stopped": the + control API closes before the process exits, and the page asked nothing else. + +What can run for real here does (processes, the CLI's stop, the exit timer); +the desktop application's side is pinned by reading main.js and app.js, the only +evidence available without launching it -- tests/e2e_windows_app.py launches it. +""" + +import inspect +import os +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest +from meshbay_node import platform as plat +from meshbay_node.cli import lifecycle + +ROOT = Path(__file__).resolve().parents[3] +MAIN_JS = ROOT / "packages" / "meshbay-client" / "src" / "main.js" +STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" + + +def _js(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _block(src: str, start: str, end: str = "\n });\n") -> str: + return src.split(start, 1)[1].split(end, 1)[0] + + +# ── node.toml ──────────────────────────────────────────────────────────────── + +def test_a_byte_order_mark_in_node_toml_is_read(tmp_path): + from meshbay_node.config import load_config + cfg_file = tmp_path / "node.toml" + cfg_file.write_bytes(b"\xef\xbb\xbf" + + b'[hub]\nurl = "http://127.0.0.1:1"\nusername = "someone"\n') + assert load_config(cfg_file).hub.username == "someone" + + +def test_a_node_toml_it_cannot_read_is_said_not_swallowed(tmp_path): + """A service node has no console: the reason it will not start goes to + the log, where it used to die after "Logging to ..." in silence.""" + cfg_file = tmp_path / "node.toml" + cfg_file.write_text("[hub\nusername = ", encoding="utf-8") + env = {**os.environ, "LOCALAPPDATA": str(tmp_path), "APPDATA": str(tmp_path), + "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)} + r = subprocess.run([sys.executable, "-c", "from meshbay_node.daemon import main; main()", + "--config", str(cfg_file)], + capture_output=True, text=True, timeout=60, env=env) + assert r.returncode == 1, r.stdout + r.stderr + assert "cannot read" in r.stdout + if sys.platform == "win32": + log = tmp_path / "meshbay" / "state" / "node.log" + assert "cannot read" in log.read_text(encoding="utf-8") + + +# ── the daemon leaves when it has stopped ──────────────────────────────────── + +def test_a_stopped_node_does_not_wait_for_a_busy_thread(): + """A worker still walking a large tree kept the process alive after + _shutdown(), with its control API already closed.""" + script = textwrap.dedent(""" + import threading, time + from meshbay_node.daemon import exit_after + threading.Thread(target=time.sleep, args=(120,), name="walker").start() + exit_after(0.5, "the node has stopped") + """) + t0 = time.monotonic() + r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, + timeout=60) + assert r.returncode == 0, r.stderr + assert time.monotonic() - t0 < 30 + assert "walker" in r.stderr, "it names what it did not wait for" + + +def test_only_the_real_daemon_ends_its_own_process(): + """Tests run daemons inside pytest: the timer must be armed by main() + alone, both before _shutdown() (a hung shutdown) and after it.""" + from meshbay_node import daemon + assert daemon.NodeDaemon.exit_process_when_stopped is False + assert "daemon.exit_process_when_stopped = True" in inspect.getsource(daemon.main) + shutdown = inspect.getsource(daemon.NodeDaemon._shutdown) + assert shutdown.count("if self.exit_process_when_stopped:") == 2 + assert shutdown.index("SHUTDOWN_DEADLINE_SECS") < shutdown.index('log.info("Node stopped")') \ + < shutdown.index("EXIT_GRACE_SECS") + + +# ── the CLI's stop: by pid, then checked ───────────────────────────────────── + +class _Stub: + def __init__(self, monkeypatch, *, answering=None, graceful=False, pids=(), + kill_works=True): + self.killed: list[int] = [] + self.pids = list(pids) + monkeypatch.setattr(lifecycle, "_running_status", lambda cfg: answering) + monkeypatch.setattr(plat, "request_graceful_stop", lambda *a, **k: graceful) + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + monkeypatch.setattr(plat, "_pid_alive", lambda pid: pid in self.pids) + monkeypatch.setattr(plat, "autostart_end", lambda: None) + monkeypatch.setattr(plat, "node_pids", lambda: list(self.pids)) + + def kill(pid): + self.killed.append(pid) + if kill_works: + self.pids.remove(pid) + monkeypatch.setattr(plat, "kill_pid", kill) + clock = iter(range(0, 10_000)) + monkeypatch.setattr(time, "monotonic", lambda: next(clock)) + monkeypatch.setattr(time, "sleep", lambda s: None) + + +class _Cfg: + data_dir = Path(".") + + class node: + ui_port = 1 + + +def test_a_stop_the_node_did_not_finish_kills_it_by_pid(monkeypatch): + stub = _Stub(monkeypatch, answering={"pid": 4242}, graceful=False, pids=[4242]) + assert lifecycle._stop_node(_Cfg) == "stopped (forced)" + assert stub.killed == [4242] + + +def test_a_node_process_left_behind_is_never_called_stopped(monkeypatch): + _Stub(monkeypatch, answering=None, graceful=False, pids=[5151], kill_works=False) + with pytest.raises(lifecycle.NodeStillRunning, match="5151"): + lifecycle._stop_node(_Cfg) + + +def test_a_node_answering_nothing_is_still_a_node(monkeypatch): + """Its API closed, its process there: not "not running".""" + stub = _Stub(monkeypatch, answering=None, graceful=False, pids=[6161]) + monkeypatch.setattr(plat, "autostart_end", lambda: stub.pids.clear()) + assert lifecycle._stop_node(_Cfg) == "stopped (forced)" + + +def test_a_stop_that_fails_exits_non_zero_with_the_reason(monkeypatch, capsys): + def refuse(cfg): + raise lifecycle.NodeStillRunning("meshbay-node is still running (pid 7)") + monkeypatch.setattr(lifecycle, "_stop_node", refuse) + monkeypatch.setattr(lifecycle, "load_config", lambda p: _Cfg) + + class Args: + config = None + subcommand = "stop" + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + monkeypatch.setattr(sys, "platform", "win32") + with pytest.raises(SystemExit) as e: + lifecycle.autostart(Args) + assert e.value.code == 1 + assert "pid 7" in capsys.readouterr().out + + +@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes") +def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): + """Under an image name of this test's own: the real one would find, and + kill, the developer's node.""" + import shutil + exe = tmp_path / "mbpidtest.exe" + shutil.copy(r"C:\Windows\System32\PING.EXE", exe) + proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe") + try: + assert plat.node_pids() == [proc.pid] + plat.kill_pid(proc.pid) + assert proc.wait(timeout=10) is not None + assert plat.node_pids() == [] + finally: + if proc.poll() is None: + proc.kill() + + +# ── the desktop application: what it says, and when it sets the node up ────── + +def test_the_node_page_asks_the_node_not_only_the_task(): + """A node started from a terminal runs while the service task reads + "Ready"; one on its way out answers nothing for a while. The page said + "Stopped" about both.""" + main_js = _js(MAIN_JS) + status = main_js.split("async function nodeServiceStatus()", 1)[1].split("\n }\n", 1)[0] + assert "/running/i.test(svc.state)" not in status + assert status.count("winNodeActivity()") == 2 + activity = main_js.split("async function winNodeActivity()", 1)[1].split("\n }\n", 1)[0] + assert "probeNode()" in activity and "winNodePids()" in activity + assert "'unknown'" in activity and "'inactive'" in activity + page = _js(STATIC / "node-page.js") + assert "const stoppable = info.activeState !== 'inactive';" in page + assert "disabled=${!!busy || !stoppable}" in page + + +def test_signing_in_sets_up_this_machines_node(): + main_js = _js(MAIN_JS) + assert "handle('node:ensure'" in main_js + ensure = main_js.split("async function ensureNode(opts)", 1)[1].split("\n }\n", 1)[0] + # Only the node this build ships, on Windows: Linux is unchanged. + assert "process.platform !== 'win32' || !hasBundledNode()" in ensure + # A node set up for someone else is left as it is... + assert ensure.index("return { state: 'other_account' }") < ensure.index("provisionNode(") + # ...and so is an account already linked to another node. + assert "if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' };" in ensure + assert ensure.index("state: 'other_node'") < ensure.index("linkNodeKeyAndAwaitRunning(") + # Started however this machine is set up, through the CLI like every start. + assert "winNodeStartVia(['autostart', 'start'])" in ensure + # One start at a time. + start = main_js.split("handle('node:start'", 1)[1][:400] + assert "if (nodeEnsuring) await nodeEnsuring" in start + preload = _js(ROOT / "packages" / "meshbay-client" / "src" / "preload.js") + assert "ensure: (opts) => ipcRenderer.invoke('node:ensure', opts)" in preload + assert "bridge.node.ensure(opts)" in _js(STATIC / "platform.js") + + +def test_the_node_section_shows_for_a_node_not_yet_linked(): + """It waited for the hub to hold a node key, which nothing gave it.""" + app = _js(STATIC / "app.js") + assert "showNode=${hasNodeKey || localNode}" in app + assert "platform.capabilities.nodeAdmin && (hasNodeKey || localNode)" in app + effect = app.split("const signedInAs = user ? user.username : null;", 1)[1] + effect = effect.split("}, [signedInAs]);", 1)[0] + assert "platform.node.ensure(" in effect and "platform.node.installed()" in effect + # The token rotates; a renewal is not a sign-in. + assert "}, [signedInAs]);" in app |