Skip to content

fix(windows): guard daemons never exit and duplicate — POSIX-only lifecycle probes - #182

Open
vdebellabre wants to merge 3 commits into
Ruya-AI:mainfrom
vdebellabre:fix/windows-daemon-lifecycle
Open

vdebellabre wants to merge 3 commits into
Ruya-AI:mainfrom
vdebellabre:fix/windows-daemon-lifecycle

Conversation

@vdebellabre

Copy link
Copy Markdown

Problem

On Windows, guard daemons never exit and accumulate — 10 to 30 python/pythonw processes pile up in the task manager across Claude Desktop restarts, surviving the app itself. Worse, sessions frequently end up with duplicate daemons: two guards for the same session id, spawned seconds apart.

Root cause

The daemon lifecycle layer is POSIX-only. Three independent breaks compound on Windows:

  1. find_claude_pid() always returns None. It walks ancestry with ps -o ppid=,comm= — Git Bash's ps doesn't support -o at all. With no Claude PID, the daemon's Claude-exit watchdog (its only exit path for a normal-size session) never arms, so every guard polls its transcript forever.
  2. os.kill(pid, 0) is not a liveness probe on Windows. Signal 0 is CTRL_C_EVENT, which CPython forwards to GenerateConsoleCtrlEvent — a console-group broadcast whose result depends on console topology, not PID existence. Reproduced: it raises OSError for a LIVE detached daemon. Every probe built on it (spawn_lock._is_process_alive, reload_lock._is_process_alive, _is_guard_running_for_session, _wait_for_exit, _cleanup_legacy_pid, reload_self_daemon's stale check, doctor's checks) therefore misreads live processes as dead. Concretely: _is_guard_running_for_session classifies a live daemon's pidfile as stale once past the 5s fresh window, unlinks it, and the claim re-spawns → duplicate daemon per session whenever two SessionStart hooks fire >5s apart (reproduced deterministically).
  3. _is_cozempic_guard_process / identity checks are ps-based — they error on Windows and fail closed, so even a correct liveness probe would fall into the "alive but not a guard" freshness-unlink branch.

Fix

All Windows-gated; POSIX code paths are byte-identical:

  • helpers._pid_is_alive_windows — native probe via OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess == STILL_ACTIVE (access-denied → alive, invalid-parameter → dead). No subprocess, no console window. helpers._pid_is_alive, spawn_lock._is_process_alive, and reload_lock._is_process_alive delegate to it on Windows.
  • guard._kill0_probe — drop-in for the raw os.kill(pid, 0) probes (raises ProcessLookupError when dead) so the existing except-clauses at all call sites keep working unchanged; wired into _is_guard_running_for_session, _wait_for_exit, _cleanup_legacy_pid, reload_self_daemon, and doctor.
  • session.find_claude_pid — Windows branch walking ancestry over a CreateToolhelp32Snapshot process map (ctypes; pure walk extracted as _walk_up_to_claude for unit testing). It resolves in the SessionStart hook's CLI — a live descendant of Claude — and start_guard_daemon already forwards the result via --claude-pid, so the daemon's watchdog now arms.
  • guard._is_cozempic_guard_process_windows — same argv token rules as the ps version, applied to the command line read natively via NtQueryInformationProcess(ProcessCommandLineInformation) (quote-aware argv[0] split for Program Files-style paths). The Claude-side identity check already had a tasklist fallback and is unchanged.
  • Orphan backstop — if the Claude PID could not be resolved at all AND the transcript has been idle for COZEMPIC_GUARD_ORPHAN_EXIT_SECONDS (default 2h, 0 disables), the daemon checkpoints and exits instead of living forever. Dormant whenever a Claude PID is known — the watchdog owns that case.

Verification (Windows 11)

  • Duplicate spawn, before: two guard --daemon calls 8s apart → 2 live daemons, second overwrote the first's pidfile. After: second call returns Guard already running (PID …), pidfile intact.
  • Leak, after: daemon spawned with a watched PID exits within one poll interval of that process dying (Guard stopping (Claude exited), pidfile unlinked); daemon with no resolvable Claude PID and a stale transcript exits via the orphan backstop (Guard stopping (orphaned …)).
  • New tests in tests/test_windows_lifecycle.py: pure-function coverage of the ancestry walk, cmdline split, and env knob (run on all platforms), plus Windows-gated integration tests that reproduce the exact broken topology (detached, console-less child in its own process group) against the new probes.
  • Full suite on Windows: no new failures vs main (same pre-existing platform-specific failures). POSIX paths untouched by construction (os.name == "nt" gates).

vdebellabre and others added 3 commits August 3, 2026 22:34
…de-pid detection, orphan backstop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…seam tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing the user's real global hooks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vdebellabre

Copy link
Copy Markdown
Author

Follow-up commit pushed: the test suite's HOME-only env patching is inert on Windows (Path.home() reads USERPROFILE), so test_uninstall.py was running run_uninstall against the developer's REAL ~/.claude/settings.json — deleting their live global hooks on every suite run (recoverable via the timestamped settings.*.bak it writes). Patching USERPROFILE alongside HOME fixes the isolation and turns 16 previously-failing Windows tests green.

@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant