Commit graph

11 commits

Author SHA1 Message Date
Abhay Singh
816cb85fa8
fix(install): close parent log fd in start_detached_agent (#1576)
## Description

`start_detached_agent()` opens the agent log file and hands it to
`subprocess.Popen` as `stdout`/`stderr`, then returns the process
**without
closing the parent's copy of the file descriptor**. The child inherits
the fd
and writes to it, but the parent keeps its own copy open forever.

The result: every `headroom install start` leaks one file descriptor in
the
parent, and the leaked handle pins the log file open so it can't be
rotated.
On a tight `ulimit` or inside a container, repeated starts can walk
straight
into the fd limit.

```python
# headroom/install/runtime.py — before
log_file = open(log_file_path, "a", encoding="utf-8", errors="replace")
kwargs = {"stdout": log_file, "stderr": log_file, ...}
return subprocess.Popen(command, **kwargs)   # parent's log_file never closed
```

The fix closes the parent's copy in a `try/finally` right after `Popen`
returns:

```python
try:
    proc = subprocess.Popen(command, **kwargs)
finally:
    # The child has inherited the log file descriptor, so the parent's
    # copy is dead weight. Closing it (even when Popen raises) avoids
    # leaking one fd per `headroom install start` and lets the log file
    # be rotated.
    log_file.close()
return proc
```

The `finally` is deliberate: it also covers the case where `Popen`
itself
raises (bad executable, fork failure), which would otherwise leak the
just-opened handle. This matches the `with open(...)` pattern already
used by
`run_foreground()` a few lines above.

Closes #1554

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/install/runtime.py`: close the parent's log file descriptor
in a `try/finally` after `subprocess.Popen` in `start_detached_agent()`,
so it is released on the normal path and when `Popen` raises.
- `tests/test_install/test_runtime.py`: add two regression tests — one
for a normal start, one for `Popen` raising — asserting the parent's log
handle is closed afterwards.
- `CHANGELOG.md`: Bug Fixes entry under Unreleased.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

Before the fix (`runtime.py` reverted to its parent commit, new tests
kept) —
the assertion inspects the *actual* log file handle and finds it still
open:

```text
E   AssertionError: assert False is True
E    +  where False = <_io.TextIOWrapper name='...\deploy\demo\runner.log' mode='a' encoding='utf-8'>.closed
FAILED tests/test_install/test_runtime.py::test_start_detached_agent_closes_parent_log_fd
FAILED tests/test_install/test_runtime.py::test_start_detached_agent_closes_log_fd_when_popen_raises
============================== 2 failed in 0.43s ==============================
```

After the fix:

```text
tests\test_install\test_runtime.py ................
====================== 16 passed, 1 deselected in 0.35s =======================
```

(The one deselected test,
`test_runtime_start_lock_blocks_another_process`, is a
pre-existing failure on my Windows box — it fails identically on a clean
checkout of `main` and is unrelated to this change.)

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, headroom built from this
branch (`uv sync --extra dev`).
- Exact command / steps: ran the two regression tests, which drive the
real `start_detached_agent` code path — real `open()`, real
`Popen(stdout=...)`, real (or missing) `close()`. Only
`subprocess.Popen` is stubbed so the test never launches an actual
detached agent; the fd-lifecycle bug lives entirely in how the parent
handles its own handle, and that runs for real.
- Observed result: the log file handle the parent passed to `Popen` is
`.closed == False` before the fix and `.closed == True` after — for both
the normal path and the `Popen`-raises path (output above).
- Not tested: I intentionally did not spin up many real detached agents
to watch the OS fd table grow — on Windows that means flashing console
windows and isn't a clean signal anyway. The handle-state assertion on
the real file object is the deterministic equivalent. Did not run the
full `mypy headroom` pass (one-line lifecycle change, no new types).

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Single logical change, no new dependencies, default behavior otherwise
unchanged.
- Rebased/merged latest `main` to clear a `CHANGELOG.md` conflict.
- @chopratejas this is a sibling of #1555 (the `wrap.py` readiness-loop
handle leak you've got filed). I scoped this PR to #1554 only; happy to
follow up on #1555 separately if you'd like.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-01 23:07:01 -05:00
Parideboy
6b227b9c90
fix(install): use Windows-safe PID liveness probe in runtime_status (#1544) (#1560)
## Description

`headroom install status` crashed with `OSError: [WinError 87] The
parameter is incorrect` on Windows and, worse, tore down the live proxy
it was only meant to inspect. `runtime_status()` probed liveness with a
bare `os.kill(pid, 0)` guarded only by `except OSError`. Against a
detached Windows agent (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`),
that call raises WinError 87, which CPython surfaces as a `SystemError`
— not an `OSError` — so it escaped the handler, crashed status, and left
the deployment dead (PID file removed, port 8787 freed). This mirrors
the `os.kill`/`SystemError` fix PR #1315 applied to `cli/wrap.py`.

Closes #1544

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added a shared Windows-safe `headroom._subprocess.pid_alive()` helper:
rejects non-positive PIDs, prefers `psutil.pid_exists()`, and treats
`SystemError` (WinError 87) as "not alive".
- `install/runtime.py` `runtime_status()` now delegates to `pid_alive()`
instead of an unguarded `os.kill(pid, 0)`.
- `install/runtime.py` `stop_runtime()` now also catches `SystemError`
to avoid the same crash class on shutdown.
- `cli/wrap.py` `_pid_alive()` now delegates to the shared helper, so
the marker-cleanup path and the install/runtime status path share one
liveness probe (the shared helper the issue asked for).
- Added regression tests for the helper and `runtime_status`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ ruff check .
All checks passed!

$ ruff format --check headroom/_subprocess.py headroom/install/runtime.py headroom/cli/wrap.py tests/test_install/test_runtime.py tests/test_pid_alive.py
5 files already formatted

$ mypy headroom --ignore-missing-imports
(exit 0)

$ pytest tests/test_pid_alive.py tests/test_install/test_runtime.py tests/test_cli/test_wrap_helpers.py tests/test_cli/test_wrap_persistent.py \
    --deselect "tests/test_install/test_runtime.py::test_runtime_start_lock_blocks_another_process"
89 passed
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13, ruff 0.15.17 / mypy 1.20.2 /
pytest 9.1.0, psutil 7.2.2, branch `fix/1544-windows-pid-liveness`.
- Exact command / steps: ran the four checks above; the new
`tests/test_pid_alive.py` injects a `SystemError` (simulated WinError
87) and a stubbed `psutil` to drive both code paths, and
`test_runtime_status_*` exercise `runtime_status()` end to end with a
PID file present.
- Observed result: `runtime_status` returns `"running"` for a live PID
without sending any signal (asserted), returns `"stopped"` instead of
crashing when the probe raises `SystemError`, and the helper only ever
passes signal `0`. All 89 targeted tests pass; ruff/format/mypy clean.
- Not tested: the full `headroom install apply --preset persistent-task`
detached-agent reproduction against a live proxy was not run end to end;
it is instead covered by the deterministic `SystemError`/WinError-87
injection regression tests.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- One pre-existing test,
`test_runtime_start_lock_blocks_another_process`, fails on my local
Windows checkout **before** these changes too (it asserts cross-process
file-lock blocking and depends on `HOME` semantics that differ on
Windows). It is unrelated to this fix and is deselected above; it passes
on the Linux CI runners.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:13:11 -05:00
Ben Younes
feedead077
fix(install): stop duplicating ENTRYPOINT in persistent-docker runtime command (#833) (#1348)
## Description

`headroom install apply --preset persistent-docker` pulls the image,
starts the container, then fails after ~45s with "Deployment 'default'
did not become ready after start." The rollback removes the container
and manifest, leaving nothing running and no logs.

Root cause: the published image already bakes the proxy invocation into
its ENTRYPOINT (`Dockerfile`: `ENTRYPOINT ["headroom", "proxy"]`), but
`build_runtime_command()` in `headroom/install/runtime.py` re-added
`headroom proxy` after the image name. Docker concatenates ENTRYPOINT +
args, so the container ran `headroom proxy headroom proxy --host 0.0.0.0
...` and Click aborted with `Got unexpected extra arguments (headroom
proxy)`.

The runtime command now appends only the proxy flags after the image
name, substituting the all-interface container bind host for the host
pair carried in `proxy_args`.

Closes #833

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/install/runtime.py`: drop the duplicated `headroom proxy`
from the docker `build_runtime_command` output; append only `--host
<bind> *proxy_args[2:]`. Extracted `CONTAINER_BIND_HOST` and
`_PROXY_ARGS_HOST_PAIR_LEN` named constants.
- `tests/test_install/test_runtime.py`: new regression test asserting
the args appended after the image name never re-add the `headroom proxy`
ENTRYPOINT.
- `CHANGELOG.md`: Unreleased → Bug Fixes entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_install/ -q
91 passed, 1 skipped in 5.48s

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

$ uv run mypy headroom/install/runtime.py
Success: no issues found in 1 source file
```

#### RED → GREEN proof

RED — new test with the prod fix reverted (test kept):

```text
E   AssertionError: container args re-add the ENTRYPOINT — got
    ['headroom', 'proxy', '--host', '0.0.0.0', '--port', '8787', '--backend', 'anthropic']
FAILED tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_does_not_duplicate_entrypoint
1 failed in 0.17s
```

GREEN — with the fix applied:

```text
tests/test_install/test_runtime.py::test_build_runtime_command_for_docker_does_not_duplicate_entrypoint
1 passed in 0.11s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: reproduce the exact concatenation Docker
performs (ENTRYPOINT `headroom proxy` + the buggy CMD `headroom proxy
--host 0.0.0.0 --port 8787`):

  ```text
  $ headroom proxy headroom proxy --host 0.0.0.0 --port 8787
  Usage: headroom proxy [OPTIONS]
  Try 'headroom proxy --help' for help.
  Error: Got unexpected extra arguments (headroom proxy)
  ```

This is the exact error from the issue. After the fix,
`build_runtime_command` appends only the flags after the image name:

  ```text
args after image: ['--host', '0.0.0.0', '--port', '8787', '--backend',
'anthropic']
  ```

so the container runs `headroom proxy --host 0.0.0.0 --port 8787
--backend anthropic` (ENTRYPOINT + flags) and Click accepts it.
- Observed result: pre-fix Click aborts with the unexpected-arguments
error (container crash-loops); post-fix the command line is valid.
- Not tested: pulling and running the real `ghcr.io` image end-to-end
(requires the published image + Docker host); the failure is fully
determined by the generated argv, which is covered above and by the unit
test.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Scope is limited to the docker runtime command construction. The Python
(`runtime_kind=python`) path was already correct and is unchanged.
Screenshots N/A (CLI-only change).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:13:38 -05:00
Hc
9252d852c5
fix(init): guard persistent task startup (#616)
## Description

Prevent `headroom init` hooks from spawning duplicate persistent-task
runners while a proxy is still starting.

Fixes #615

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Problem

`_ensure_profile_running()` checked readiness for only one second and
then launched `start_detached_agent()` whenever the proxy was not ready
yet. When Claude/Codex hooks fired close together, each hook could race
through that path and spawn another detached persistent-task runner.

## Changes Made

- Add a profile-local, nonblocking runtime start lock around init hook
startup.
- Re-check readiness after acquiring the lock so late-arriving hooks do
not start a duplicate runner.
- If a runtime is already alive, wait up to 15 seconds for readiness
before stopping and restarting it.
- Add regression tests for lock contention, slow startup, and
cross-process lock behavior.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

## Test Output

```
UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py
# 89 passed in 0.61s

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check .
# All checks passed!

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check .
# 775 files already formatted

UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports
# Success: no issues found in 346 source files
```

Manual sandbox check:

```
# before this change: 3 ensure calls spawned 3 detached starts
# after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting
```

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Docs and CHANGELOG were left unchanged because this is a small runtime
bug fix with no user-facing CLI/API change.
2026-06-10 20:34:43 -05:00
Garm
efd2ac1ca4 chore: renormalize line endings to LF
`.gitattributes` declares `*.py text eol=lf` and `*.sh text eol=lf`, but
74 files (73 .py, 1 .sh) are stored in the index with CRLF line endings,
violating that contract. Every macOS/Linux clone reports these files as
"modified" on fresh checkout because git's diff engine sees the stored
bytes don't match the attribute contract, even though the working tree
and index match byte-for-byte.

Running `git add --renormalize .` rewrites each affected blob so the
stored form matches the attribute declaration. No semantic changes —
every affected file's diff is "N insertions, N deletions" with inserts
and deletes being the same lines modulo line endings.

Follow-up commit adds `.git-blame-ignore-revs` so `git blame` / GitHub
blame skip this mechanical commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:30 +02:00
JerrettDavis
d4574f4ae2 test: avoid global platform leaks in install tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:15:41 -05:00
JerrettDavis
38bf3e639c test: expand coverage across helper slices
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 07:39:52 -05:00
JerrettDavis
4a87753713 feat(docker): forward HEADROOM_WORKSPACE_DIR and HEADROOM_CONFIG_DIR into containers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:19:25 -05:00
JerrettDavis
bd242fc62d test: expand persistent install coverage
Add focused regression coverage for install, runtime, provider, state, health, supervisor, and persistent wrap flows so the new persistent deployment surfaces are exercised more thoroughly in CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 18:24:15 -05:00
JerrettDavis
865bef2216 fix: harden persistent install wrappers and review gaps
Align Docker-native wrapper help and runtime behavior with the Python install contract, including persistent deployment metadata, baked install-image defaults, and explicit unsupported wrap targets.

Harden the Python persistent-install path with profile validation, safer provider-scope handling, Windows environment restoration, runtime parity improvements, and rollback-safe apply/update behavior.

Update README, Docker install docs, CI, and focused regressions to cover the Windows BOM failure, wrapper parity, compose coverage, and Docker-native wrap behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 18:03:21 -05:00
JerrettDavis
21896a095c feat: add persistent install lifecycle management
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 13:47:05 -05:00