fix(install): use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows (#2527)

## Description
On Windows, the detached agent process spawned by `install hook ensure`
(and the `install restart` self-spawn) pops up a visible black console
window repeatedly, because `DETACHED_PROCESS` makes `CREATE_NO_WINDOW` a
no-op per the Win32 process-creation-flags docs. #2521

## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)

## Changes Made
- `headroom/install/runtime.py`: `start_detached_agent()` now uses
`CREATE_NO_WINDOW` instead of `DETACHED_PROCESS`, combined with
`CREATE_NEW_PROCESS_GROUP` (unchanged detach/isolation semantics, window
hidden).
- `headroom/install/runtime.py`: `_spawn_detached_restart()` now also
sets `CREATE_NO_WINDOW` on Windows (previously had no `creationflags` at
all on that platform).
- `tests/test_install/test_runtime.py`: updated the Windows branch of
`test_start_detached_agent_and_run_foreground` to assert the actual
`creationflags` value passed to `Popen`, instead of just monkeypatching
an unused `DETACHED_PROCESS` attribute.

## Testing
- [x] Added/updated tests
- [x] Ran full local test suite

```
$ python -m pytest tests/test_install -q
======================= 137 passed, 1 skipped in 48.68s =======================

$ ruff check headroom/install/runtime.py tests/test_install/test_runtime.py
All checks passed!

$ ruff format --check headroom/install/runtime.py tests/test_install/test_runtime.py
2 files already formatted

$ mypy headroom --ignore-missing-imports
Success: no issues found in 506 source files
```

## Real Behavior Proof
- Environment: Windows 11, Python 3.13.11, headroom repo local checkout
- Exact command / steps: `python -m pytest
tests/test_install/test_runtime.py -q`, plus manual read of `subprocess`
Windows creation-flag semantics (`DETACHED_PROCESS` + child console
allocation vs `CREATE_NO_WINDOW`)
- Observed result: all 25 tests in `test_runtime.py` pass, including the
updated assertion that `creationflags == CREATE_NO_WINDOW |
CREATE_NEW_PROCESS_GROUP` on the Windows code path
- Not tested: did not reproduce the original visible-console-popup repro
end-to-end via live Claude Code hook invocation (no environment with the
full hook-triggered respawn loop set up in this session); relying on the
Win32 docs and the reporter's own local verification of the same flag
swap

## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-07-26 04:24:53 +02:00 committed by GitHub
parent d50cfabedc
commit 045f3dfe6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 18 additions and 6 deletions

View file

@ -280,7 +280,10 @@ def start_detached_agent(profile: str) -> subprocess.Popen[str]:
kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
if _is_windows():
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
# DETACHED_PROCESS makes CREATE_NO_WINDOW a no-op (per Win32 docs), so a
# detached console child pops up a visible window. Use CREATE_NO_WINDOW
# instead; it still detaches from the parent's console.
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
)
else:
@ -413,7 +416,9 @@ def _spawn_detached_restart(profile: str) -> None:
"""
command = [*resolve_headroom_command(), "install", "restart", "--profile", profile]
popen_kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if not _is_windows():
if _is_windows():
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
else:
popen_kwargs["start_new_session"] = True
subprocess.Popen(command, **popen_kwargs)

View file

@ -417,15 +417,22 @@ def test_run_foreground_and_detached_helpers(monkeypatch, tmp_path: Path) -> Non
monkeypatch.setattr("headroom.install.runtime.resolve_headroom_command", lambda: ["headroom"])
monkeypatch.setattr("headroom.install.runtime.sys.platform", "win32")
monkeypatch.setattr("headroom.install.runtime.subprocess.DETACHED_PROCESS", 1, raising=False)
monkeypatch.setattr("headroom.install.runtime.subprocess.CREATE_NO_WINDOW", 4, raising=False)
monkeypatch.setattr(
"headroom.install.runtime.subprocess.CREATE_NEW_PROCESS_GROUP", 2, raising=False
)
nt_calls: list[tuple[list[str], dict]] = []
fake_proc_nt = FakeProc()
monkeypatch.setattr(
"headroom.install.runtime.subprocess.Popen", lambda command, **kwargs: fake_proc_nt
)
def fake_popen_nt(command: list[str], **kwargs):
nt_calls.append((command, kwargs))
return fake_proc_nt
monkeypatch.setattr("headroom.install.runtime.subprocess.Popen", fake_popen_nt)
assert start_detached_agent("demo") is fake_proc_nt
# DETACHED_PROCESS is not used: it makes CREATE_NO_WINDOW a no-op on
# Windows, so a detached console child would pop up a visible window.
assert nt_calls[0][1]["creationflags"] == 4 | 2
monkeypatch.setattr("headroom.install.runtime.sys.platform", "linux")
fake_proc_posix = FakeProc()