summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-05 13:06:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-05 13:06:55 +0200
commite89a57bb97b5a0d624e8d490b6b8aa38ba140817 (patch)
tree3e51dfb630510508cdab16f0b0205772816f4896 /packages/meshbay-node/src/meshbay_node
parent7601991ccb1d75637c055062c38b1852eeef9700 (diff)
downloadmeshbay-e89a57bb97b5a0d624e8d490b6b8aa38ba140817.tar.gz
fix(win): graceful shutdown, one startup-mode control, and a stray-\r bug
Windows-only changes, all found by actually running the previous session's work rather than by review alone: - CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes SetConsoleCtrlHandler) so closing a console window, signing off, or a system shutdown runs the daemon's real _shutdown() instead of Windows just ending the process — closing WebRTC sessions and any in-flight ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays uncatchable (like SIGKILL), so autostart_run() now spawns with CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end() tries CTRL_BREAK_EVENT against the recorded pid first, falling back to the hard kill only if that doesn't stop it in time. - Replaced the Node page's two independent autostart/service-mode toggles with one "start automatically" select (off / at sign-in / as a background service). The old pair let both be active at once — starting the daemon twice, at boot and at sign-in — and their layout broke wrapping inside .node-service's flex row. The new control always removes whichever mechanism is active before installing the target; platform.py's service_install() does the same on the CLI side. The "background service" option disables itself (with a hint pointing at the CLI) when running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all assume an installed build's layout — verified live rather than assumed by actually running those scripts unelevated. - findNodeBinary() no longer bakes a stray \r into resolved paths. Found by rebooting after enabling per-user autostart: where.exe listed two matches, and stdout.trim().split('\n')[0] only strips the whole string's ends, leaving line one's own trailing \r attached — which landed inside the Startup .vbs's quoted path and broke it with "Unterminated string constant" at boot. Fixed by splitting on \r?\n and trimming every line. - Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no VC++ Redistributable needed, confirmed by inspecting the built node-runtime's actual import table rather than assuming. New docs/windows-build.md: a concise clone-to-installer build guide. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py18
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py169
2 files changed, 178 insertions, 9 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index b932c16..ea13680 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -678,9 +678,18 @@ class NodeDaemon:
# 12. Wait for shutdown
stop_event = asyncio.Event()
loop = asyncio.get_event_loop()
+ console_shutdown_done = None
if sys.platform == "win32":
- for sig in (signal.SIGINT, signal.SIGTERM):
+ # SIGBREAK: CTRL_BREAK_EVENT, how platform.autostart_end() asks
+ # a per-user-mode daemon to stop gracefully instead of only
+ # ever taskkill /F.
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGBREAK):
signal.signal(sig, lambda *_: stop_event.set())
+ # CTRL_CLOSE/LOGOFF/SHUTDOWN reach no Python signal at all --
+ # see platform.install_console_close_handler for why this is
+ # a separate mechanism rather than another signal.signal() line.
+ from meshbay_node.platform import install_console_close_handler
+ console_shutdown_done = install_console_close_handler(loop, stop_event)
else:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
@@ -694,6 +703,13 @@ class NodeDaemon:
await stop_event.wait()
await self._shutdown()
+ if console_shutdown_done is not None:
+ # Releases the console-control handler's blocking wait (see
+ # platform.install_console_close_handler) so it can return and
+ # let Windows actually end the process for CTRL_CLOSE/LOGOFF/
+ # SHUTDOWN, now that cleanup is genuinely done rather than just
+ # started.
+ console_shutdown_done.set()
async def _reload_config(self) -> None:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index 9b18618..981d466 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -1,12 +1,18 @@
"""Platform-specific paths and tool resolution for meshbay-node."""
import asyncio
+import logging
import os
import shutil
+import signal
import subprocess
import sys
+import threading
+import time
from pathlib import Path
+log = logging.getLogger(__name__)
+
# ── Console ──────────────────────────────────────────────────────────────────
@@ -213,6 +219,24 @@ def _startup_vbs() -> Path:
/ "Startup" / "MeshBay Node.vbs")
+def _pid_file() -> Path:
+ """Where autostart_run() records the pid it spawned, for autostart_end()
+ to signal later -- possibly from a different process (a new Electron
+ session, or a fresh CLI invocation), so this cannot be an in-memory
+ handle."""
+ return state_dir() / "node.pid"
+
+
+def _pid_is_meshbay_node(pid: int) -> bool:
+ """True if `pid` is currently running *and* is meshbay-node.exe. Guards
+ against a stale pidfile whose pid Windows has since handed to an
+ unrelated process -- autostart_end() would otherwise send CTRL_BREAK_EVENT
+ to whatever that is instead."""
+ r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
+ capture_output=True, text=True)
+ return "meshbay-node.exe" in r.stdout.lower()
+
+
def _node_exe() -> str | None:
"""Best guess at the meshbay-node launcher: PATH first, then next to the
interpreter (a venv's Scripts/ dir, or a bundled runtime), then argv[0]."""
@@ -260,22 +284,76 @@ def autostart_remove() -> None:
def autostart_run() -> None:
- """Start the daemon now, detached and windowless. Raises RuntimeError if
- the launcher cannot be located."""
+ """Start the daemon now, windowless. Raises RuntimeError if the launcher
+ cannot be located."""
if not autostart_supported():
raise RuntimeError("autostart is Windows-only")
exe = _node_exe()
if not exe:
raise RuntimeError("cannot locate the meshbay-node launcher")
- subprocess.Popen([exe], creationflags=0x00000008 | 0x08000000, # DETACHED | NO_WINDOW
- close_fds=True)
+ # CREATE_NEW_PROCESS_GROUP, not DETACHED_PROCESS: still no visible window
+ # (CREATE_NO_WINDOW), but the child keeps a console object of its own and
+ # becomes the root of its own process group -- what autostart_end() needs
+ # to target it with CTRL_BREAK_EVENT instead of only ever a hard taskkill.
+ # DETACHED_PROCESS has no console at all, so nothing could be signalled.
+ proc = subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000,
+ close_fds=True)
+ try:
+ pid_file = _pid_file()
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
+ pid_file.write_text(str(proc.pid), encoding="utf-8")
+ except OSError:
+ pass # best effort -- autostart_end() falls back to taskkill by image name
+
+
+# How long autostart_end() waits for a graceful CTRL_BREAK_EVENT stop before
+# giving up and force-killing. A chosen grace period, not an OS-enforced one
+# (unlike the ~5 s Windows itself allows a CTRL_CLOSE/LOGOFF/SHUTDOWN handler,
+# see install_console_close_handler below -- CTRL_BREAK carries no such ceiling).
+_GRACEFUL_STOP_TIMEOUT_SECS = 5.0
def autostart_end() -> None:
- """Stop any running daemon (hard: there is no CTRL_CLOSE handler yet)."""
- if autostart_supported():
- subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"],
- capture_output=True)
+ """
+ Stop the running daemon.
+
+ Tries a graceful stop first: CTRL_BREAK_EVENT to the pid autostart_run()
+ recorded. Because that process is the root of its own group
+ (CREATE_NEW_PROCESS_GROUP), daemon.py's own SIGBREAK handler turns this
+ into the same stop_event.set() SIGINT/SIGTERM already use, running the
+ real _shutdown() -- closes WebRTC sessions, kills any in-flight ffmpeg
+ transcode. Falls back to a hard `taskkill /F`, by image name, when there
+ is no pidfile, the recorded process is already gone, or it does not exit
+ within the grace period -- same as before this existed, just no longer
+ the only path. `taskkill /F` itself is TerminateProcess and cannot be made
+ graceful; nothing can catch it, on any OS.
+ """
+ if not autostart_supported():
+ return
+ pid_file = _pid_file()
+ try:
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ pid = None
+ if pid is not None and not _pid_is_meshbay_node(pid):
+ pid = None # stale pidfile -- Windows may have reused the pid since
+ if pid is not None:
+ try:
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
+ except OSError:
+ pid = None # already gone, or never existed
+ else:
+ deadline = time.monotonic() + _GRACEFUL_STOP_TIMEOUT_SECS
+ while time.monotonic() < deadline:
+ if not _pid_is_meshbay_node(pid):
+ pid_file.unlink(missing_ok=True)
+ return
+ time.sleep(0.2)
+ log.warning("pid %d did not exit within %.1fs of CTRL_BREAK_EVENT, "
+ "falling back to taskkill /F", pid, _GRACEFUL_STOP_TIMEOUT_SECS)
+ pid_file.unlink(missing_ok=True)
+ subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"],
+ capture_output=True)
# ── Service mode (Windows, opt-in at install time) ───────────────────────────
@@ -340,9 +418,17 @@ def service_install(exe: str | None = None) -> None:
Register the boot-time Scheduled Task. Needs admin — raises RuntimeError
with schtasks' own message on failure, which is "Access is denied." when
not elevated.
+
+ Removes the per-user Startup launcher first, if present: the two
+ mechanisms are mutually exclusive by design (both installed would start
+ the daemon twice, once at boot and again at sign-in), and this is a
+ separate front door from the Node page's own startup-mode selector (which
+ enforces the same thing on its side) -- the CLI (`meshbay-node service
+ install`) must not be able to leave that invariant broken.
"""
if not service_supported():
raise RuntimeError("service mode is Windows-only")
+ autostart_remove()
exe = exe or _node_exe()
if not exe:
raise RuntimeError(
@@ -374,3 +460,70 @@ def service_end() -> None:
"""Stop the running instance, if any. No admin needed."""
if service_supported():
_schtasks("/end", "/tn", TASK_NAME)
+
+
+# ── Console close / logoff / shutdown handler (Windows) ──────────────────────
+#
+# CPython's own console handler claims CTRL_C_EVENT and CTRL_BREAK_EVENT --
+# delivered as SIGINT/SIGBREAK, handled in daemon.py's win32 signal block --
+# but returns "not handled" for CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and
+# CTRL_SHUTDOWN_EVENT: there is no Python signal for any of the three. Without
+# a handler of our own, Windows just ends the process for these -- no
+# _shutdown(), no closed WebRTC sessions, no killed ffmpeg. Covers: closing
+# the console window of an interactively-run `meshbay-node run`, user logoff,
+# system shutdown. Does NOT cover `taskkill /F` -- TerminateProcess is
+# uncatchable on any OS, the same as SIGKILL; see autostart_end() for how the
+# Node page's Stop button avoids relying on it instead.
+
+_CONSOLE_HANDLER_REFS: list = [] # ctypes callbacks must be kept referenced or they may be freed
+
+CTRL_CLOSE_EVENT = 2
+CTRL_LOGOFF_EVENT = 5
+CTRL_SHUTDOWN_EVENT = 6
+
+
+def install_console_close_handler(
+ loop: asyncio.AbstractEventLoop, stop_event: asyncio.Event,
+) -> "threading.Event | None":
+ """
+ Register the handler. Returns a threading.Event the caller must set once
+ its own graceful shutdown has actually finished -- daemon.py does this
+ right after `await self._shutdown()` -- or None off-Windows, or if
+ registration itself failed (logged, not raised: losing this is a
+ regression, refusing to start the daemon over it would not be).
+
+ MSDN: for these three events the process is ended "after the process
+ returns from the handler function, or after 5 seconds, whichever occurs
+ first" -- so the handler, which Windows runs on a thread of its own and
+ never the main one, blocks here instead of returning immediately, and
+ nudges the asyncio loop the thread-safe way since it is not the loop's
+ own thread. The wait is capped just under that ceiling so the process
+ still exits by itself if cleanup runs long, rather than the OS treating an
+ unresponsive handler as a hang.
+ """
+ if sys.platform != "win32":
+ return None
+ import ctypes
+ from ctypes import wintypes
+
+ handled = {CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT}
+ shutdown_done = threading.Event()
+ handler_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
+
+ def _handler(ctrl_type: int) -> bool:
+ if ctrl_type not in handled:
+ return False # not ours -- let Python's own handler or the default action take it
+ log.info("Console control event %d (close/logoff/shutdown) -- shutting down", ctrl_type)
+ loop.call_soon_threadsafe(stop_event.set)
+ shutdown_done.wait(4.5)
+ return True
+
+ handler_ref = handler_type(_handler)
+ if not ctypes.windll.kernel32.SetConsoleCtrlHandler(handler_ref, True):
+ log.warning("SetConsoleCtrlHandler failed (%s) -- closing the console window, "
+ "logging off or shutting down will not run a clean shutdown; "
+ "SIGINT/SIGTERM/SIGBREAK are unaffected",
+ ctypes.WinError())
+ return None
+ _CONSOLE_HANDLER_REFS.append(handler_ref)
+ return shutdown_done