From 077c90b78b715ff146facce9c1307bff042d6548 Mon Sep 17 00:00:00 2001 From: CelChe Date: Sat, 25 Jul 2026 00:53:36 +0100 Subject: [PATCH] fix(daemon): identify our daemon on Windows via CIM, not ps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_process_is_our_daemon` falls back to `ps -o command= -p ` off Linux. Windows has no `ps` — and when Git Bash is on PATH it has a *worse* one: the MSYS `ps` only lists MSYS processes, so a natively spawned daemon reads as gone and the function returns False for the real, running daemon. Everything gated on that identity check then misbehaves: - `cs daemon stop` → "pid N is alive but is NOT our daemon (PID reused). Refusing to SIGTERM." The daemon cannot be stopped at all. - `render_thin._signal_outdated_daemon` → refuses to SIGTERM after an upgrade, so the daemon serves stale code indefinitely while every session falls back to the ~45ms inline render path, forever. - `cs daemon status` → reports a healthy daemon as a recycled PID. Observed on Windows 11 / v3.32.0 with a live daemon whose real cmdline is `python.exe -m claude_statusbar.cli daemon _run --render-interval 1.0`. Query CIM instead on win32. `wmic` would be cheaper but is deprecated and absent from current Windows 11 builds. ~0.5s per call, paid only by the stop / install / drift-restart paths — never by a render. Falls back from `powershell` to `pwsh`, and stays conservative (False) when neither exists, so an unrelated process is still never signalled. Co-Authored-By: Claude Opus 5 (1M context) --- src/claude_statusbar/daemon.py | 40 +++++++++++++++++++++++++ tests/test_daemon.py | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/claude_statusbar/daemon.py b/src/claude_statusbar/daemon.py index 009852b..57c53c4 100644 --- a/src/claude_statusbar/daemon.py +++ b/src/claude_statusbar/daemon.py @@ -343,16 +343,56 @@ def _cmdline_is_our_daemon(cmdline: str) -> bool: ) +_WIN_CMDLINE_TIMEOUT_S = 8.0 + + +def _win_process_cmdline(pid: int) -> Optional[str]: + """Command line of `pid` on Windows, or None if it can't be read. + + Windows has no /proc, and `ps` is worse than useless there: when Git Bash + is on PATH its MSYS `ps` only lists MSYS processes, so a natively spawned + daemon reads as gone and the caller concludes "not ours". + + CIM is the reliable source. `wmic` would be faster but is deprecated and + already absent from current Windows 11 builds. ~0.5s per call, which only + the stop / install / drift-restart paths pay — never a render. + """ + import subprocess + script = ( + f"$p = Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}';" + "if ($p) { $p.CommandLine }" + ) + for exe in ("powershell", "pwsh"): + try: + out = subprocess.run( + [exe, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + timeout=_WIN_CMDLINE_TIMEOUT_S, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except (OSError, subprocess.TimeoutExpired): + continue # not installed / hung — try the next interpreter + if out.returncode == 0 and out.stdout.strip(): + return out.stdout + return None + + def _process_is_our_daemon(pid: int) -> bool: """Verify the PID actually belongs to *our* daemon, not a recycled PID. Linux: read /proc//cmdline directly (cheap, no fork). + Windows: query CIM (see `_win_process_cmdline`). macOS / fallback: shell out to `ps -o command= -p ` (~10ms — only runs on stop/install paths, never on the per-render hot path). Returns False on any error (better to assume not-ours and skip than to accidentally SIGTERM an unrelated user process). """ + if sys.platform == "win32": + cmdline = _win_process_cmdline(pid) + return _cmdline_is_our_daemon(cmdline) if cmdline else False + proc_path = f"/proc/{pid}/cmdline" try: with open(proc_path, "rb") as f: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 6771cea..ea016a1 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1045,3 +1045,56 @@ def test_release_pidfile_still_cleans_its_own_file(monkeypatch, tmp_path: Path): assert _d._acquire_pidfile() is True _d._release_pidfile() assert not _d.pid_path().exists() + + +def test_windows_identity_uses_cim_not_ps(monkeypatch): + """On Windows there is no /proc, and a Git-Bash `ps` on PATH only lists + MSYS processes — a natively spawned daemon reads as gone, so `cs daemon + stop` refuses to stop it and the drift-kill guard never restarts it onto + upgraded code.""" + import subprocess as _sp + + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd[0]) + if cmd[0] == "ps": + raise AssertionError("ps must not be consulted on win32") + return _sp.CompletedProcess( + cmd, 0, + stdout=('"C:\Python\python.exe" -m claude_statusbar.cli ' + 'daemon _run --render-interval 1.0\n'), + stderr="", + ) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(_sp, "run", fake_run) + + assert _d._process_is_our_daemon(4242) is True + assert calls and calls[0] in ("powershell", "pwsh") + + +def test_windows_identity_false_when_pid_is_someone_else(monkeypatch): + import subprocess as _sp + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + _sp, "run", + lambda cmd, **kw: _sp.CompletedProcess( + cmd, 0, stdout="C:\Windows\explorer.exe\n", stderr=""), + ) + + assert _d._process_is_our_daemon(4242) is False + + +def test_windows_identity_false_when_no_shell_available(monkeypatch): + """No powershell and no pwsh — stay conservative, never SIGTERM.""" + import subprocess as _sp + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + _sp, "run", + lambda cmd, **kw: (_ for _ in ()).throw(FileNotFoundError(cmd[0])), + ) + + assert _d._process_is_our_daemon(4242) is False