diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-07 21:25:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-07 22:22:51 +0200 |
| commit | e833fe1bfc8eb6f66cc5dc53997cc4158bab583f (patch) | |
| tree | 6874dfd09cb210eaf61e4f5761d0bad23c9cd0a3 /packages/meshbay-node/src | |
| parent | 92e25ffcc5edf5d1a9996bfb921b5a95b826134b (diff) | |
| download | meshbay-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/src')
4 files changed, 129 insertions, 10 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py index 901c3d5..b461055 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py +++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py @@ -55,23 +55,36 @@ def _running_status(cfg) -> dict | None: return _await_daemon(cfg, since=0, timeout=0.1) +class NodeStillRunning(RuntimeError): + pass + + 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.""" + first, so it shuts down properly, then by force. Returns how it went, and + raises NodeStillRunning when a node process is still there at the end -- + never "stopped" about a process nobody saw go.""" import time from meshbay_node.platform import ( + _pid_alive, autostart_end, + kill_pid, + node_pids, request_graceful_stop, service_end, service_state, service_status, ) + answering = _running_status(cfg) + pid = answering.get("pid") if answering else None if request_graceful_stop(cfg.data_dir, cfg.node.ui_port): how = "stopped" - elif _running_status(cfg) is None: + elif answering is None and not node_pids(): how = "not running" else: + # Asked and not gone: its control API closes first, so it may answer + # nothing while its process is still there. how = "stopped (forced)" if service_status()["installed"]: # Also leaves the task "Ready": a /run while it still reads "Running" @@ -80,7 +93,17 @@ def _stop_node(cfg) -> str: deadline = time.monotonic() + 15 while service_state().lower() == "running" and time.monotonic() < deadline: time.sleep(0.25) + if pid is not None and _pid_alive(int(pid)): + kill_pid(int(pid)) # the one that answered, by pid, wherever it came from autostart_end() # a node in this session that would not stop + deadline = time.monotonic() + 5 + while (left := node_pids()) and time.monotonic() < deadline: + time.sleep(0.25) + if left: + raise NodeStillRunning( + f"meshbay-node is still running (pid {', '.join(map(str, left))}) and could " + "not be stopped from here -- it was probably started from an " + "administrator prompt. Stop it there, or end it in Task Manager.") return how @@ -94,7 +117,12 @@ def _start_and_confirm(args, action: str) -> None: cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if action == "restart": - _stop_node(cfg) + try: + _stop_node(cfg) + except NodeStillRunning as e: + # Starting another would find the port taken and quit at once. + print(f"Could not restart the node: {e}") + sys.exit(1) else: already = _running_status(cfg) if already is not None: @@ -122,6 +150,21 @@ def _start_and_confirm(args, action: str) -> None: "its files were not replaced, or another copy was started.") +def _print_stop(args) -> None: + try: + print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + except NodeStillRunning as e: + print(e) + sys.exit(1) + + +def _stop_node_or_warn(args) -> None: + try: + _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) + except NodeStillRunning as e: + print(f"warning: {e}") + + def restart_daemon(args) -> None: if sys.platform == "win32": _start_and_confirm(args, "restart") @@ -161,7 +204,7 @@ def autostart(args) -> None: elif sub == "start": _start_and_confirm(args, "start") elif sub == "stop": - print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + _print_stop(args) elif sub == "status": st = _plat.autostart_status() if st["installed"]: @@ -186,7 +229,7 @@ def service(args) -> None: 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)) + _stop_node_or_warn(args) try: _plat.service_install() except RuntimeError as e: @@ -201,7 +244,7 @@ def service(args) -> None: 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)) + _stop_node_or_warn(args) _plat.service_remove() print(f"Removed the {_plat.TASK_NAME!r} scheduled task.") elif sub == "start": @@ -211,7 +254,7 @@ def service(args) -> None: sys.exit(1) _start_and_confirm(args, "start") elif sub == "stop": - print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + _print_stop(args) elif sub == "status": st = _plat.service_status() if st["installed"]: diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 2cd8a8f..eb694ae 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -403,7 +403,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg = Config() if path.exists(): - raw = tomllib.loads(path.read_text(encoding="utf-8")) + # utf-8-sig: Windows PowerShell 5.1's `Set-Content -Encoding utf8` and + # older Notepads write a BOM, which TOML refuses -- and a service node + # then stopped before it had said anything (found editing node.toml + # on a real install). + raw = tomllib.loads(path.read_text(encoding="utf-8-sig")) hub = raw.get("hub", {}) cfg.hub.url = hub.get("url", cfg.hub.url) diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index e1aac41..6fba120 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -31,6 +31,7 @@ import os import signal import socket import sys +import threading import time from dataclasses import asdict from pathlib import Path @@ -81,6 +82,31 @@ if WEBRTC_AVAILABLE: log = logging.getLogger(__name__) +# How long a stopped node's process may outlive its _shutdown(), and the most +# _shutdown() itself may take. Python's own exit waits for every worker thread, +# and one still walking or hashing a large tree kept the process alive for as +# long as that took, with its control API already closed: the desktop app saw +# no node and said "Stopped" about a process that was still there. +EXIT_GRACE_SECS = 3.0 +SHUTDOWN_DEADLINE_SECS = 30.0 + + +def exit_after(seconds: float, why: str, code: int = 0) -> threading.Timer: + """End this process in `seconds`, whatever is still running in it.""" + def _exit() -> None: + busy = sorted(t.name for t in threading.enumerate() + if t is not threading.current_thread() and not t.daemon + and t is not threading.main_thread()) + log.warning("%s -- ending the process now (still busy: %s)", + why, ", ".join(busy) or "nothing") + logging.shutdown() + os._exit(code) + timer = threading.Timer(seconds, _exit) + timer.daemon = True + timer.start() + return timer + + # ── Hub WS sender bridge ───────────────────────────────────────────────────── class _WsSender: @@ -101,6 +127,9 @@ def _root_shape(roots) -> set[tuple]: class NodeDaemon(EnrichmentMixin): + # Set by main(), never by a test that runs a daemon in its own process. + exit_process_when_stopped = False + def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config self._config_path = config_path @@ -1564,6 +1593,9 @@ class NodeDaemon(EnrichmentMixin): async def _shutdown(self) -> None: log.info("Shutting down...") self._state["status"] = "stopping" + if self.exit_process_when_stopped: + exit_after(SHUTDOWN_DEADLINE_SECS, + f"shutdown took more than {SHUTDOWN_DEADLINE_SECS:.0f}s", code=1) for handle in self._pending_broadcasts.values(): handle.cancel() @@ -1624,6 +1656,8 @@ class NodeDaemon(EnrichmentMixin): token_file.unlink(missing_ok=True) log.info("Node stopped") + if self.exit_process_when_stopped: + exit_after(EXIT_GRACE_SECS, "the node has stopped") # ── Entry point ─────────────────────────────────────────────────────────────── @@ -1668,7 +1702,15 @@ def main() -> None: # Printed for whoever ran it, and logged too: a daemon started by the # service task has no console, and these are why it would refuse to start. - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + config_path = args.config or DEFAULT_CONFIG_PATH + try: + cfg = load_config(config_path) + except Exception as e: + # Uncaught, this went to a stderr that Task Scheduler discards: the + # node died with "Logging to ..." as its last word. + print(f"Error: cannot read {config_path}: {e}") + log.error("cannot read %s: %s", config_path, e) + sys.exit(1) if not cfg.hub.username: print("Error: hub.username not set in config. Run: meshbay-node init") log.error("hub.username not set in config. Run: meshbay-node init") @@ -1681,7 +1723,8 @@ def main() -> None: log.error("%s", e) sys.exit(1) - daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH)) + daemon = NodeDaemon(cfg, Path(config_path)) + daemon.exit_process_when_stopped = True try: asyncio.run(daemon.run()) except ControlPortTaken as e: diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 88db83d..c0e4d00 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -397,6 +397,35 @@ def autostart_end() -> None: subprocess.run(argv, capture_output=True) +def node_pids() -> list[int]: + """Every meshbay-node.exe running, in any session, except this process and + its parent (the CLI is meshbay-node.exe too). Empty off Windows. + + What says whether a node is still there after a stop: its control API + closes first, so a process that has not exited yet answers nothing and + used to count as gone. + """ + if sys.platform != "win32": + return [] + r = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {NODE_IMAGE}", "/NH", "/FO", "CSV"], + capture_output=True, text=True) + mine = {os.getpid(), os.getppid()} + pids = [] + for line in r.stdout.splitlines(): + cells = [c.strip('"') for c in line.split('","')] + if len(cells) > 1 and cells[1].isdigit() and int(cells[1]) not in mine: + pids.append(int(cells[1])) + return pids + + +def kill_pid(pid: int) -> None: + """Force-stop one process and its children (its ffmpeg transcodes). Reaches + this session only, unless the caller is elevated. Windows only: systemd + stops the node everywhere else.""" + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True) + + # ── Service mode (Windows, opt-in at install time) ─────────────────────────── # # The Startup-folder .vbs above only ever runs after *this* user signs in. A |