diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 18 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/platform.py | 169 |
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 |