From 5a73ef4a86566e2ee8a73620075758c94c894c2d Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 17 Jul 2026 01:34:39 +0100 Subject: [PATCH] fix(desktop): watchdog shutdown must not hard-kill on Windows The desktop parent watchdog used os.kill(os.getpid(), SIGTERM) to stop the backend, with a comment promising uvicorn's shutdown sequence would run. On Windows that call is TerminateProcess -- a hard kill that bypasses every cleanup path, so the promise only held on POSIX. signal.raise_signal(SIGTERM) triggers the in-process Python-level handler uvicorn installed, with identical semantics on both platforms. Closes #282 --- app/main.py | 9 +++++--- tests/test_watchdog.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/test_watchdog.py diff --git a/app/main.py b/app/main.py index a3a35cd..e0c9c4c 100644 --- a/app/main.py +++ b/app/main.py @@ -144,9 +144,12 @@ async def _desktop_parent_watchdog(parent_pid: int) -> None: while True: if not _process_exists(parent_pid): _log.info("desktop parent process exited; stopping backend") - # Send SIGTERM to ourselves so uvicorn runs its shutdown sequence - # instead of bypassing cleanup with os._exit(). - os.kill(os.getpid(), signal.SIGTERM) + # Raise SIGTERM in-process so uvicorn's handler runs its shutdown + # sequence. os.kill(pid, SIGTERM) would be wrong here: on Windows + # it is TerminateProcess -- a hard kill that bypasses cleanup + # (#282). raise_signal triggers the Python-level handler on both + # platforms. + signal.raise_signal(signal.SIGTERM) return await asyncio.sleep(1) diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py new file mode 100644 index 0000000..98c8041 --- /dev/null +++ b/tests/test_watchdog.py @@ -0,0 +1,50 @@ +"""Tests for the desktop parent watchdog's shutdown path (#282).""" + +from __future__ import annotations + +import signal + +import pytest + +from app import main as main_mod + + +@pytest.mark.asyncio +async def test_watchdog_raises_sigterm_in_process(monkeypatch): + """When the parent dies, the watchdog must raise SIGTERM in-process + (uvicorn's handler runs) -- NOT os.kill, which on Windows is + TerminateProcess and bypasses the shutdown sequence.""" + raised: list[int] = [] + monkeypatch.setattr(main_mod, "_process_exists", lambda _pid: False) + monkeypatch.setattr(main_mod.signal, "raise_signal", raised.append) + + killed: list = [] + monkeypatch.setattr(main_mod.os, "kill", lambda *a: killed.append(a)) + + await main_mod._desktop_parent_watchdog(12345) + + assert raised == [signal.SIGTERM] + assert killed == [] # the hard-kill path must be gone + + +@pytest.mark.asyncio +async def test_watchdog_keeps_waiting_while_parent_alive(monkeypatch): + """While the parent lives, the watchdog sleeps and loops -- no signal.""" + checks: list[int] = [] + + def alive(pid: int) -> bool: + checks.append(pid) + return len(checks) < 3 # alive twice, then gone + + async def instant_sleep(_delay): + return None + + raised: list[int] = [] + monkeypatch.setattr(main_mod, "_process_exists", alive) + monkeypatch.setattr(main_mod.asyncio, "sleep", instant_sleep) + monkeypatch.setattr(main_mod.signal, "raise_signal", raised.append) + + await main_mod._desktop_parent_watchdog(999) + + assert len(checks) == 3 + assert raised == [signal.SIGTERM]