mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(install): guard install_agent_ensure against duplicate runtime spawns (#1301)
## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
## Description
`install_agent_ensure` in `cli/install.py` only checked
`probe_ready(health_url)`. If the proxy was alive-but-not-ready (e.g.
during cold start while tokenizers load — ~38s on Windows),
`probe_ready` returned false and it unconditionally called
`_start_deployment` → `start_detached_agent`, spawning a **second
runtime** without:
1. acquiring `acquire_runtime_start_lock`
2. checking `runtime_status`
3. stopping the existing instance
Two proxies then contend for `127.0.0.1:<port>`; only one can bind, and
the deployment ends up wedged (never ready). Every subsequent ensure
spawns yet another runtime → restart storm.
By contrast, the hook path `cli/init.py:_ensure_profile_running` does it
correctly: it acquires the start-lock, checks `runtime_status`, and
`stop_runtime`s a wedged instance before starting a fresh one.
Closes #1151.
## Changes Made
- Added `acquire_runtime_start_lock` to the imports from
`install.runtime` in `headroom/cli/install.py`
- Rewrote `install_agent_ensure` to mirror the guarded pattern from
`_ensure_profile_running` in `cli/init.py`:
- Fast-path probe: if proxy is already ready, return immediately
(preserves existing behavior)
- Lock acquisition: acquire `acquire_runtime_start_lock` — if another
ensure holds it, return without spawning (prevents duplicate)
- Double-checked locking: re-probe `probe_ready` after acquiring the
lock (race window handled)
- Wedged instance detection: if `runtime_status` says "running" but
proxy isn't ready within 15s grace period, call `stop_runtime` before
starting fresh
- Fall through to `_start_deployment` only when truly needed
- Added `_STARTUP_READY_TIMEOUT_SECONDS = 15` constant (matching the
value used in `_ensure_profile_running`)
- **Failure propagation (addresses @JerrettDavis's review feedback):**
removed the `try/except Exception` wrapper around the guarded block.
`install agent ensure` is an automation-facing CLI command and must exit
non-zero on failure so callers can distinguish a successful ensure from
a failed one. The `init.py` hook path retains its `try/except` because
silent retry is intentional there. The control flow is shared; the error
contract is intentionally different because the call sites have
different needs.
- Added 5 regression tests in `tests/test_cli/test_install_cli.py`:
- `test_install_agent_ensure_no_spawn_when_lock_not_acquired` — verifies
no runtime spawned when lock is contended (the core bug)
- `test_install_agent_ensure_stops_wedged_runtime_before_restart` —
verifies `stop_runtime` is called BEFORE `_start_deployment` when
instance is wedged (ordering assertion: `calls.index("stop") <
calls.index("start_deployment")`)
- `test_install_agent_ensure_starts_when_stopped_and_lock_acquired` —
verifies the normal start path including the real `_start_deployment` →
`start_detached_agent` wiring
- `test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck` —
verifies double-checked locking prevents duplicate when proxy becomes
ready between initial probe and lock acquisition
- `test_install_agent_ensure_propagates_start_deployment_failure` —
**new** regression test for the failure-propagation fix: monkeypatches
`_start_deployment` to raise `click.ClickException("simulated start
failure")` and asserts both `exit_code != 0` and that the error message
survives in output
## 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 run python -m pytest tests/test_cli/test_install_cli.py -v --tb=short
tests/test_cli/test_install_cli.py::test_install_apply_starts_service_supervisor PASSED [ 6%]
tests/test_cli/test_install_cli.py::test_install_status_includes_backend_from_health_probe PASSED [ 12%]
tests/test_cli/test_install_cli.py::test_install_restart_uses_internal_helpers PASSED [ 18%]
tests/test_cli/test_install_cli.py::test_install_apply_rejects_invalid_profile PASSED [ 25%]
tests/test_cli/test_install_cli.py::test_install_apply_rejects_provider_scope_targets_without_support PASSED [ 31%]
tests/test_cli/test_install_cli.py::test_install_apply_restores_previous_deployment_after_failed_update PASSED [ 37%]
tests/test_cli/test_install_cli.py::test_install_start_rejects_task_lifecycle PASSED [ 43%]
tests/test_cli/test_install_cli.py::test_install_apply_uses_docker_runtime_for_persistent_docker PASSED [ 50%]
tests/test_cli/test_install_cli.py::test_install_remove_continues_when_runtime_teardown_errors PASSED [ 56%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_reports_already_healthy PASSED [ 62%]
tests/test_cli/test_install_cli.py::test_install_agent_run_exits_with_foreground_status PASSED [ 68%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_no_spawn_when_lock_not_acquired PASSED [ 75%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_stops_wedged_runtime_before_restart PASSED [ 81%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_starts_when_stopped_and_lock_acquired PASSED [ 87%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck PASSED [ 93%]
tests/test_cli/test_install_cli.py::test_install_agent_ensure_propagates_start_deployment_failure PASSED [100%]
============================== 16 passed in 0.29s ==============================
```
```
$ uv run ruff check headroom/cli/install.py tests/test_cli/test_install_cli.py
All checks passed!
$ uv run ruff format --check headroom/cli/install.py tests/test_cli/test_install_cli.py
2 files already formatted
$ uv run mypy headroom/cli/install.py --ignore-missing-imports
Success: no issues found in 1 source file
```
## Real Behavior Proof
- **Environment**: Python 3.11.14, Linux 6.17.0, headroom dev
environment (uv-synced), rebased onto `upstream/main` at `3be2526b`
- **Exact command / steps**: `uv run python -m pytest
tests/test_cli/test_install_cli.py -v --tb=short` (and the ruff + mypy
commands above)
- **Observed result**: All 16 tests pass (11 existing + 5 new regression
tests). The 5 new tests verify: (1) no-spawn when the lock is contended,
(2) `stop_runtime` ordering before `_start_deployment` on a wedged
instance, (3) normal start path, (4) double-checked locking after the
lock is acquired, (5) failure propagation when `_start_deployment`
raises — this last test is the regression for @JerrettDavis's review
feedback. ruff check, ruff format --check, and mypy all pass clean.
- **Not tested**: Live deployment with concurrent `install agent ensure`
invocations on Windows (only unit tests with monkeypatched runtime
functions). The fix mirrors the proven pattern from
`_ensure_profile_running` which is already battle-tested in the init
hook path.
## Review Readiness
- [x] I have performed a self-review of my own code
- [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
## Screenshots (if applicable)
N/A
## Additional Notes
**Triage of labels on this PR:**
- `status: needs author action` — **stale**. The 4 `Real Behavior Proof`
fields (`Environment`, `Exact command / steps`, `Observed result`, `Not
tested`) are all present in this body. The bot snapshot was taken before
the body was filled in. Requesting the label be dropped on the next bot
run.
- `status: ci failing` — **CI env, not caused by this PR.**
`install-native (macos-latest)` and `wrap-native (macos-latest)` fail
during the editable Rust/Python extension build with `ld: library
'clang_rt.osx' not found`, which is before this command path runs.
@JerrettDavis confirmed this is not caused by the PR. All Linux jobs,
all unit/integration/E2E jobs, lint, commitlint, template check, and
Docker E2E jobs are green. `mergeable: MERGEABLE` is the actual gate.
**CHANGELOG:** not updated — this is a single bug fix in an unreleased
section, and the maintainers have not requested CHANGELOG entries for
individual PRs in past PRs in this repo. Happy to add an entry under `##
Unreleased` if requested.
This commit is contained in:
parent
5986c2260f
commit
8da0b4e565
2 changed files with 207 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ from headroom.install.models import (
|
|||
from headroom.install.planner import build_manifest
|
||||
from headroom.install.providers import apply_mutations, revert_mutations
|
||||
from headroom.install.runtime import (
|
||||
acquire_runtime_start_lock,
|
||||
run_foreground,
|
||||
runtime_status,
|
||||
start_detached_agent,
|
||||
|
|
@ -330,6 +331,9 @@ def install_agent_run(profile: str) -> None:
|
|||
raise SystemExit(run_foreground(manifest))
|
||||
|
||||
|
||||
_STARTUP_READY_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
@install_agent.command("ensure")
|
||||
@click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
|
||||
def install_agent_ensure(profile: str) -> None:
|
||||
|
|
@ -339,5 +343,21 @@ def install_agent_ensure(profile: str) -> None:
|
|||
if probe_ready(manifest.health_url):
|
||||
click.echo(f"Deployment '{profile}' is already healthy.")
|
||||
return
|
||||
_start_deployment(manifest)
|
||||
with acquire_runtime_start_lock(manifest.profile) as acquired:
|
||||
if not acquired:
|
||||
click.echo(f"Deployment '{profile}' start is already in progress.")
|
||||
return
|
||||
# Double-check after acquiring the lock — another ensure may have
|
||||
# started the runtime while we waited for the lock.
|
||||
if probe_ready(manifest.health_url):
|
||||
click.echo(f"Deployment '{profile}' is already healthy.")
|
||||
return
|
||||
if runtime_status(manifest) == "running":
|
||||
# Runtime exists but isn't ready yet — give it a grace period
|
||||
# before deciding it's wedged and restarting.
|
||||
if wait_ready(manifest, timeout_seconds=_STARTUP_READY_TIMEOUT_SECONDS):
|
||||
click.echo(f"Deployment '{profile}' is healthy.")
|
||||
return
|
||||
stop_runtime(manifest)
|
||||
_start_deployment(manifest)
|
||||
click.echo(f"Deployment '{profile}' is healthy.")
|
||||
|
|
|
|||
|
|
@ -341,3 +341,189 @@ def test_install_agent_run_exits_with_foreground_status(monkeypatch) -> None:
|
|||
result = runner.invoke(main, ["install", "agent", "run"])
|
||||
|
||||
assert result.exit_code == 7
|
||||
|
||||
|
||||
def test_install_agent_ensure_no_spawn_when_lock_not_acquired(monkeypatch) -> None:
|
||||
"""Ensure does not spawn a runtime when the start lock is contended."""
|
||||
runner = CliRunner()
|
||||
calls: list[str] = []
|
||||
|
||||
class Manifest:
|
||||
profile = "default"
|
||||
health_url = "http://127.0.0.1:8787/readyz"
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
||||
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
||||
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_lock(profile):
|
||||
yield False
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_detached_agent",
|
||||
lambda profile: calls.append("start_agent"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_persistent_docker",
|
||||
lambda manifest: calls.append("start_docker"),
|
||||
)
|
||||
|
||||
result = runner.invoke(main, ["install", "agent", "ensure"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already in progress" in result.output
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_install_agent_ensure_stops_wedged_runtime_before_restart(monkeypatch) -> None:
|
||||
"""Ensure stops a wedged runtime (running but not ready) before starting fresh."""
|
||||
runner = CliRunner()
|
||||
calls: list[str] = []
|
||||
|
||||
class Manifest:
|
||||
profile = "default"
|
||||
health_url = "http://127.0.0.1:8787/readyz"
|
||||
preset = "persistent-task"
|
||||
supervisor_kind = "none"
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
||||
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
||||
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
|
||||
monkeypatch.setattr("headroom.cli.install.wait_ready", lambda manifest, timeout_seconds: False)
|
||||
monkeypatch.setattr("headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop"))
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_detached_agent",
|
||||
lambda profile: calls.append("start_agent"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_persistent_docker",
|
||||
lambda manifest: calls.append("start_docker"),
|
||||
)
|
||||
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_lock(profile):
|
||||
yield True
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install._start_deployment", lambda manifest: calls.append("start_deployment")
|
||||
)
|
||||
|
||||
result = runner.invoke(main, ["install", "agent", "ensure"])
|
||||
assert result.exit_code == 0, result.output
|
||||
# stop must come before start_deployment — that's the bug guard.
|
||||
assert calls.index("stop") < calls.index("start_deployment")
|
||||
assert "start_agent" not in calls
|
||||
assert "start_docker" not in calls
|
||||
|
||||
|
||||
def test_install_agent_ensure_starts_when_stopped_and_lock_acquired(monkeypatch) -> None:
|
||||
"""Ensure starts a runtime when none is running and lock is acquired."""
|
||||
runner = CliRunner()
|
||||
calls: list[str] = []
|
||||
|
||||
class Manifest:
|
||||
profile = "default"
|
||||
health_url = "http://127.0.0.1:8787/readyz"
|
||||
preset = "persistent-task"
|
||||
supervisor_kind = "none"
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
||||
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
||||
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "stopped")
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_detached_agent",
|
||||
lambda profile: calls.append("start_agent"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_persistent_docker",
|
||||
lambda manifest: calls.append("start_docker"),
|
||||
)
|
||||
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_lock(profile):
|
||||
yield True
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
||||
monkeypatch.setattr("headroom.cli.install.wait_ready", lambda manifest, timeout_seconds: True)
|
||||
|
||||
result = runner.invoke(main, ["install", "agent", "ensure"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls == ["start_agent"]
|
||||
|
||||
|
||||
def test_install_agent_ensure_no_duplicate_spawn_after_lock_recheck(monkeypatch) -> None:
|
||||
"""Ensure does not spawn if proxy becomes ready between initial probe and lock."""
|
||||
runner = CliRunner()
|
||||
calls: list[str] = []
|
||||
|
||||
class Manifest:
|
||||
profile = "default"
|
||||
health_url = "http://127.0.0.1:8787/readyz"
|
||||
|
||||
# First probe_ready (before lock) returns False, second (after lock) returns True
|
||||
probe_results = iter([False, True])
|
||||
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
||||
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: next(probe_results))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"headroom.cli.install.start_detached_agent",
|
||||
lambda profile: calls.append("start_agent"),
|
||||
)
|
||||
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_lock(profile):
|
||||
yield True
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
||||
|
||||
result = runner.invoke(main, ["install", "agent", "ensure"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already healthy" in result.output
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_install_agent_ensure_propagates_start_deployment_failure(monkeypatch) -> None:
|
||||
"""Ensure must exit non-zero and surface the error when _start_deployment fails.
|
||||
|
||||
Regression for review feedback on PR #1301: the previous implementation wrapped
|
||||
the guarded block in `except Exception` and returned normally, which made
|
||||
a failed ensure indistinguishable from a successful one. Automation callers
|
||||
need a non-zero exit code to detect that the deployment did not come up.
|
||||
"""
|
||||
runner = CliRunner()
|
||||
|
||||
class Manifest:
|
||||
profile = "default"
|
||||
health_url = "http://127.0.0.1:8787/readyz"
|
||||
preset = "persistent-task"
|
||||
supervisor_kind = "none"
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
|
||||
monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: False)
|
||||
monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "stopped")
|
||||
|
||||
import contextlib
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_lock(profile):
|
||||
yield True
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install.acquire_runtime_start_lock", fake_lock)
|
||||
|
||||
def boom(manifest):
|
||||
raise click.ClickException("simulated start failure")
|
||||
|
||||
monkeypatch.setattr("headroom.cli.install._start_deployment", boom)
|
||||
|
||||
result = runner.invoke(main, ["install", "agent", "ensure"])
|
||||
assert result.exit_code != 0, f"expected non-zero exit, got {result.exit_code}: {result.output}"
|
||||
assert "simulated start failure" in result.output
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue