From 8da0b4e565be2d5f798741bb9b7bee70c2102c8c Mon Sep 17 00:00:00 2001 From: Priyanshu Sharma Date: Wed, 24 Jun 2026 09:54:28 -0500 Subject: [PATCH] fix(install): guard install_agent_ensure against duplicate runtime spawns (#1301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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:`; 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. --- headroom/cli/install.py | 22 +++- tests/test_cli/test_install_cli.py | 186 +++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/headroom/cli/install.py b/headroom/cli/install.py index 12a1727ad..ed9883d96 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -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.") diff --git a/tests/test_cli/test_install_cli.py b/tests/test_cli/test_install_cli.py index 94510724c..ed1600892 100644 --- a/tests/test_cli/test_install_cli.py +++ b/tests/test_cli/test_install_cli.py @@ -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