fix: reap backend when desktop owner exits

This commit is contained in:
Palash Debnath
2026-08-30 21:18:36 +05:30
parent 80aafa3a53
commit 0a20aeb0c9
5 changed files with 98 additions and 1 deletions
+1
View File
@@ -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
+33
View File
@@ -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
+6
View File
@@ -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):
+6 -1
View File
@@ -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()