2026-04-11 13:47:05 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
import errno
|
|
|
|
|
|
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 17:34:40 -05:00
|
|
|
import click
|
2026-06-11 17:42:43 -07:00
|
|
|
import pytest
|
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 17:34:40 -05:00
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
import headroom.cli.wrap as wrap_cli
|
2026-04-11 13:47:05 -05:00
|
|
|
|
|
|
|
|
|
2026-06-11 17:42:43 -07:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _no_attached_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
"""Default: no other wrap clients attached, so restart paths are hermetic.
|
|
|
|
|
|
|
|
|
|
The ephemeral restart guards consult ``_live_proxy_clients``; without this, a
|
|
|
|
|
real ``headroom wrap`` session on the dev's machine could make these tests
|
|
|
|
|
flaky. Individual tests override this to simulate attached wrappers.
|
|
|
|
|
"""
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [])
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 13:47:05 -05:00
|
|
|
class _Manifest:
|
|
|
|
|
profile = "default"
|
|
|
|
|
preset = "persistent-service"
|
|
|
|
|
supervisor_kind = "service"
|
|
|
|
|
health_url = "http://127.0.0.1:8787/readyz"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 13:47:05 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.supervisors.start_supervisor",
|
|
|
|
|
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
2026-04-22 11:28:30 +00:00
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
2026-04-11 13:47:05 -05:00
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-04-11 13:47:05 -05:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-04-11 13:47:05 -05:00
|
|
|
assert calls == ["start:default"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 13:47:05 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.supervisors.start_supervisor",
|
|
|
|
|
lambda manifest: calls.append(f"start:{manifest.profile}"),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-04-11 13:47:05 -05:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-04-11 13:47:05 -05:00
|
|
|
assert calls == ["start:default"]
|
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 17:34:40 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None:
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
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 17:34:40 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
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 17:34:40 -05:00
|
|
|
|
|
|
|
|
try:
|
2026-04-22 11:28:30 +00:00
|
|
|
wrap_cli._ensure_proxy(8787, False)
|
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 17:34:40 -05:00
|
|
|
except click.ClickException as exc:
|
|
|
|
|
assert "is not healthy" in str(exc)
|
|
|
|
|
else:
|
|
|
|
|
raise AssertionError("expected unhealthy persistent deployment to raise")
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_available_port", lambda port, **kw: port)
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-04-22 11:28:30 +00:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-04-22 11:28:30 +00:00
|
|
|
assert calls == ["start"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 07:19:00 +06:00
|
|
|
def test_ensure_proxy_reports_unbindable_port_before_starting_subprocess(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
"_find_available_port",
|
|
|
|
|
lambda port, **kw: (_ for _ in ()).throw(
|
|
|
|
|
OSError(errno.EADDRNOTAVAIL, "address not available")
|
|
|
|
|
),
|
2026-06-05 07:19:00 +06:00
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
|
|
|
|
except click.ClickException as exc:
|
|
|
|
|
message = str(exc)
|
|
|
|
|
else:
|
|
|
|
|
raise AssertionError("expected unbindable port to raise before starting proxy")
|
|
|
|
|
|
|
|
|
|
assert "Port 8787 is unavailable" in message
|
|
|
|
|
assert calls == []
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 15:58:27 -07:00
|
|
|
def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) -> None:
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(f"restart:{manifest.profile}:{port}") or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-05-09 15:58:27 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-05-09 15:58:27 -07:00
|
|
|
assert calls == ["restart:default:8787"]
|
|
|
|
|
|
|
|
|
|
|
fix(version): mark source-checkout builds as -dev (#2072)
## Description
`headroom --version` and the dashboard show `0.32.0` from a source
checkout, but the latest published release is `0.31.0`. That `0.32.0` is
not a real release: on a git checkout `get_version()` predicts the
*next* release from conventional commits since the last tag (`v0.31.0` +
`feat:` commits → `0.32.0`) and renders it identically to a shipped
version — so a dev build looks published.
This appends `-dev` on the source-checkout path so a dev build is never
mistaken for the published release.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/_version.py`: the source-checkout branch of `get_version()`
now returns `f"{source_version}-dev"`.
- `tests/test_package_init_lazy.py`: updated the source-tree version
test to assert the `-dev` suffix.
Released installs are unaffected: pip wheels and Docker images with a
baked `BUILD_VERSION` never take the source-checkout path, so they still
report clean release versions (`0.31.0` / `v0.31.0`). The suffix makes
`is_release_version()` return `False` and `normalize_release_version()`
return `None`, which every comparison site already handles — e.g.
`wrap.py`'s `_proxy_needs_version_restart` requires both sides to
normalize, so a dev build short-circuits to "no restart" (no behavior
change).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality (updated the existing
source-tree test)
- [x] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_package_init_lazy.py tests/test_cli_doctor.py -q
============================== 12 passed in 1.79s ==============================
============================== 51 passed in 0.56s ==============================
$ ruff check headroom/_version.py tests/test_package_init_lazy.py
All checks passed!
$ mypy headroom/_version.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: local source checkout (macOS), `.venv`, latest release
tag `v0.31.0`
- Exact command / steps: `headroom --version`
- Observed result:
- Before: `headroom, version 0.32.0` — indistinguishable from a release
- After: `headroom, version 0.32.0-dev`
- Not tested: behavior inside a built Docker image / installed pip wheel
— unchanged by design, since those paths never compute a source-tree
version.
## 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
Scope kept to a bare `-dev` marker, which answers "is this a release?".
Appending the short git SHA (`-dev+g<sha>`) to distinguish individual
dev builds in bug reports is an easy follow-up if wanted. Docs/CHANGELOG
unchecked as N/A for a dev-only version-string fix.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-13 09:38:17 -04:00
|
|
|
def test_ensure_proxy_restarts_stale_proxy_from_dev_build(monkeypatch) -> None:
|
|
|
|
|
"""A source (-dev) CLI still restarts a stale proxy: the -dev marker is
|
|
|
|
|
display-only and must not disable a real version-mismatch restart."""
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.32.0-dev")
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(f"restart:{manifest.profile}:{port}") or True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
|
|
|
|
|
|
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
|
|
|
|
assert calls == ["restart:default:8787"]
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 15:58:27 -07:00
|
|
|
def test_ensure_proxy_leaves_active_stale_persistent_deployment_running(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 1, "active_relay_tasks": 2}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("active deployment should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-05-09 15:58:27 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-05-09 15:58:27 -07:00
|
|
|
|
|
|
|
|
|
2026-06-11 17:42:43 -07:00
|
|
|
def test_ensure_proxy_defers_persistent_restart_when_http_wrapper_attached(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A stale persistent proxy is left running while marker-tracked HTTP
|
|
|
|
|
wrappers are attached, even when WebSocket session count is zero."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": 12345},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached persistent proxy should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-06-11 17:42:43 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-11 17:42:43 -07:00
|
|
|
|
|
|
|
|
|
2026-04-11 18:24:15 -05:00
|
|
|
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
|
|
|
|
class DefaultManifest:
|
|
|
|
|
profile = "default"
|
|
|
|
|
port = 8787
|
|
|
|
|
|
|
|
|
|
class OtherManifest:
|
|
|
|
|
profile = "custom"
|
|
|
|
|
port = 8787
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.state.list_manifests",
|
|
|
|
|
lambda: [OtherManifest(), DefaultManifest()],
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
manifest = wrap_cli._find_persistent_manifest(8787)
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
assert manifest.profile == "default"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None:
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
2026-04-11 18:24:15 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
assert wrap_cli._recover_persistent_proxy(8787) is True
|
2026-04-11 18:24:15 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None:
|
|
|
|
|
class TaskManifest(_Manifest):
|
|
|
|
|
supervisor_kind = "task"
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: TaskManifest())
|
2026-04-11 18:24:15 -05:00
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
|
2026-04-22 11:28:30 +00:00
|
|
|
assert wrap_cli._recover_persistent_proxy(8787) is False
|
2026-05-09 15:58:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-05-09 15:58:27 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-05-09 15:58:27 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-05-09 15:58:27 -07:00
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
|
|
|
|
|
|
fix(docker): report source build version (#1862)
## Description
Closes #1858
Docker/Compose source builds could report stale or misleading version
information: the dashboard initially rendered a hardcoded `v0.3.0`, then
`/health` replaced it with installed package metadata, which can be
stale when building locally from `main` without release metadata in the
image.
This change makes source Docker Compose builds report an explicit
source-build identity, removes the stale dashboard fallback, and keeps
CLI/doctor version checks from treating source-build labels as
release-version drift.
## 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
- Add `HEADROOM_VERSION` / `HEADROOM_BUILD_VERSION` runtime version
overrides and optional packaged `_build_info.py` metadata.
- Teach Docker Compose source builds to pass a `source-build` sentinel
that the Dockerfile expands to `source-build+g<sha>` when git metadata
is available, or `source-build+sha256.<digest>` otherwise.
- Keep release/published image builds on normal package metadata when
`HEADROOM_BUILD_VERSION` is unset.
- Include only minimal `.git` metadata in the Docker build context so
the source-build label can identify the checkout without copying git
objects.
- Treat source-build labels and raw hashes as non-release labels in
`wrap` and `doctor`, avoiding false stale-proxy restarts and drift
warnings.
- Replace the dashboard hardcoded `0.3.0` fallback with `loading` /
`unknown` and format non-release build labels without a `v` prefix.
- Include the runtime version in proxy startup logs, `/health`,
`/livez`, and OTEL service version reporting.
## Testing
- [x] Unit tests pass (`pytest` in GitHub CI)
- [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
GitHub CI: all checks passing
- CI: build, build-wheel, lint, test shards, test-extras, test-agno, test-dashboard-ui
- Docker: docker-native-e2e, docker-wrap-e2e, docker-init-e2e
- Native wrappers: macOS, Windows, Ubuntu
- Security: CodeQL, gitleaks, pip-audit
- Governance: template, label, merge-conflicts, commitlint
$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1858-version-mismatch pytest tests/test_package_init_lazy.py::test_version_prefers_explicit_build_env tests/test_package_init_lazy.py::test_version_label_helpers_only_prefix_release_versions tests/test_package_init_lazy.py::test_version_uses_packaged_build_metadata tests/test_package_init_lazy.py::test_observability_version_uses_runtime_version tests/test_docker_compose_persistence.py tests/test_cli_doctor.py::TestProxyLiveness::test_up_leaves_source_label_unprefixed tests/test_cli_doctor.py::TestVersionDrift::test_non_release_version_labels_skip_drift_comparison tests/test_cli/test_wrap_persistent.py::test_proxy_version_restart_ignores_non_release_source_labels tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -q
13 passed, 1 warning
$ uvx ruff==0.15.17 check .
All checks passed!
$ uvx ruff==0.15.17 format --check .
1058 files already formatted
$ uvx mypy==1.20.2 headroom --ignore-missing-imports
Success: no issues found in 407 source files
$ git diff --check
# no output
$ docker compose config
# resolved headroom-proxy build args include HEADROOM_BUILD_VERSION: source-build
$ HEADROOM_BUILD_VERSION=6266a1d docker compose config
# explicit override is preserved as HEADROOM_BUILD_VERSION: 6266a1d
$ docker build --check --build-arg HEADROOM_BUILD_VERSION=source-build .
Check complete, no warnings found.
```
## Real Behavior Proof
- Environment: macOS local checkout, Python 3.13.5, Docker Desktop
builder `desktop-linux`, plus GitHub Actions CI.
- Exact command / steps: `docker compose config`,
`HEADROOM_BUILD_VERSION=6266a1d docker compose config`, and `docker
build --check --build-arg HEADROOM_BUILD_VERSION=source-build .`.
- Observed result: Compose defaults the top-level `headroom-proxy` build
arg to the `source-build` sentinel, preserves explicit overrides, and
Dockerfile syntax/check validation passes for the source-build path.
- Not tested: Full end-to-end release publishing flow; this PR only
changes local/source-build reporting.
- CI proof: GitHub Actions completed successfully across Docker E2E, CI
test shards, lint/type checks, native wrapper checks, security checks,
and PR governance.
## 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/CI with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A
## Additional Notes
Docs and changelog are N/A for this runtime-reporting bug fix. The PR is
open and ready for review with all GitHub checks passing.
2026-07-08 13:32:04 -05:00
|
|
|
def test_proxy_version_restart_ignores_non_release_source_labels(monkeypatch) -> None:
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.0")
|
|
|
|
|
assert wrap_cli._proxy_needs_version_restart({"version": "source-build+g6266a1d774b5"}) is False
|
|
|
|
|
assert (
|
|
|
|
|
wrap_cli._proxy_needs_version_restart({"version": "source-build+sha.abcdef123456"}) is False
|
|
|
|
|
)
|
|
|
|
|
assert wrap_cli._proxy_needs_version_restart({"version": "6266a1d"}) is False
|
|
|
|
|
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0+gabcdef0"}) is False
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "source-build+sha.abcdef123456")
|
|
|
|
|
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is False
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.1")
|
|
|
|
|
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is True
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 21:24:47 -07:00
|
|
|
def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": "https://api.githubcopilot.com",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-06-02 21:24:47 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-02 21:24:47 -07:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.individual.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-02 21:24:47 -07:00
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][2]["openai_api_url"] == "https://api.individual.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
fix(copilot): refresh wrapped subscription tokens (#2156) (#2182)
## Description
`headroom wrap copilot --subscription` currently validates a Copilot
subscription credential once at launch, exchanges it once, and then pins
that short-lived API token into the proxy as an explicit override. When
the token expires, long-lived wrapped sessions start returning
`transient_auth_error` and then a final HTTP 401 until the entire
wrapped session is restarted.
This PR keeps the validated launch token for first-request determinism,
carries reusable OAuth refresh material into the proxy, and refreshes
inside `CopilotTokenProvider` when the seeded token is expired. The
explicit `GITHUB_COPILOT_API_TOKEN` override path stays unchanged when
no reusable OAuth token exists. The configured `GITHUB_COPILOT_API_URL`
also stays pinned across refresh, matching the current wrap contract.
Closes #2156.
## 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
- Extended the Copilot subscription token resolution path to carry
reusable OAuth refresh material and expiry metadata instead of
discarding it after the wrap-time exchange.
- Reworked `CopilotTokenProvider.get_api_token()` so it seeds the
wrapper-validated launch token once for the first request, then
refreshes through the existing exchange path when that token is expired
and reusable OAuth material exists.
- Rejected non-finite seeded expiry values such as `inf`, so malformed
`GITHUB_COPILOT_API_TOKEN_EXPIRES_AT` inputs cannot pin a stale launch
token forever.
- Preserved the explicit `GITHUB_COPILOT_API_TOKEN` override path when
no reusable OAuth token exists, so non-refreshable overrides keep
today's fixed behavior.
- Kept explicitly configured `GITHUB_COPILOT_API_URL` values pinned
across refresh rather than adopting a refreshed payload's host.
- Replaced wrapper-managed seeded `tid_` bearer passthrough with the
refresh-aware provider path, so the wrapped CLI no longer bypasses
expiry refresh just because it keeps sending the launch token back to
the proxy.
- Started a dedicated local proxy instance whenever a
subscription-seeded session targets a shared or persistent proxy port,
so per-session refresh material is not silently dropped on healthy-proxy
reuse or cross-wired between concurrent sessions.
- Scrubbed inherited Copilot refresh-seed environment variables from
both the Copilot child env and the proxy subprocess env before
re-injecting the explicit launch-time values.
- Added focused auth, wrap, proxy-env, and proxy-reuse regression
coverage for expiry refresh, non-finite expiry rejection, session-local
proxy isolation, explicit-override preservation, exchange-flag
independence, configured API URL pinning, and secret handling.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py -q`)
- [x] Linting passes (`uv run ruff check headroom/copilot_auth.py
headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/copilot_auth.py headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Type checking passes (`uv run mypy headroom/copilot_auth.py`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
195 passed, 1 skipped in 3.14s
```
```text
All checks passed!
```
```text
6 files already formatted
```
```text
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Python 3.12.13, `uv`, no live Copilot credentials.
- Exact command / steps: run the focused auth, wrap, proxy-env, and
proxy-reuse regression suite after seeding an expired launch token plus
reusable OAuth refresh material, then rerun lint and format checks on
the touched files.
- Observed result: base reproduces the bug because the explicit-token
branch never refreshes, accepts non-finite expiry inputs, and
shared-proxy reuse can keep the wrong per-session refresh seed alive;
head refreshes through the reusable OAuth token, rejects non-finite
seeded expiry, replaces the wrapper-managed seeded bearer instead of
blindly passing it through, preserves the valid-seed fast path and the
fixed override path when no refresh material exists, keeps the
configured API URL pinned across refresh, starts a dedicated local proxy
when the requested port already belongs to a shared or persistent proxy,
and keeps the reusable OAuth token confined to explicit proxy launch env
only. The focused suite passed with `195 passed, 1 skipped in 3.14s`.
- Not tested: live business-subscription session past the provider's
real token-expiry window.
## 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 stays provider-local. The fix remains inside
`headroom/copilot_auth.py` and the Copilot wrap handoff in
`headroom/cli/wrap.py`; it does not add generic 401 retry logic to
provider-neutral proxy layers.
- The line that disables token exchange for the Copilot CLI child env is
unchanged because it never reached the proxy env and was not the root
cause.
- Subscription-seeded sessions now get a dedicated local proxy whenever
the requested port already belongs to a shared or persistent proxy;
existing shared proxies are left alone to avoid disrupting attached
wrappers.
- Live provider confirmation still needs maintainer or reporter
validation because that truth is owned by GitHub's real subscription
APIs, not by local stubs.
- Headroom's release pipeline generates changelog entries from
conventional commits, so `CHANGELOG.md` is intentionally untouched.
2026-07-14 11:52:43 -04:00
|
|
|
def test_ensure_proxy_starts_isolated_ephemeral_proxy_for_copilot_subscription_seed(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: port == 8787)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_find_available_port",
|
|
|
|
|
lambda start_port, **kw: calls.append(("find_port", start_port)) or 8788,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("subscription-seeded session should not restart the shared proxy")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
copilot_api_token="tid-session-token",
|
|
|
|
|
copilot_refresh_oauth_token="gho-refresh",
|
|
|
|
|
copilot_api_token_expires_at=456.5,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8788
|
|
|
|
|
assert calls[0] == ("find_port", 8788)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][1][0] == 8788
|
|
|
|
|
assert calls[1][2]["copilot_api_token"] == "tid-session-token"
|
|
|
|
|
assert calls[1][2]["copilot_refresh_oauth_token"] == "gho-refresh"
|
|
|
|
|
assert calls[1][2]["copilot_api_token_expires_at"] == 456.5
|
|
|
|
|
|
|
|
|
|
|
feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445)
## Description
`headroom wrap opencode` currently routes `headroom/*` models only to
ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot
subscription cannot point those `headroom/*` requests at the Copilot
seat while keeping Headroom compression and stats, even though Headroom
already has the validated subscription resolver, the proxy seed path,
and the OpenCode provider route needed to do it.
This PR adds `--copilot-subscription` to the OpenCode wrap command. It
reuses the existing Copilot subscription token resolver, passes the
validated endpoint and token seed into the existing proxy startup path,
rejects unsupported runtime modes, and treats any non-empty Copilot API
token as a private session seed so token-only sessions do not reuse a
shared proxy. The generated OpenCode provider still targets the local
proxy, and subscription secrets stay out of OpenCode config,
environment, and terminal output.
Closes #2441
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] 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
- Add `headroom wrap opencode --copilot-subscription` in
`headroom/cli/wrap.py`.
- Reuse the existing validated Copilot subscription resolver through one
small required-resolution helper shared with the dedicated Copilot
wrapper.
- Pass the resolved endpoint and token seed into `_ensure_proxy()` as
`openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`,
and `copilot_api_token_expires_at`.
- Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`,
and translated backends before proxy or OpenCode launch.
- Validate subscription mode before snapshotting OpenCode config, so
rejected invocations don't create stale backups.
- Scrub inherited Copilot proxy seed variables from the OpenCode child
environment.
- Treat any non-empty Copilot API token as a private session seed so
token-only sessions do not reuse shared or persistent proxies.
- Add focused OpenCode and persistent-proxy coverage for seed handoff,
guard failures, direct-token isolation, secret non-disclosure, and
unchanged non-subscription behavior.
- Leave `CHANGELOG.md` untouched because Headroom generates changelog
entries from conventional commits.
## Testing
- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_wrap_opencode.py
tests/test_cli/test_wrap_persistent.py
tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`)
- [x] Linting passes (`uv run ruff check headroom/cli/wrap.py
tests/test_cli/test_wrap_opencode.py
tests/test_cli/test_wrap_persistent.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure.
The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass.
```
## Real Behavior Proof
- Environment: Windows, `uv` development environment, local CLI tests,
no live Copilot seat on this host
- Exact command / steps: run the focused OpenCode and persistent-proxy
tests with a mocked `CopilotSubscriptionTokenResolution`, then capture
the proof rows for seed handoff, direct-token isolation, guards, and
secret non-disclosure
- Observed result: Targeted subscription and proxy-seed tests pass,
including OpenCode-only resolver-input env scrubbing and private-proxy
teardown on config-injection failure; the full focused command passed
with `106 passed in 136.65s`; Ruff check and format check pass.
- Not tested: live Copilot subscription seat run on this host
## 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
- [ ] 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 feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Feature approval comes from the open `enhancement` label on
https://github.com/headroomlabs-ai/headroom/issues/2441.
- Keep the final live-backend claim behind manual owner proof. Local CLI
tests can prove config, guard, secret, and proxy-seed behavior, but they
cannot prove a real Copilot seat on this host.
- `CHANGELOG.md` remains untouched because Headroom's release pipeline
generates changelog entries from conventional commits.
2026-07-20 14:02:14 -04:00
|
|
|
def test_ensure_proxy_isolates_copilot_subscription_seed_with_api_token_only(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: port == 8787)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_find_available_port",
|
|
|
|
|
lambda start_port, **kw: calls.append(("find_port", start_port)) or 8788,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
copilot_api_token="direct-token-only",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8788
|
|
|
|
|
assert calls[0] == ("find_port", 8788)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][2]["copilot_api_token"] == "direct-token-only"
|
|
|
|
|
|
|
|
|
|
|
fix(copilot): refresh wrapped subscription tokens (#2156) (#2182)
## Description
`headroom wrap copilot --subscription` currently validates a Copilot
subscription credential once at launch, exchanges it once, and then pins
that short-lived API token into the proxy as an explicit override. When
the token expires, long-lived wrapped sessions start returning
`transient_auth_error` and then a final HTTP 401 until the entire
wrapped session is restarted.
This PR keeps the validated launch token for first-request determinism,
carries reusable OAuth refresh material into the proxy, and refreshes
inside `CopilotTokenProvider` when the seeded token is expired. The
explicit `GITHUB_COPILOT_API_TOKEN` override path stays unchanged when
no reusable OAuth token exists. The configured `GITHUB_COPILOT_API_URL`
also stays pinned across refresh, matching the current wrap contract.
Closes #2156.
## 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
- Extended the Copilot subscription token resolution path to carry
reusable OAuth refresh material and expiry metadata instead of
discarding it after the wrap-time exchange.
- Reworked `CopilotTokenProvider.get_api_token()` so it seeds the
wrapper-validated launch token once for the first request, then
refreshes through the existing exchange path when that token is expired
and reusable OAuth material exists.
- Rejected non-finite seeded expiry values such as `inf`, so malformed
`GITHUB_COPILOT_API_TOKEN_EXPIRES_AT` inputs cannot pin a stale launch
token forever.
- Preserved the explicit `GITHUB_COPILOT_API_TOKEN` override path when
no reusable OAuth token exists, so non-refreshable overrides keep
today's fixed behavior.
- Kept explicitly configured `GITHUB_COPILOT_API_URL` values pinned
across refresh rather than adopting a refreshed payload's host.
- Replaced wrapper-managed seeded `tid_` bearer passthrough with the
refresh-aware provider path, so the wrapped CLI no longer bypasses
expiry refresh just because it keeps sending the launch token back to
the proxy.
- Started a dedicated local proxy instance whenever a
subscription-seeded session targets a shared or persistent proxy port,
so per-session refresh material is not silently dropped on healthy-proxy
reuse or cross-wired between concurrent sessions.
- Scrubbed inherited Copilot refresh-seed environment variables from
both the Copilot child env and the proxy subprocess env before
re-injecting the explicit launch-time values.
- Added focused auth, wrap, proxy-env, and proxy-reuse regression
coverage for expiry refresh, non-finite expiry rejection, session-local
proxy isolation, explicit-override preservation, exchange-flag
independence, configured API URL pinning, and secret handling.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py -q`)
- [x] Linting passes (`uv run ruff check headroom/copilot_auth.py
headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/copilot_auth.py headroom/cli/wrap.py tests/test_copilot_auth.py
tests/test_cli/test_wrap_copilot.py tests/test_cli_proxy_env.py
tests/test_cli/test_wrap_persistent.py`)
- [x] Type checking passes (`uv run mypy headroom/copilot_auth.py`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
195 passed, 1 skipped in 3.14s
```
```text
All checks passed!
```
```text
6 files already formatted
```
```text
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Python 3.12.13, `uv`, no live Copilot credentials.
- Exact command / steps: run the focused auth, wrap, proxy-env, and
proxy-reuse regression suite after seeding an expired launch token plus
reusable OAuth refresh material, then rerun lint and format checks on
the touched files.
- Observed result: base reproduces the bug because the explicit-token
branch never refreshes, accepts non-finite expiry inputs, and
shared-proxy reuse can keep the wrong per-session refresh seed alive;
head refreshes through the reusable OAuth token, rejects non-finite
seeded expiry, replaces the wrapper-managed seeded bearer instead of
blindly passing it through, preserves the valid-seed fast path and the
fixed override path when no refresh material exists, keeps the
configured API URL pinned across refresh, starts a dedicated local proxy
when the requested port already belongs to a shared or persistent proxy,
and keeps the reusable OAuth token confined to explicit proxy launch env
only. The focused suite passed with `195 passed, 1 skipped in 3.14s`.
- Not tested: live business-subscription session past the provider's
real token-expiry window.
## 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 stays provider-local. The fix remains inside
`headroom/copilot_auth.py` and the Copilot wrap handoff in
`headroom/cli/wrap.py`; it does not add generic 401 retry logic to
provider-neutral proxy layers.
- The line that disables token exchange for the Copilot CLI child env is
unchanged because it never reached the proxy env and was not the root
cause.
- Subscription-seeded sessions now get a dedicated local proxy whenever
the requested port already belongs to a shared or persistent proxy;
existing shared proxies are left alone to avoid disrupting attached
wrappers.
- Live provider confirmation still needs maintainer or reporter
validation because that truth is owned by GitHub's real subscription
APIs, not by local stubs.
- Headroom's release pipeline generates changelog entries from
conventional commits, so `CHANGELOG.md` is intentionally untouched.
2026-07-14 11:52:43 -04:00
|
|
|
def test_ensure_proxy_starts_isolated_ephemeral_proxy_when_subscription_seed_targets_persistent_port(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"headroom.install.health.probe_ready",
|
|
|
|
|
lambda url: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("subscription-seeded session should skip persistent probing")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("subscription-seeded session should not restart the persistent proxy")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_find_available_port",
|
|
|
|
|
lambda start_port, **kw: calls.append(("find_port", start_port)) or 8788,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
|
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
copilot_api_token="tid-session-token",
|
|
|
|
|
copilot_refresh_oauth_token="gho-refresh",
|
|
|
|
|
copilot_api_token_expires_at=456.5,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8788
|
|
|
|
|
assert calls[0] == ("find_port", 8788)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][1][0] == 8788
|
|
|
|
|
assert calls[1][2]["copilot_api_token"] == "tid-session-token"
|
|
|
|
|
assert calls[1][2]["copilot_refresh_oauth_token"] == "gho-refresh"
|
|
|
|
|
assert calls[1][2]["copilot_api_token_expires_at"] == 456.5
|
|
|
|
|
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
def test_ensure_proxy_reuses_agent_proxy_without_savings_profile(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.delenv("HEADROOM_SAVINGS_PROFILE", raising=False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("default agent proxy should not restart for savings profile")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_for_explicit_agent_savings_profile(monkeypatch) -> None:
|
2026-06-12 02:58:06 +03:00
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
monkeypatch.setenv("HEADROOM_SAVINGS_PROFILE", "agent-90")
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
2026-06-12 02:58:06 +03:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-12 02:58:06 +03:00
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_reuses_agent_proxy_with_savings_profile(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"savings_profile": "agent-90",
|
|
|
|
|
"target_ratio": 0.10,
|
|
|
|
|
"compress_user_messages": True,
|
|
|
|
|
"compress_system_messages": True,
|
|
|
|
|
"protect_recent": 2,
|
|
|
|
|
"protect_analysis_context": True,
|
|
|
|
|
"min_tokens_to_crush": 120,
|
|
|
|
|
"max_items_after_crush": 8,
|
|
|
|
|
"smart_crusher_with_compaction": False,
|
|
|
|
|
"accuracy_guard": "strict",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
fix(wrap): keep agent savings opt-in (#1294)
## Description
Fixes a regression from #830 where `headroom wrap codex` / `claude` /
`cursor` treated `agent-90` as required even when the user started a
normal proxy without `HEADROOM_SAVINGS_PROFILE`.
A plain `headroom proxy` on port 8787 followed by `headroom wrap codex`
currently reports `Proxy on port 8787 is missing: --savings-profile` and
tries to restart the already-running proxy. The `agent-90` profile was
documented as opt-in, so wrap should only require or inject it when
`HEADROOM_SAVINGS_PROFILE` is explicitly set.
Closes #1293
## 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
- Stop defaulting agent wrappers to `agent-90` when
`HEADROOM_SAVINGS_PROFILE` is unset.
- Stop reporting agent-savings config mismatches unless an agent savings
profile was explicitly requested.
- Add regression tests for default wrap startup, explicit profile
forwarding, and reuse/restart behavior around existing proxies.
- Fix repo-wide pre-commit issues found during amend: Windows-safe
`fcntl` typing, an optional env typing issue, OpenCode JSON parser
return typing, and ruff import/format drift.
## 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
> maturin develop -m crates/headroom-py/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.41s
Built wheel for abi3 Python >= 3.10
Installed headroom-ai-0.27.0
> C:\git\headroom\.venv\Scripts\python.exe -c "from headroom._core import hello; print(hello())"
headroom-core
> ruff check .
All checks passed!
> ruff format --check .
913 files already formatted
> C:\git\headroom\.venv\Scripts\python.exe -m mypy headroom
Success: no issues found in 388 source files
> C:\git\headroom\.venv\Scripts\python.exe -m pytest tests/test_agent_savings.py tests/test_cli/test_wrap_persistent.py tests/test_proxy_healthchecks.py tests/test_transforms/test_content_router.py -q
collected 112 items
112 passed, 1 warning in 16.04s
> git diff --check
# no output
> git commit --amend --no-edit
Sync plugin versions.....................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
```
## Real Behavior Proof
- Environment: Windows dev checkout, Python 3.13.3 via
`C:\git\headroom\.venv\Scripts\python.exe`, Rust extension built with
`maturin develop -m crates/headroom-py/Cargo.toml`.
- Exact command / steps: reproduced the code path from #830 by
exercising `_ensure_proxy(8787, False, agent_type="codex")` with a
running proxy health payload that has no `savings_profile`, and by
exercising `_start_proxy(8787, agent_type="codex")` with
`HEADROOM_SAVINGS_PROFILE` unset and set.
- Observed result: without `HEADROOM_SAVINGS_PROFILE`, wrap reuses the
running proxy and `_start_proxy` does not inject
`HEADROOM_SAVINGS_PROFILE` or `HEADROOM_TARGET_RATIO`; with
`HEADROOM_SAVINGS_PROFILE=agent-90`, wrap still requires/forwards the
profile and restarts an incompatible proxy.
- Not tested: full end-to-end CLI launch against a live Codex binary.
The focused proxy/wrap tests cover the failing restart/config decision
directly.
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The pytest run emits an existing Windows `cp1252` background-thread
warning while reading subprocess output; the tests still pass.
No documentation or changelog update is included because this restores
the already-documented opt-in behavior for `agent-90`.
2026-06-22 19:06:04 -05:00
|
|
|
monkeypatch.setenv("HEADROOM_SAVINGS_PROFILE", "agent-90")
|
2026-06-12 02:58:06 +03:00
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("configured proxy should not restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
2026-06-12 02:58:06 +03:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-12 02:58:06 +03:00
|
|
|
|
|
|
|
|
|
2026-05-09 15:58:27 -07:00
|
|
|
def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -> None:
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1",
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 2, "active_relay_tasks": 2}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("active proxy should not be killed")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-05-09 15:58:27 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-11 17:42:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_defers_version_restart_when_http_wrapper_attached(monkeypatch) -> None:
|
|
|
|
|
"""A stale-version proxy is NOT restarted while a marker-tracked HTTP
|
|
|
|
|
wrapper is attached, even though the WebSocket session count is zero."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": "0.0.1", # stale → version restart wanted
|
|
|
|
|
# No WebSocket relay sessions — the gap that let the old code kill it.
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Another HTTP wrapper (PID 999) is attached per the marker registry.
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached proxy must not be killed for a version restart")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy must not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
2026-06-11 17:42:43 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-11 17:42:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_defers_flag_restart_when_other_wrapper_attached(monkeypatch) -> None:
|
|
|
|
|
"""Requesting --memory must not restart the proxy out from under another
|
|
|
|
|
attached wrapper; reuse the running proxy as-is instead."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION, # same version → no version restart
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
# Running proxy lacks `memory`; this session asks for it.
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [999])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("attached proxy must not be killed to add flags")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("replacement proxy must not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
2026-06-11 17:42:43 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-11 17:42:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_for_flags_when_no_other_wrapper(monkeypatch) -> None:
|
|
|
|
|
"""Control: with no other wrapper attached, a missing-flag restart still
|
|
|
|
|
happens — the guard must not block the single-client upgrade path."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_live_proxy_clients", lambda *a, **kw: [])
|
feat: headroom wrap opencode / unwrap opencode CLI (#1105)
## Summary
This PR implements transparent `headroom wrap opencode` support without
asking users to edit OpenCode provider URLs, choose an extra CLI flag,
or maintain a static provider list.
The wrapper now lives at the runtime transport boundary: OpenCode keeps
its user/provider config, while Headroom intercepts outbound provider
traffic in-process and routes it through the local Headroom proxy.
## What changed
### Transparent OpenCode wrapping
- `headroom wrap opencode` injects the `headroom-opencode` plugin
through `OPENCODE_CONFIG_CONTENT`.
- Existing OpenCode provider URLs are preserved. We do not rewrite user
config URLs to point at Headroom.
- Existing `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` env vars are
preserved.
- Local OpenCode traffic, localhost traffic, and Headroom proxy traffic
bypass the shim to avoid loops.
### Runtime transport interception
- Added an OpenCode plugin transport shim that wraps:
- `globalThis.fetch`
- `http.request` / `http.get`
- `https.request` / `https.get`
- External provider calls are routed to the local Headroom proxy.
- The original upstream origin is passed through `x-headroom-base-url`,
so the proxy can forward to the real provider without changing OpenCode
config.
- External `http2.connect` is blocked loudly instead of allowing direct
provider traffic to leak outside Headroom.
### Live provider additions
Provider coverage is no longer based on a static config scan. Because
routing happens at outbound request time, providers added mid-session
are routed through Headroom automatically as long as they use the
covered Node transport paths.
### Subagent and child-process coverage
- The parent OpenCode plugin sets a packaged Node preload shim through
`NODE_OPTIONS=--import=.../hook-shim/handler.js`.
- The transport shim patches `child_process.spawn`, `exec`, `execFile`,
and `fork` so child Node processes receive the Headroom preload even
when OpenCode passes a custom `env`.
- The child-process shim fails closed if it loads without
`HEADROOM_OPENCODE_TRANSPORT_PROXY_URL`.
- This closes the subagent leak path where a child Node process could
otherwise start without Headroom transport interception.
## Why this goes beyond PR #1089
PR #1089 improves OpenCode provider registration, but it still focuses
on provider config shape. This PR moves the enforcement boundary to
runtime transport interception.
This PR goes further because:
- No provider URL rewriting is required.
- New providers added mid-session are covered automatically.
- Subagents and child Node processes inherit the Headroom transport
shim.
- Direct external HTTP/2 paths fail loudly instead of leaking.
- The wrap remains transparent to the user's OpenCode provider config.
- The wrapper is fail-closed for unsupported child-process preload
state.
## Additional robustness fixes
While validating the change in Docker, the full Python suite exposed
unrelated Linux/container robustness issues. These are fixed in this PR
so the suite is green:
- Binary cache handling now treats cache paths under a non-writable
existing parent as unavailable, including when tests run as root in
Docker.
- `release_version.py` honors `MANUAL_VER` before git calls so direct
script execution works outside a `.git` checkout.
- Test logger isolation now resets relevant Headroom child loggers so
proxy logging setup cannot poison later `caplog` tests.
- The scanner missing-path test now uses a guaranteed missing `tmp_path`
child instead of relying on `/nonexistent/path`.
## Validation
All implementation validation was run inside Docker.
- Full Python suite from a fresh Docker copy: `6605 passed, 523
skipped`.
- Ruff on changed Python/OpenCode paths: passed.
- OpenCode plugin typecheck: passed.
- OpenCode plugin tests: `9 passed`.
- OpenCode plugin build: passed.
- Hook shim preload smoke test: passed.
## Notes
This PR intentionally does not add a CLI option. `headroom wrap
opencode` means full wrap. Either Headroom wraps OpenCode transparently,
or the path fails loudly instead of silently leaking provider traffic.
---------
Co-authored-by: Rudimar Ronsoni <6081613+rudironsoni@users.noreply.github.com>
2026-06-22 18:07:12 +02:00
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
2026-06-11 17:42:43 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
2026-06-11 17:42:43 -07:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-11 17:42:43 -07:00
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
2026-06-29 00:26:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should restart when requested features differ from running config."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Persistent proxy is running, so _check_proxy returns True
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request openai_api_url that differs from running config (None)
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-29 00:26:38 +02:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
# Proxy should be killed and restarted due to openai_api_url mismatch
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
|
|
|
|
assert calls[1][2]["openai_api_url"] == "https://api.githubcopilot.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should restart when memory is requested but not enabled."""
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
# Persistent proxy is running, so _check_proxy returns True
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_kill_proxy_by_pid",
|
|
|
|
|
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request memory that differs from running config (False)
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
2026-06-29 00:26:38 +02:00
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
# Proxy should be killed and restarted due to memory mismatch
|
|
|
|
|
assert calls[0] == ("kill", 12345, 8787)
|
|
|
|
|
assert calls[1][0] == "start"
|
fix: check feature configuration before reusing persistent deployments (#1330)
## Description
A persistent proxy started for one use case (e.g. `--backend anthropic`)
would be silently reused for another (e.g. `--subscription
--provider-type openai`) causing 401 auth failures because
`_ensure_proxy()` only checked health + version, skipping the feature
configuration check (memory, openai_api_url, learn, code_graph).
Closes #N/A
## 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 feature configuration check in the persistent deployment path of
`_ensure_proxy()` in `headroom/cli/wrap.py:1726-1752`. When features
mismatch, the code now falls through to the non-persistent path which
handles proxy restart with upgraded config.
- Added three new tests in `tests/test_cli/test_wrap_persistent.py`:
-
`test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch`
— verifies proxy restart when openai_api_url differs
- `test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch`
— verifies proxy restart when memory is requested but not enabled
- `test_ensure_proxy_reuses_persistent_deployment_when_features_match` —
verifies proxy reuse when all features match
- Updated `CHANGELOG.md` with fix description
## 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
$ ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_persistent.py
All checks passed!
$ ruff format --check headroom/cli/wrap.py tests/test_cli/test_wrap_persistent.py
2 files already formatted
$ python -c "
from tests.test_cli.test_wrap_persistent import *
import pytest
test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch(pytest.MonkeyPatch())
print('Test 1 passed: feature mismatch restarts proxy')
test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch(pytest.MonkeyPatch())
print('Test 2 passed: memory mismatch restarts proxy')
test_ensure_proxy_reuses_persistent_deployment_when_features_match(pytest.MonkeyPatch())
print('Test 3 passed: matching features reuse proxy')
print('All tests passed!')
"
Test 1 passed: feature mismatch restarts proxy
Test 2 passed: memory mismatch restarts proxy
Test 3 passed: matching features reuse proxy
All tests passed!
$ .venv/bin/mypy headroom
headroom/proxy/server.py:1230: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked]
headroom/proxy/server.py:1301: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked]
headroom/proxy/server.py:1305: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked]
Success: no issues found in 397 source files
```
## Real Behavior Proof
- Environment: Linux (WSL2) Ubuntu 24.04, Python 3.12, persistent
deployment via `headroom install agent run --profile default` with
`--backend anthropic`
- Exact command / steps:
1. Started persistent deployment: `headroom install agent run --profile
default`
2. Ran copilot wrapper: `headroom wrap copilot --subscription
--provider-type openai --wire-api responses --memory -- --model
gpt-5.4-mini`
- Observed result:
- Before fix: Proxy forwarded to `api.openai.com` instead of
`api.githubcopilot.com`, causing 401 auth errors
- After fix: Proxy correctly forwards to `api.githubcopilot.com` and
copilot works as expected
- Not tested: macOS, Windows, enterprise/data-residency accounts, other
wrapped tools (claude, codex, aider)
## 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
## Screenshots (if applicable)
N/A
## Additional Notes
The fix is minimal and targeted. It only adds a feature configuration
check in the persistent deployment path without changing any other
behavior. The non-persistent proxy path already had this check; this PR
brings the same logic to persistent deployments.
Co-authored-by: carlosduplar <[email protected]>
2026-07-14 20:10:31 +02:00
|
|
|
assert calls[1][2]["memory"] is True
|
2026-06-29 00:26:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_recovered_persistent_for_openai_api_url_mismatch(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-29 00:26:38 +02:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.business.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
assert calls == [("restart", "default", 8787)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_restarts_recovered_persistent_when_config_unavailable(monkeypatch) -> None:
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: {"version": "x"})
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_config", lambda port: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-29 00:26:38 +02:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.business.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
assert calls == [("restart", "default", 8787)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_reuses_persistent_deployment_when_features_match(monkeypatch) -> None:
|
|
|
|
|
"""Persistent deployment should be reused when all requested features match."""
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": 12345,
|
|
|
|
|
"memory": True,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": "https://api.githubcopilot.com",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("should not restart when features match")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("should not start ephemeral proxy when features match")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Request same features as running config
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-29 00:26:38 +02:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
memory=True,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_proxy_recovered_persistent_deployment_checks_feature_mismatch(monkeypatch) -> None:
|
|
|
|
|
"""Recovered persistent deployments must still restart on feature mismatch.
|
|
|
|
|
|
|
|
|
|
Regression guard for the recover path: when wrap requests a different
|
|
|
|
|
openai_api_url (Copilot subscription), do not early-return right after
|
|
|
|
|
recover; run the shared mismatch checks and restart if needed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
calls: list[object] = []
|
|
|
|
|
health = {
|
|
|
|
|
"version": wrap_cli._HEADROOM_VERSION,
|
|
|
|
|
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
|
|
|
|
"config": {
|
|
|
|
|
"pid": "12345",
|
|
|
|
|
"memory": False,
|
|
|
|
|
"learn": False,
|
|
|
|
|
"code_graph": False,
|
|
|
|
|
"openai_api_url": None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest())
|
|
|
|
|
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True)
|
|
|
|
|
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_restart_persistent_proxy",
|
|
|
|
|
lambda manifest, port: calls.append(("restart", manifest.profile, port)) or True,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
wrap_cli,
|
|
|
|
|
"_start_proxy",
|
|
|
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
|
|
|
AssertionError("ephemeral proxy should not start")
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
proc, actual_port = wrap_cli._ensure_proxy(
|
2026-06-29 00:26:38 +02:00
|
|
|
8787,
|
|
|
|
|
False,
|
|
|
|
|
openai_api_url="https://api.githubcopilot.com",
|
|
|
|
|
)
|
|
|
|
|
|
fix(wrap): replace stale-proxy detection with Vite-style port fallback (#1406)
## Description
Replace the 180-line process-killing approach with a 15-line Vite-style
socket.bind probe: if port is busy (EADDRINUSE or EACCES), try the next
available port.
Pure stdlib -- no /proc, no lsof, no subprocess -- works identically on
Linux/macOS/Windows.
## Problem
When `headroom wrap <agent>` is killed without proper cleanup (window
close, SSH timeout, crash), the background proxy becomes orphaned and
holds the port. The next `headroom wrap` on the same port would wait
30-45 seconds then fail with a confusing error.
This PR takes a simpler, safer approach: find the next available port.
No process detection, no killing.
### Related issues
- **#589** (Port 8787 reserved by Windows) -- partially addressed:
EACCES is now skipped together with EADDRINUSE
- **#804** (Shared proxy killed by exiting session) -- already fixed
upstream via `_live_proxy_clients` marker files; this PR doesn't touch
that code
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [x] Code refactoring (no functional changes)
## Changes Made
- `headroom/cli/wrap.py`: Add `_find_available_port()` -- socket.bind
loop that skips EADDRINUSE (busy) and EACCES (reserved/privileged)
ports, returns first available port in range. Replace `_ensure_proxy`
port-bind check with auto-fallback call to `_find_available_port`.
Remove `_ensure_port_free()` call from `_start_proxy()`. Remove 8 dead
functions: `_find_process_on_port`, `_linux_find_process_on_port`,
`_resolve_inode_to_pid`, `_is_headroom_proxy`, `_read_process_cmdline`,
`_kill_process`, `_ensure_port_free`, `_format_unbindable_port_error`.
- `tests/test_cli/test_wrap_helpers.py`: Remove 14 old
`TestEnsurePortFree` tests (mocked /proc parsing, process killing). Add
6 new `TestFindAvailablePort` tests covering: port free, first port
busy, multiple busy, EACCES skipped, unexpected error propagated, range
exhausted.
- `tests/test_cli/test_wrap_persistent.py`: Adapt persistence tests for
`_find_available_port` mock. Rewrite unbindable-port test to use new
error path.
Zero new dependencies. Zero changes to core proxy server, MCP,
compression, or providers.
## Testing
- [x] Unit tests pass (`python -m pytest tests/test_cli/ -v`)
- [x] New tests added for new functionality
### Test Output
```
> python -m pytest tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v --no-header
============================= 6 passed ==============================
test_port_free_returns_same PASSED
test_port_busy_finds_next PASSED
test_multiple_busy_ports PASSED
test_propagates_unexpected_error PASSED
test_propagates_eaddrinuse_with_eacces PASSED
test_exhausts_range PASSED
> python -m pytest tests/test_cli/ -q
============================= 445 passed in 8.40s ==============================
```
## Real Behavior Proof
- Environment: Ubuntu 24.04 x86_64, Python 3.12.3
- Exact command / steps: Ran `python -m pytest
tests/test_cli/test_wrap_helpers.py::TestFindAvailablePort -v` -- 6/6
pass for port fallback. Ran full test suite `python -m pytest
tests/test_cli/ -q` -- 445/445 pass.
- Observed result: `_find_available_port(8787)` returns 8787 when free,
8788 when 8787 is busy. EACCES skipped same as EADDRINUSE. Non-retryable
errors (EADDRNOTAVAIL) propagate immediately.
- Not tested: Windows EACCES fallback (no Windows CI runner). macOS port
fallback (no macOS runner). Code path is identical across platforms
(stdlib socket only).
## 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] 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
2026-07-08 01:10:52 +08:00
|
|
|
assert proc is None
|
|
|
|
|
assert actual_port == 8787
|
2026-06-29 00:26:38 +02:00
|
|
|
assert calls == [("restart", "default", 8787)]
|