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)
|
- 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)
|
- 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)
|
- 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.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
|
# (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm
|
||||||
# already uses for its own fp.)
|
# already uses for its own fp.)
|
||||||
from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402
|
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
|
# 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
|
# 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
|
except Exception: # noqa: BLE001 — pythonw/frozen builds may lack reconfigure
|
||||||
pass
|
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):
|
if not getattr(sys.stdout, "_is_safe_wrapper", False):
|
||||||
sys.stdout = _SafeStdio(sys.stdout)
|
sys.stdout = _SafeStdio(sys.stdout)
|
||||||
if not getattr(sys.stderr, "_is_safe_wrapper", False):
|
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) {
|
let mut contained = match crate::tools::spawn_process_tree(&mut cmd) {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
log::info!(
|
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