From 7662484cae8e74b7d9aa383bd6cd0dad4690aadc Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 27 Sep 2026 22:20:35 +0200 Subject: 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 --- .../meshbay-node/src/meshbay_node/cli/lifecycle.py | 154 ++++++++++++++++----- 1 file changed, 122 insertions(+), 32 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/cli/lifecycle.py') diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py index 6341561..901c3d5 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py +++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py @@ -24,28 +24,107 @@ def reload(args) -> None: return +def _await_daemon(cfg, since: float, timeout: float = 30.0) -> dict | None: + """The status of a daemon started at or after `since`, or None. + + The token file is rewritten at every start, so one older than `since` + belongs to the instance that was just stopped — answering from it would + report the old daemon as the new one. + """ + import json + import time + import urllib.request + + token_file = cfg.data_dir / "ui-token" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if token_file.stat().st_mtime >= since: + token = token_file.read_text(encoding="utf-8").strip() + url = f"http://127.0.0.1:{cfg.node.ui_port}/api/status?t={token}" + with urllib.request.urlopen(url, timeout=2) as r: + return json.loads(r.read()) + except (OSError, ValueError): + pass + time.sleep(0.5) + return None + + +def _running_status(cfg) -> dict | None: + """The status of whatever node answers right now, or None.""" + return _await_daemon(cfg, since=0, timeout=0.1) + + +def _stop_node(cfg) -> str: + """Stop the node, whichever session it runs in: through its own control API + first, so it shuts down properly, then by force. Returns how it went.""" + import time + + from meshbay_node.platform import ( + autostart_end, + request_graceful_stop, + service_end, + service_state, + service_status, + ) + if request_graceful_stop(cfg.data_dir, cfg.node.ui_port): + how = "stopped" + elif _running_status(cfg) is None: + how = "not running" + else: + how = "stopped (forced)" + if service_status()["installed"]: + # Also leaves the task "Ready": a /run while it still reads "Running" + # is dropped (MultipleInstances IgnoreNew), leaving no node at all. + service_end() + deadline = time.monotonic() + 15 + while service_state().lower() == "running" and time.monotonic() < deadline: + time.sleep(0.25) + autostart_end() # a node in this session that would not stop + return how + + +def _start_and_confirm(args, action: str) -> None: + """Start the node the way this machine is set up to, and report what + actually answered -- never "started" about a node nobody checked.""" + import time + + from meshbay_node import __version__ + from meshbay_node.platform import autostart_run, log_file, service_run, service_status + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + if action == "restart": + _stop_node(cfg) + else: + already = _running_status(cfg) + if already is not None: + print(f"already running — node {already.get('version', '?')}, " + f"{already.get('status', '?')}") + return + since = time.time() - 1 + service = service_status()["installed"] + try: + service_run() if service else autostart_run() + except RuntimeError as e: + print(f"Could not start the node: {e}") + sys.exit(1) + status = _await_daemon(cfg, since) + if status is None: + print("no node answered within 30s of being started" + + (" by the service task." if service else ".")) + print(f"See the node's log: {log_file()}") + sys.exit(1) + running = status.get("version", "?") + print(f"{'restarted' if action == 'restart' else 'started'} — node {running}, " + f"{status.get('status', '?')}") + if running != __version__: + print(f"warning: that is node {running} but this is {__version__} — " + "its files were not replaced, or another copy was started.") + + def restart_daemon(args) -> None: if sys.platform == "win32": - from meshbay_node.platform import ( - autostart_end, - autostart_run, - service_end, - service_run, - service_status, - ) - if service_status()["installed"]: - service_end() - service_run() - print("restarted the node (service task)") - return - autostart_end() # kill whatever is running now - try: - autostart_run() - except RuntimeError as e: - print(f"Could not restart: {e}. Stop the daemon (Ctrl+C) and " - "relaunch it from where meshbay-node is on PATH.") - sys.exit(1) - print("restarted the node") + _start_and_confirm(args, "restart") return _systemctl_user( "restart", "meshbay-node", @@ -64,7 +143,14 @@ def autostart(args) -> None: "'systemctl --user enable --now meshbay-node'.") sys.exit(1) sub = args.subcommand or "status" + service_mode = _plat.service_status()["installed"] if sub == "install": + if service_mode: + # Both would start the node: at boot, then again at sign-in. + print("The node already runs as a background service. Remove it " + "first (meshbay-node service remove, elevated) to start it at " + "sign-in instead.") + sys.exit(1) _plat.autostart_install() print("Installed the Startup launcher — meshbay-node starts at " "each sign-in (no window, no admin).") @@ -73,19 +159,15 @@ def autostart(args) -> None: _plat.autostart_remove() print("Removed the Startup launcher.") elif sub == "start": - try: - _plat.autostart_run() - except RuntimeError as e: - print(f"Could not start: {e}") - sys.exit(1) - print("started") + _start_and_confirm(args, "start") elif sub == "stop": - _plat.autostart_end() - print("stopped") + print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) elif sub == "status": st = _plat.autostart_status() if st["installed"]: print("autostart installed — runs meshbay-node at sign-in") + elif service_mode: + print("autostart not used — the node runs as a background service") else: print("autostart not installed — meshbay-node autostart install") else: @@ -102,6 +184,9 @@ def service(args) -> None: sys.exit(1) sub = args.subcommand or "status" if sub == "install": + # A node already running in this session holds the control API's port: + # the service's own would exit at once, leaving the old one in charge. + _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) try: _plat.service_install() except RuntimeError as e: @@ -114,14 +199,19 @@ def service(args) -> None: "signed in yet (no password stored).") print("Start it now with: meshbay-node service start") elif sub == "remove": + # Deleting a task does not end its running instance: stop the node + # first, or it runs on in session 0 with nothing left to stop it. + _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) _plat.service_remove() print(f"Removed the {_plat.TASK_NAME!r} scheduled task.") elif sub == "start": - _plat.service_run() - print("started") + if not _plat.service_status()["installed"]: + print("service not installed — meshbay-node service install " + "(needs an elevated prompt)") + sys.exit(1) + _start_and_confirm(args, "start") elif sub == "stop": - _plat.service_end() - print("stopped") + print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) elif sub == "status": st = _plat.service_status() if st["installed"]: -- cgit v1.2.3