Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 12 additions & 18 deletions src/iai_mcp/cli/_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,24 +471,18 @@ def cmd_daemon_stop(args: argparse.Namespace) -> int:
payload = LifecycleLock().read()
pid = payload["pid"] if payload else None
if pid is not None and _is_pid_alive(pid):
try:
os.kill(pid, _signal.SIGINT)
except (ProcessLookupError, PermissionError) as exc:
logger.debug("SIGINT to daemon pid=%d failed: %s", pid, exc)
return 0

deadline = _time.monotonic() + _stop_escalation_bound()
interval = _stop_poll_interval()
while _time.monotonic() < deadline:
if not _is_pid_alive(pid):
return 0
_time.sleep(interval)

if _is_pid_alive(pid):
_sp.run(
["taskkill", "/F", "/PID", str(pid)],
check=False, capture_output=True,
)
# taskkill /T terminates the daemon together with any child processes
# it spawned (worker interpreters, maturin, etc.) as one tree, so none
# is left orphaned and holding the hippo lock. os.kill(pid, SIGINT) is
# NOT usable here: on Windows it maps to TerminateProcess against the
# parent alone -- it can't reach the children, and because the parent
# then dies immediately the old poll loop returned before ever running
# the (childless) taskkill escalation. Kill the whole tree at once,
# while it is still intact.
_sp.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
check=False, capture_output=True,
)
else:
print(f"Unsupported OS: {platform.system()}", file=sys.stderr)
return 1
Expand Down
34 changes: 34 additions & 0 deletions tests/test_cli_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,40 @@ def test_stop_no_sigkill_when_pid_dies_during_wait(
), calls


def test_stop_windows_taskkills_process_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Windows stop must kill the whole daemon process tree so a spawned worker
interpreter can't survive holding the hippo lock. It issues
taskkill /F /T /PID (tree-kill, while the tree is intact) rather than
os.kill(pid, SIGINT) -- which on Windows is a TerminateProcess against the
parent alone: that orphans the children and, because the parent dies at once,
the old code returned before the (now childless) taskkill escalation ran.
"""
monkeypatch.setattr(platform, "system", lambda: "Windows")
calls, _sig = _patch_stop_collaborators(
monkeypatch, pid=7373, alive_sequence=[True],
)
rc = cli_mod.cmd_daemon_stop(object())
assert rc == 0

end_idx = next(
i for i, c in enumerate(calls)
if c[0] == "run" and "/End" in c[1]
)
taskkill_idx = next(
i for i, c in enumerate(calls)
if c[0] == "run" and c[1][:1] == ["taskkill"]
)
assert end_idx < taskkill_idx, calls
# Must be a TREE kill (/T) of the recorded pid.
assert calls[taskkill_idx][1] == [
"taskkill", "/F", "/T", "/PID", "7373"
], calls
# Must NOT hard-kill via os.kill -- that orphans the child tree on Windows.
assert not any(c[0] == "kill" for c in calls), calls


def test_stop_lockfile_absent_bootout_only_no_signal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
Loading