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