aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:35 +0200
commit7662484cae8e74b7d9aa383bd6cd0dad4690aadc (patch)
tree057609311e339c839dcad04b33de62e2541a9943 /packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
parent326d7796c79f616f5a0f2058386df2bded657c78 (diff)
downloadmeshbay-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/src/meshbay_node/cli/lifecycle.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/lifecycle.py154
1 files changed, 122 insertions, 32 deletions
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"]: