aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_windows_node_lifecycle.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-07 21:25:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-07 22:22:51 +0200
commite833fe1bfc8eb6f66cc5dc53997cc4158bab583f (patch)
tree6874dfd09cb210eaf61e4f5761d0bad23c9cd0a3 /packages/meshbay-node/tests/test_windows_node_lifecycle.py
parent92e25ffcc5edf5d1a9996bfb921b5a95b826134b (diff)
downloadmeshbay-e833fe1bfc8eb6f66cc5dc53997cc4158bab583f.tar.gz
fix: set the Windows node up at sign-in, and stop it for real
Found by the first Windows beta tester, then reproduced on a clean install. After a service-mode install nothing set the node up for the account that signed in: the boot task started a node that quit ("hub.username not set"), and the sidebar showed Node / Create group only once the hub held a node key. The only way to the wizard that provisions was the home page's welcome card, which an account already in a group never sees. The way out was `meshbay-node init` and the key pasted on the profile page -- which is also what PACKAGING-GUIDE.md told people to do. - main.js `node:ensure`, called by app.js at sign-in: provisions, starts and links the node this build ships (Windows, bundled node only). A node set up for another account, or an account linked to another node, is left alone. node:start waits for it, so the two never race. - The sidebar shows the Node section when a node exists on this machine. - The Node page's status is the node's: its control API and the process list, not the service task's state (a node started from a terminal ran while the page said Stopped). Stop says Stopped only once no meshbay-node.exe is left, and stays offered for a process that answers nothing. - CLI stop kills the pid that answered when a graceful stop does not finish, and fails with the reason when a node process is still there. - The daemon ends its process 3s after _shutdown(): Python's exit waited for a busy indexer thread, with the control API already closed. Armed by main() only, never by a daemon run inside a test. - node.toml is read as utf-8-sig (PowerShell 5.1 writes a BOM), and a config that cannot be read is logged instead of dying silently in service mode. - "Pair this browser" queues the code for the next group of this node to open instead of saying "Paired successfully"; no banner before a group. - test_e2e_windows_app.py (opt-in, MESHBAY_WIN_E2E=1) drives the installed app against a throwaway hub: fresh account to linked node, Stop, Start, Restart, checked against the real processes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_windows_node_lifecycle.py')
-rw-r--r--packages/meshbay-node/tests/test_windows_node_lifecycle.py235
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