aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.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/src/meshbay_node/daemon.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/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py47
1 files changed, 45 insertions, 2 deletions
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: