aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/platform.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/platform.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py188
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