fix: reap backend when desktop owner exits
This commit is contained in:
@@ -25,6 +25,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
|
||||
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
|
||||
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
|
||||
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
|
||||
|
||||
## [0.5.1] — 2026-08-28
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Terminate a desktop-contained backend when its owning shell disappears."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import BinaryIO, Callable
|
||||
|
||||
|
||||
def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None:
|
||||
"""Block until the desktop-owned stdin pipe closes, then exit immediately."""
|
||||
try:
|
||||
while reader.read(1):
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
exit_process(0)
|
||||
|
||||
|
||||
def arm_desktop_parent_watchdog() -> bool:
|
||||
"""Use stdin EOF as an unforgeable parent-liveness signal for desktop runs."""
|
||||
if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1":
|
||||
return False
|
||||
reader = getattr(sys.stdin, "buffer", None)
|
||||
if reader is None:
|
||||
return False
|
||||
threading.Thread(
|
||||
target=_watch_parent_pipe,
|
||||
args=(reader, os._exit),
|
||||
name="desktop-parent-watchdog",
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
@@ -77,6 +77,7 @@ os.environ.setdefault("FOR_DISABLE_CONSOLE_CTRL_HANDLER", "1")
|
||||
# (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm
|
||||
# already uses for its own fp.)
|
||||
from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402
|
||||
from core.parent_liveness import arm_desktop_parent_watchdog # noqa: E402
|
||||
|
||||
# Force UTF-8 stdio before wrapping (#1155): on Windows the spawned backend's
|
||||
# stdout defaults to cp1252, and any library that prints user text (kittentts
|
||||
@@ -89,6 +90,11 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
except Exception: # noqa: BLE001 — pythonw/frozen builds may lack reconfigure
|
||||
pass
|
||||
|
||||
# The desktop keeps the backend's stdin pipe open for its own lifetime. EOF is
|
||||
# therefore a stable ownership signal that survives PID reuse and lets a child
|
||||
# terminate even when the shell crashes before its normal process-tree teardown.
|
||||
arm_desktop_parent_watchdog()
|
||||
|
||||
if not getattr(sys.stdout, "_is_safe_wrapper", False):
|
||||
sys.stdout = _SafeStdio(sys.stdout)
|
||||
if not getattr(sys.stderr, "_is_safe_wrapper", False):
|
||||
|
||||
@@ -652,7 +652,12 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
|
||||
]);
|
||||
}
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
// Keep stdin piped but unwritten. The backend's parent-liveness watchdog
|
||||
// blocks on it; desktop exit closes the handle and the child terminates,
|
||||
// including on macOS where parent death alone does not reap descendants.
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut contained = match crate::tools::spawn_process_tree(&mut cmd) {
|
||||
Ok(c) => {
|
||||
log::info!(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.parent_liveness import _watch_parent_pipe, arm_desktop_parent_watchdog
|
||||
|
||||
|
||||
def test_parent_pipe_eof_exits_cleanly():
|
||||
exits = []
|
||||
_watch_parent_pipe(io.BytesIO(b""), exits.append)
|
||||
assert exits == [0]
|
||||
|
||||
|
||||
def test_parent_pipe_ignores_bytes_until_eof():
|
||||
exits = []
|
||||
_watch_parent_pipe(io.BytesIO(b"keepalive"), exits.append)
|
||||
assert exits == [0]
|
||||
|
||||
|
||||
def test_watchdog_is_disabled_outside_desktop(monkeypatch):
|
||||
monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False)
|
||||
assert arm_desktop_parent_watchdog() is False
|
||||
|
||||
|
||||
def test_desktop_child_exits_when_parent_closes_stdin():
|
||||
env = os.environ.copy()
|
||||
env["OMNIVOICE_DESKTOP_CONTAINED"] = "1"
|
||||
child = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from core.parent_liveness import arm_desktop_parent_watchdog; "
|
||||
"arm_desktop_parent_watchdog(); print('ready', flush=True); "
|
||||
"__import__('time').sleep(30)",
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=Path(__file__).parents[3] / "backend",
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
assert child.stdout.readline().strip() == "ready"
|
||||
child.stdin.close()
|
||||
assert child.wait(timeout=3) == 0
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait()
|
||||
Reference in New Issue
Block a user