diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/platform.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/platform.py | 188 |
1 files changed, 112 insertions, 76 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 7db251b..09397a2 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -4,7 +4,6 @@ import asyncio import logging import os import shutil -import signal import subprocess import sys import threading @@ -74,6 +73,17 @@ def state_dir() -> Path: return Path.home() / ".local" / "state" / "meshbay" +def log_file() -> Path | None: + """Where the daemon logs on Windows; None elsewhere, where journald has it. + + A daemon started by the service task or the Startup launcher has no console: + its stderr goes nowhere, and a node that fails there fails in silence. + """ + if sys.platform == "win32": + return state_dir() / "node.log" + return None + + # ── Packaged defaults ──────────────────────────────────────────────────────── @@ -211,35 +221,71 @@ 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"], +def _pid_alive(pid: int) -> bool: + """Whether a process with this pid exists, in any session (tasklist lists + session 0 too, where a service-mode daemon runs; opening it would not).""" + r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"], capture_output=True, text=True) - return "meshbay-node.exe" in r.stdout.lower() + return f'"{pid}"' in r.stdout 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].""" - found = shutil.which("meshbay-node") - if found: - return found + """The meshbay-node to launch: this very one when frozen, then the launcher + next to the interpreter (a venv's Scripts/), then argv[0], then PATH. + + PATH was first, and a machine with two copies on it -- an MSIX and an NSIS + install, an older one left behind -- had `autostart start` from one launch + the other, a different version, found by testing a real install. + """ + if getattr(sys, "frozen", False): + return sys.executable for cand in (Path(sys.executable).parent / "meshbay-node.exe", Path(sys.argv[0])): if cand.name.lower().startswith("meshbay-node") and cand.exists(): return str(cand.resolve()) - return None + return shutil.which("meshbay-node") + + +def request_graceful_stop(data_dir: Path, ui_port: int, timeout: float = 15.0) -> bool: + """Ask the running daemon to stop through its control API, and wait until + its process is gone. True once it is; False when nothing answered or it + did not exit in time -- the caller then forces it. + + The one stop that reaches a daemon in any session with no elevation, and + runs its _shutdown() (WebRTC sessions closed, transcodes stopped). CTRL_BREAK + did neither across sessions, and aimed at a process on another console it + reached every process on the caller's own -- the CLI killed itself. + """ + import json + import urllib.request + + try: + token = (data_dir / "ui-token").read_text(encoding="utf-8").strip() + except OSError: + return False + base = f"http://127.0.0.1:{ui_port}" + try: + with urllib.request.urlopen(f"{base}/api/status?t={token}", timeout=3) as r: + pid = json.loads(r.read()).get("pid") + req = urllib.request.Request(f"{base}/api/shutdown?t={token}", method="POST", data=b"") + with urllib.request.urlopen(req, timeout=3): + pass + except (OSError, ValueError): + return False + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if pid is None: + try: + urllib.request.urlopen(f"{base}/api/status?t={token}", timeout=1) + except OSError: + return True + elif not _pid_alive(int(pid)): + return True + time.sleep(0.3) + log.warning("the node did not exit within %.0fs of being asked to stop", timeout) + return False def autostart_status() -> dict: @@ -283,69 +329,43 @@ def autostart_run() -> None: exe = _node_exe() if not exe: raise RuntimeError("cannot locate the meshbay-node launcher") - # 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 + # CREATE_NO_WINDOW: a hidden console of its own, which still delivers + # CTRL_LOGOFF/SHUTDOWN to install_console_close_handler, so signing out + # stops the node properly. close_fds: nothing of the caller's is inherited + # -- the desktop app starts its node through here for that reason, since a + # child of Electron inherited Electron's sockets and held them after it quit. + subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000, close_fds=True) -# 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 +NODE_IMAGE = "meshbay-node.exe" def autostart_end() -> None: """ - Stop the running daemon. + Force-stop a daemon running in this session: the fallback after + request_graceful_stop(), which callers try first. + + It used to send CTRL_BREAK_EVENT to the pid autostart_run() recorded. That + process has a console of its own (CREATE_NO_WINDOW), so it is not a group + on the caller's console -- and GenerateConsoleCtrlEvent aimed at such a pid + reaches every process on the caller's console instead: `autostart stop` + and `restart-daemon` killed themselves, the latter before restarting + anything (found by running both against a real install). + `taskkill /F` is TerminateProcess: cannot be caught, cannot reach session 0. - 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. + Never this process or its parent: the CLI is meshbay-node.exe too (and a + venv's launcher is its parent), and `taskkill /IM meshbay-node.exe` killed + the very command that ran it -- `autostart stop` and `restart-daemon` + exited 1 in silence, the latter with the node down. + `/T` because a venv's meshbay-node.exe is a launcher whose python.exe child + is the daemon, and a frozen daemon's children are its ffmpeg transcodes. """ 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) + argv = ["taskkill", "/F", "/T", "/IM", NODE_IMAGE] + for pid in {os.getpid(), os.getppid()}: + argv += ["/FI", f"PID ne {pid}"] + subprocess.run(argv, capture_output=True) # ── Service mode (Windows, opt-in at install time) ─────────────────────────── @@ -393,6 +413,15 @@ def autostart_end() -> None: TASK_NAME = "MeshBay Node" # the Scheduled Task's own name +# Task Scheduler's defaults end a task after 72 hours, never start it on +# battery and stop it when the power cable comes out — a node on a laptop was +# down for any of the three. Kept identical to packaging/win/service.ps1. +SERVICE_TASK_SETTINGS = ( + "New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) " + "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries " + "-MultipleInstances IgnoreNew -StartWhenAvailable" +) + def service_supported() -> bool: return sys.platform == "win32" @@ -424,6 +453,11 @@ def service_status() -> dict: return {"installed": True, "state": state} +def service_state() -> str: + """Task Scheduler's word for the task ('Running', 'Ready', ...), '' if absent.""" + return service_status()["state"] + + def service_install(exe: str | None = None) -> None: """ Register the boot-time Scheduled Task with an S4U logon. Needs admin — @@ -458,8 +492,9 @@ def service_install(exe: str | None = None) -> None: "$t = New-ScheduledTaskTrigger -AtStartup; " "$p = New-ScheduledTaskPrincipal -UserId $env:MESHBAY_SVC_USER " "-LogonType S4U -RunLevel Limited; " + f"$s = {SERVICE_TASK_SETTINGS}; " "Register-ScheduledTask -TaskName $env:MESHBAY_SVC_TASK " - "-Action $a -Trigger $t -Principal $p -Force | Out-Null" + "-Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null" ) r = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script], @@ -473,6 +508,7 @@ def service_remove() -> None: """Delete the Scheduled Task if present. Needs admin; silent otherwise (mirrors autostart_remove — nothing to report if it was never installed).""" if service_supported(): + _schtasks("/end", "/tn", TASK_NAME) # deleting does not end the instance _schtasks("/delete", "/tn", TASK_NAME, "/f") @@ -498,8 +534,8 @@ def service_end() -> None: # _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. +# uncatchable on any OS, the same as SIGKILL; request_graceful_stop() is how +# every Stop avoids relying on it. _CONSOLE_HANDLER_REFS: list = [] # ctypes callbacks must be kept referenced or they may be freed |