mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04: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
This commit is contained in:
parent
6fb5f3bc3d
commit
b4205c68e6
6 changed files with 518 additions and 168 deletions
|
|
@ -15,6 +15,7 @@ Usage:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
|
|
@ -349,20 +350,27 @@ def _port_bind_error(port: int) -> OSError | None:
|
|||
s.bind(("127.0.0.1", port))
|
||||
except OSError as exc:
|
||||
return exc
|
||||
except OverflowError:
|
||||
return OSError(errno.EADDRNOTAVAIL, f"Port {port} out of range (0-65535)")
|
||||
return None
|
||||
|
||||
|
||||
def _format_unbindable_port_error(port: int, error: OSError, agent_type: str) -> str:
|
||||
"""Build an actionable message for ports that fail before uvicorn can bind."""
|
||||
command = "headroom proxy"
|
||||
if agent_type != "unknown":
|
||||
command = f"headroom wrap {agent_type}"
|
||||
suggested_port = port + 1
|
||||
return (
|
||||
f"Port {port} is unavailable on 127.0.0.1 before the proxy can start: {error}. "
|
||||
"On Windows this can happen when the port is in an excluded or reserved range. "
|
||||
f"Rerun with a different port, for example `{command} --port {suggested_port}`."
|
||||
)
|
||||
def _find_available_port(start_port: int, max_attempts: int = 100) -> int:
|
||||
"""Find first available port >= start_port via socket.bind probe.
|
||||
|
||||
Skips ports with EADDRINUSE (busy) and EACCES (reserved on Windows,
|
||||
privileged on Linux) — both indicate the port can't be bound here.
|
||||
Other OS errors (EADDRNOTAVAIL) propagate immediately.
|
||||
Raises RuntimeError when no port is found in range.
|
||||
"""
|
||||
end_port = min(start_port + max_attempts, 65536)
|
||||
for port in range(start_port, end_port):
|
||||
error = _port_bind_error(port)
|
||||
if error is None:
|
||||
return port
|
||||
if error.errno not in (errno.EADDRINUSE, errno.EACCES):
|
||||
raise error
|
||||
raise RuntimeError(f"No available port found in range {start_port}-{end_port - 1}")
|
||||
|
||||
|
||||
def _get_log_path() -> Path:
|
||||
|
|
@ -400,7 +408,11 @@ def _start_proxy(
|
|||
Stdout and stderr are written to a dedicated sibling file, usually
|
||||
`~/.headroom/logs/proxy-stdio.log`, to avoid pipe deadlock risk without
|
||||
competing with the rotating `proxy.log` runtime log.
|
||||
|
||||
The caller is responsible for ensuring *port* is available
|
||||
(see ``_find_available_port``).
|
||||
"""
|
||||
|
||||
cmd = [sys.executable, "-m", "headroom.cli", "proxy", "--port", str(port)]
|
||||
|
||||
# Forward HEADROOM_MODE env var so the proxy respects the user's mode choice
|
||||
|
|
@ -2025,7 +2037,7 @@ def _run_proxy_only_watcher(
|
|||
learn: bool,
|
||||
memory: bool,
|
||||
agent_type: str,
|
||||
print_setup_lines: Callable[[], None],
|
||||
print_setup_lines: Callable[[int], None],
|
||||
) -> None:
|
||||
"""Shared scaffolding for proxy-only wrap subcommands (no child binary launch).
|
||||
|
||||
|
|
@ -2039,19 +2051,24 @@ def _run_proxy_only_watcher(
|
|||
through here. ``_launch_tool`` owns the proxy lifecycle on that path.
|
||||
"""
|
||||
proxy_holder: list[subprocess.Popen | None] = [None]
|
||||
cleanup = _make_cleanup(proxy_holder, port)
|
||||
_register_proxy_client(port)
|
||||
port_holder: list[int] = [port]
|
||||
cleanup = _make_cleanup(proxy_holder, port_holder)
|
||||
signal.signal(signal.SIGINT, cleanup)
|
||||
signal.signal(signal.SIGTERM, cleanup)
|
||||
|
||||
try:
|
||||
_print_wrap_banner(agent_label)
|
||||
proxy_holder[0] = _ensure_proxy(
|
||||
_register_proxy_client(port)
|
||||
proxy_holder[0], actual_port = _ensure_proxy(
|
||||
port, no_proxy, learn=learn, memory=memory, agent_type=agent_type
|
||||
)
|
||||
_push_runtime_env(port, no_proxy)
|
||||
if actual_port != port:
|
||||
_unregister_proxy_client(port)
|
||||
_register_proxy_client(actual_port)
|
||||
port_holder[0] = actual_port
|
||||
_push_runtime_env(actual_port, no_proxy)
|
||||
click.echo()
|
||||
print_setup_lines()
|
||||
print_setup_lines(actual_port)
|
||||
click.echo()
|
||||
click.echo(" Press Ctrl+C to stop the proxy.")
|
||||
click.echo()
|
||||
|
|
@ -2772,8 +2789,8 @@ def _ensure_proxy(
|
|||
vertex_api_url: str | None = None,
|
||||
clear_vertex_api_url: bool = False,
|
||||
copilot_api_token: str | None = None,
|
||||
) -> subprocess.Popen | None:
|
||||
"""Start or verify proxy. Returns process handle if we started it."""
|
||||
) -> tuple[subprocess.Popen | None, int]:
|
||||
"""Start or verify proxy. Returns (process_handle, actual_port)."""
|
||||
helpers = _live_wrap_module()
|
||||
# --no-proxy reuses an already-running proxy, so backend/region/provider
|
||||
# flags (which only apply when we start one) would be silently dropped.
|
||||
|
|
@ -2807,9 +2824,9 @@ def _ensure_proxy(
|
|||
f" Leaving it running because {detail} "
|
||||
"are still attached; it will be restarted when idle."
|
||||
)
|
||||
return None
|
||||
return None, port
|
||||
if helpers._restart_persistent_proxy(manifest, port):
|
||||
return None
|
||||
return None, port
|
||||
raise click.ClickException(
|
||||
f"Persistent deployment '{manifest.profile}' on port {port} "
|
||||
f"is running stale Headroom {running_version} and could not be restarted."
|
||||
|
|
@ -2839,7 +2856,7 @@ def _ensure_proxy(
|
|||
if not missing:
|
||||
click.echo(f" Proxy already running on port {port}")
|
||||
click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard")
|
||||
return None
|
||||
return None, port
|
||||
# Features mismatch or config unavailable — fall through to
|
||||
# the non-persistent path which handles proxy restart.
|
||||
else:
|
||||
|
|
@ -2850,9 +2867,9 @@ def _ensure_proxy(
|
|||
# restart logic can run. For plain recover-only calls,
|
||||
# preserve the historical fast return.
|
||||
if not any((memory, learn, code_graph, openai_api_url)):
|
||||
return None
|
||||
return None, port
|
||||
if not helpers._check_proxy(port):
|
||||
return None
|
||||
return None, port
|
||||
|
||||
# A freshly recovered persistent proxy may not expose
|
||||
# a full config payload yet. In feature-sensitive flows
|
||||
|
|
@ -2870,7 +2887,7 @@ def _ensure_proxy(
|
|||
"did not expose config; restarting with requested features..."
|
||||
)
|
||||
if helpers._restart_persistent_proxy(manifest, port):
|
||||
return None
|
||||
return None, port
|
||||
raise click.ClickException(
|
||||
f"Persistent deployment '{manifest.profile}' on port {port} "
|
||||
"could not be restarted after recovery."
|
||||
|
|
@ -2898,12 +2915,12 @@ def _ensure_proxy(
|
|||
f"{flags_str}; restarting..."
|
||||
)
|
||||
if helpers._restart_persistent_proxy(manifest, port):
|
||||
return None
|
||||
return None, port
|
||||
raise click.ClickException(
|
||||
f"Persistent deployment '{manifest.profile}' on port {port} "
|
||||
"could not be restarted with requested features."
|
||||
)
|
||||
return None
|
||||
return None, port
|
||||
elif helpers._check_proxy(port):
|
||||
raise click.ClickException(
|
||||
f"Persistent deployment '{manifest.profile}' on port {port} is not healthy."
|
||||
|
|
@ -2943,7 +2960,7 @@ def _ensure_proxy(
|
|||
f" Leaving it running because {detail} "
|
||||
"are still attached; it will be restarted when idle."
|
||||
)
|
||||
return None
|
||||
return None, port
|
||||
|
||||
click.echo(
|
||||
f" Proxy on port {port} is running Headroom {running_version}; "
|
||||
|
|
@ -3034,26 +3051,31 @@ def _ensure_proxy(
|
|||
f" Please stop the proxy on port {port} manually "
|
||||
f"and rerun with {flags_str}."
|
||||
)
|
||||
return None
|
||||
return None, port
|
||||
|
||||
if not needs_restart:
|
||||
click.echo(f" Proxy already running on port {port}")
|
||||
click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard")
|
||||
return None
|
||||
return None, port
|
||||
|
||||
# Start (or restart) the proxy with the requested flags
|
||||
bind_error = helpers._port_bind_error(port)
|
||||
if bind_error is not None:
|
||||
raise click.ClickException(
|
||||
helpers._format_unbindable_port_error(port, bind_error, agent_type)
|
||||
)
|
||||
# Find an available port (port may be busy from a stale proxy).
|
||||
try:
|
||||
actual_port = helpers._find_available_port(port)
|
||||
except OSError as e:
|
||||
raise click.ClickException(f"Port {port} is unavailable: {e}") from e
|
||||
except RuntimeError as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
click.echo(f" Starting Headroom proxy on port {port}...")
|
||||
if actual_port != port:
|
||||
click.echo(f" Port {port} is in use, using port {actual_port} instead.")
|
||||
|
||||
click.echo(f" Starting Headroom proxy on port {actual_port}...")
|
||||
try:
|
||||
proc = cast(
|
||||
subprocess.Popen[Any],
|
||||
_live_wrap_module()._start_proxy(
|
||||
port,
|
||||
actual_port,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type=agent_type,
|
||||
|
|
@ -3068,9 +3090,9 @@ def _ensure_proxy(
|
|||
copilot_api_token=copilot_api_token,
|
||||
),
|
||||
)
|
||||
click.echo(f" Proxy ready on http://127.0.0.1:{port}")
|
||||
click.echo(f" Dashboard: http://127.0.0.1:{port}/dashboard")
|
||||
return proc
|
||||
click.echo(f" Proxy ready on http://127.0.0.1:{actual_port}")
|
||||
click.echo(f" Dashboard: http://127.0.0.1:{actual_port}/dashboard")
|
||||
return proc, actual_port
|
||||
except RuntimeError as e:
|
||||
click.echo(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
|
@ -3094,7 +3116,7 @@ def _ensure_proxy(
|
|||
"advertise the requested Vertex target. Requests may still go "
|
||||
"to the proxy's existing Vertex upstream."
|
||||
)
|
||||
return None
|
||||
return None, port
|
||||
|
||||
|
||||
def _client_marker_path(port: int) -> Path:
|
||||
|
|
@ -3222,24 +3244,24 @@ def _live_proxy_clients(port: int, *, exclude_self: bool = True) -> list[int]:
|
|||
return live
|
||||
|
||||
|
||||
def _make_cleanup(proxy_proc_holder: list, port: int = 8787) -> Any:
|
||||
def _make_cleanup(proxy_proc_holder: list, port: int | list[int] = 8787) -> Any:
|
||||
"""Create a cleanup function that terminates the proxy on exit.
|
||||
|
||||
Only kills the proxy when no other live headroom-wrapped clients remain,
|
||||
tracked via per-PID marker files in ``paths.proxy_clients_dir(port)``.
|
||||
|
||||
``port`` can be an ``int`` or a ``list[int]``. When a port fallback occurs
|
||||
(``_ensure_proxy`` ups the port because the requested one is busy), the
|
||||
caller can update ``port[0]`` in-place and the closure picks it up.
|
||||
"""
|
||||
|
||||
def _other_clients_exist() -> bool:
|
||||
# Reference-count from marker files, not argv scans. Wrapped clients
|
||||
# carry the proxy URL in ANTHROPIC_BASE_URL/OPENAI_BASE_URL (env, not
|
||||
# argv), so `pgrep -f` could never see them — and it matched unrelated
|
||||
# processes by substring. Markers are exact and OS-portable.
|
||||
return len(_live_proxy_clients(port, exclude_self=True)) > 0
|
||||
p = port[0] if isinstance(port, list) else port
|
||||
return len(_live_proxy_clients(p, exclude_self=True)) > 0
|
||||
|
||||
def cleanup(signum: int | None = None, frame: Any = None) -> None:
|
||||
# Drop our own marker first so the count reflects the post-exit state;
|
||||
# also covers the signal path, where the `finally` block may not run.
|
||||
_unregister_proxy_client(port)
|
||||
p = port[0] if isinstance(port, list) else port
|
||||
_unregister_proxy_client(p)
|
||||
proc = proxy_proc_holder[0] if proxy_proc_holder else None
|
||||
if proc and proc.poll() is None:
|
||||
if _other_clients_exist():
|
||||
|
|
@ -3281,8 +3303,8 @@ def _launch_tool(
|
|||
) -> None:
|
||||
"""Common logic: start proxy, launch tool, clean up."""
|
||||
proxy_holder: list[subprocess.Popen | None] = [None]
|
||||
cleanup = _make_cleanup(proxy_holder, port)
|
||||
_register_proxy_client(port)
|
||||
port_holder: list[int] = [port]
|
||||
cleanup = _make_cleanup(proxy_holder, port_holder)
|
||||
signal.signal(signal.SIGINT, _ignore_child_sigint)
|
||||
signal.signal(signal.SIGTERM, cleanup)
|
||||
|
||||
|
|
@ -3294,7 +3316,8 @@ def _launch_tool(
|
|||
click.echo(" ╚═══════════════════════════════════════════════╝")
|
||||
click.echo()
|
||||
|
||||
proxy_holder[0] = _ensure_proxy(
|
||||
_register_proxy_client(port)
|
||||
proxy_holder[0], actual_port = _ensure_proxy(
|
||||
port,
|
||||
no_proxy,
|
||||
learn=learn,
|
||||
|
|
@ -3307,7 +3330,16 @@ def _launch_tool(
|
|||
openai_api_url=openai_api_url,
|
||||
copilot_api_token=copilot_api_token,
|
||||
)
|
||||
_push_runtime_env(port, no_proxy)
|
||||
if actual_port != port:
|
||||
_unregister_proxy_client(port)
|
||||
_register_proxy_client(actual_port)
|
||||
port_holder[0] = actual_port
|
||||
_push_runtime_env(actual_port, no_proxy)
|
||||
|
||||
# If port fell back, update env URLs to point at the actual port
|
||||
if actual_port != port:
|
||||
for k, v in dict(env).items():
|
||||
env[k] = v.replace(f"127.0.0.1:{port}", f"127.0.0.1:{actual_port}")
|
||||
|
||||
if code_graph:
|
||||
_setup_code_graph(verbose=False)
|
||||
|
|
@ -3697,9 +3729,9 @@ def claude(
|
|||
proxy_holder: list[subprocess.Popen | None] = [None]
|
||||
_saved_base_url: list[str | None] = [None] # previous settings.json value for restore
|
||||
_settings_foundry: list[bool] = [False]
|
||||
port_holder: list[int] = [port]
|
||||
_settings_vertex: list[bool] = [False]
|
||||
cleanup = _make_cleanup(proxy_holder, port)
|
||||
_register_proxy_client(port)
|
||||
cleanup = _make_cleanup(proxy_holder, port_holder)
|
||||
signal.signal(signal.SIGINT, _ignore_child_sigint)
|
||||
signal.signal(signal.SIGTERM, cleanup)
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
|
|
@ -3779,7 +3811,8 @@ def claude(
|
|||
proxy_url = _claude_proxy_base_url(port)
|
||||
vertex_upstream = _vertex_target_api_url_from_claude_env(proxy_url) if use_vertex else None
|
||||
|
||||
proxy_holder[0] = _ensure_proxy(
|
||||
_register_proxy_client(port)
|
||||
proxy_holder[0], actual_port = _ensure_proxy(
|
||||
port,
|
||||
no_proxy,
|
||||
learn=learn,
|
||||
|
|
@ -3792,7 +3825,11 @@ def claude(
|
|||
vertex_api_url=vertex_upstream,
|
||||
clear_vertex_api_url=use_vertex and vertex_upstream is None,
|
||||
)
|
||||
_push_runtime_env(port, no_proxy)
|
||||
if actual_port != port:
|
||||
_unregister_proxy_client(port)
|
||||
_register_proxy_client(actual_port)
|
||||
port_holder[0] = actual_port
|
||||
_push_runtime_env(actual_port, no_proxy)
|
||||
|
||||
if not no_rtk:
|
||||
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
|
||||
|
|
@ -3807,7 +3844,7 @@ def claude(
|
|||
if not no_mcp:
|
||||
from headroom.mcp_registry import ClaudeRegistrar
|
||||
|
||||
_setup_headroom_mcp(ClaudeRegistrar(), port, verbose=verbose)
|
||||
_setup_headroom_mcp(ClaudeRegistrar(), actual_port, verbose=verbose)
|
||||
elif verbose:
|
||||
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
|
||||
|
||||
|
|
@ -3826,6 +3863,7 @@ def claude(
|
|||
if code_graph:
|
||||
_setup_code_graph(verbose=verbose)
|
||||
|
||||
proxy_url = _claude_proxy_base_url(actual_port)
|
||||
click.echo()
|
||||
click.echo(" Launching Claude Code (API routed through Headroom)...")
|
||||
if use_vertex:
|
||||
|
|
@ -4439,7 +4477,7 @@ def codex(
|
|||
_codex_config_file, _codex_backup_file = _codex_config_paths()
|
||||
_snapshot_codex_config_if_unwrapped(_codex_config_file, _codex_backup_file)
|
||||
|
||||
# Setup CLI context tool for Codex.
|
||||
# Non-port-dependent setup first (RTK, etc.).
|
||||
if not no_rtk:
|
||||
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
|
||||
click.echo(" Setting up lean-ctx for Codex...")
|
||||
|
|
@ -4452,8 +4490,74 @@ def codex(
|
|||
global_agents = _codex_home_dir() / "AGENTS.md"
|
||||
_inject_rtk_instructions(global_agents, verbose=verbose)
|
||||
|
||||
# --prepare-only: only update Codex config, do NOT start proxy.
|
||||
# MCP/memory/provider config are all config-file writes — they don't
|
||||
# need a running proxy. Use the raw requested port (no health check,
|
||||
# no port fallback) since the user will run the full command later.
|
||||
if prepare_only:
|
||||
if not no_mcp:
|
||||
from headroom.mcp_registry import CodexRegistrar
|
||||
|
||||
_setup_headroom_mcp(CodexRegistrar(), port, verbose=verbose, force=True)
|
||||
elif verbose:
|
||||
click.echo(" Skipping MCP retrieve tool (--no-mcp)")
|
||||
|
||||
from headroom.mcp_registry import CodexRegistrar
|
||||
|
||||
_setup_coding_compressor(
|
||||
CodexRegistrar(),
|
||||
serena_context="codex",
|
||||
serena=serena,
|
||||
no_serena=no_serena,
|
||||
no_tokensave=no_tokensave,
|
||||
verbose=verbose,
|
||||
force=True,
|
||||
)
|
||||
|
||||
if memory:
|
||||
click.echo(" Setting up memory for Codex...")
|
||||
mem_dir = Path.cwd() / ".headroom"
|
||||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = str(mem_dir / "memory.db")
|
||||
mem_user = os.environ.get("USER", os.environ.get("USERNAME", "default"))
|
||||
_inject_memory_mcp_config(mem_user)
|
||||
agents_md = Path.cwd() / "AGENTS.md"
|
||||
_inject_memory_agents_md(agents_md)
|
||||
|
||||
# Sync Claude's memories → DB so MCP search finds them
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from headroom.memory.sync import _build_sync_backend, sync_import
|
||||
from headroom.memory.sync_adapters.claude_code import (
|
||||
ClaudeCodeAdapter,
|
||||
get_claude_memory_dir,
|
||||
)
|
||||
|
||||
claude_memory_dir = get_claude_memory_dir()
|
||||
|
||||
async def _import_claude_memories() -> int:
|
||||
backend = _build_sync_backend(db_path)
|
||||
await backend._ensure_initialized()
|
||||
adapter = ClaudeCodeAdapter(claude_memory_dir)
|
||||
count = await sync_import(backend, adapter, mem_user)
|
||||
await backend.close()
|
||||
return count
|
||||
|
||||
imported = asyncio.run(_import_claude_memories())
|
||||
if imported:
|
||||
click.echo(f" Memory: imported {imported} memories from Claude")
|
||||
except Exception as e:
|
||||
click.echo(f" Warning: Claude memory import failed: {e}")
|
||||
|
||||
_inject_codex_provider_config(port)
|
||||
return
|
||||
|
||||
# Register headroom MCP server in Codex config.toml so Codex can
|
||||
# call headroom_retrieve on compression markers from the proxy.
|
||||
# These config writes do not need a running proxy — they run before
|
||||
# _ensure_proxy so unwrap has config to clean up even when proxy
|
||||
# startup or binary lookup fails.
|
||||
if not no_mcp:
|
||||
from headroom.mcp_registry import CodexRegistrar
|
||||
|
||||
|
|
@ -4519,17 +4623,46 @@ def codex(
|
|||
except Exception as e:
|
||||
click.echo(f" Warning: Claude memory import failed: {e}")
|
||||
|
||||
if prepare_only:
|
||||
_inject_codex_provider_config(port)
|
||||
return
|
||||
|
||||
codex_bin = shutil.which("codex")
|
||||
if not codex_bin:
|
||||
click.echo("Error: 'codex' not found in PATH.")
|
||||
click.echo("Install Codex CLI: npm install -g @openai/codex")
|
||||
raise SystemExit(1)
|
||||
|
||||
env, env_vars_display = _build_codex_launch_env(port, os.environ)
|
||||
# Register our proxy client marker BEFORE _ensure_proxy so that another
|
||||
# wrapper's cleanup sees us as an active client and doesn't terminate a
|
||||
# shared proxy during the startup gap.
|
||||
_register_proxy_client(port)
|
||||
|
||||
# Let _ensure_proxy decide the port (same contract as other wrappers).
|
||||
# Called after config writes so unwrap has config to restore even when
|
||||
# proxy startup fails.
|
||||
_codex_proxy, actual_port = _ensure_proxy(
|
||||
port,
|
||||
no_proxy,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="codex",
|
||||
code_graph=code_graph,
|
||||
backend=backend,
|
||||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
)
|
||||
|
||||
# If the proxy fell back to a different port, move our marker to the
|
||||
# actual port so cleanup tracking stays accurate.
|
||||
if actual_port != port:
|
||||
_unregister_proxy_client(port)
|
||||
_register_proxy_client(actual_port)
|
||||
|
||||
# If the proxy fell back to a different port, update the MCP config so
|
||||
# the retrieval tool URL points at the port the proxy is actually on.
|
||||
if actual_port != port and not no_mcp:
|
||||
from headroom.mcp_registry import CodexRegistrar
|
||||
|
||||
_setup_headroom_mcp(CodexRegistrar(), actual_port, verbose=verbose, force=True)
|
||||
|
||||
env, env_vars_display = _build_codex_launch_env(actual_port, os.environ)
|
||||
|
||||
# Per-project savings attribution: the injected provider config maps the
|
||||
# X-Headroom-Project header to HEADROOM_PROJECT via env_http_headers, so
|
||||
|
|
@ -4543,26 +4676,42 @@ def codex(
|
|||
# transport unless a custom provider declares supports_websockets = true.
|
||||
# NOTE: this must run BEFORE _inject_memory_mcp_config because it rewrites
|
||||
# the config file. Re-inject MCP config after if memory is enabled.
|
||||
_inject_codex_provider_config(port)
|
||||
_inject_codex_provider_config(actual_port)
|
||||
if memory:
|
||||
_inject_memory_mcp_config(os.environ.get("USER", os.environ.get("USERNAME", "default")))
|
||||
|
||||
_launch_tool(
|
||||
binary=codex_bin,
|
||||
args=codex_args,
|
||||
env=env,
|
||||
port=port,
|
||||
no_proxy=no_proxy,
|
||||
tool_label="CODEX",
|
||||
env_vars_display=env_vars_display,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="codex",
|
||||
code_graph=code_graph,
|
||||
backend=backend,
|
||||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
)
|
||||
# Proxy already started by _ensure_proxy above; tell _launch_tool to
|
||||
# skip duplicate startup. Cleanup of _codex_proxy happens on exit
|
||||
# via the finally block below.
|
||||
try:
|
||||
_launch_tool(
|
||||
binary=codex_bin,
|
||||
args=codex_args,
|
||||
env=env,
|
||||
port=actual_port,
|
||||
no_proxy=True,
|
||||
tool_label="CODEX",
|
||||
env_vars_display=env_vars_display,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="codex",
|
||||
code_graph=code_graph,
|
||||
backend=backend,
|
||||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
)
|
||||
finally:
|
||||
# _launch_tool's internal cleanup unregisters this client marker,
|
||||
# but doesn't know about the proxy we started. Terminate it when
|
||||
# no other clients remain.
|
||||
if _codex_proxy and _codex_proxy.poll() is None:
|
||||
_other = _live_proxy_clients(actual_port, exclude_self=True)
|
||||
if not _other:
|
||||
_codex_proxy.terminate()
|
||||
try:
|
||||
_codex_proxy.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_codex_proxy.kill()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -4812,8 +4961,8 @@ def cursor(
|
|||
if prepare_only:
|
||||
return
|
||||
|
||||
def _print_cursor_setup() -> None:
|
||||
for line in _render_cursor_setup_lines(port, project=_project_name_from_cwd()):
|
||||
def _print_cursor_setup(actual_port: int) -> None:
|
||||
for line in _render_cursor_setup_lines(actual_port, project=_project_name_from_cwd()):
|
||||
click.echo(line)
|
||||
if not no_rtk:
|
||||
click.echo()
|
||||
|
|
@ -4910,9 +5059,9 @@ def cline(
|
|||
if prepare_only:
|
||||
return
|
||||
|
||||
def _print_cline_setup() -> None:
|
||||
anthropic_base = _claude_proxy_base_url(port)
|
||||
openai_base = f"http://127.0.0.1:{port}/v1"
|
||||
def _print_cline_setup(actual_port: int) -> None:
|
||||
anthropic_base = _claude_proxy_base_url(actual_port)
|
||||
openai_base = f"http://127.0.0.1:{actual_port}/v1"
|
||||
click.echo(" Configure Cline in VS Code:")
|
||||
click.echo(" Settings > Cline > API Provider")
|
||||
click.echo(f" Anthropic Base URL: {anthropic_base}")
|
||||
|
|
@ -5034,9 +5183,9 @@ def continue_dev(
|
|||
if prepare_only:
|
||||
return
|
||||
|
||||
def _print_continue_setup() -> None:
|
||||
anthropic_base = _claude_proxy_base_url(port)
|
||||
openai_base = f"http://127.0.0.1:{port}/v1"
|
||||
def _print_continue_setup(actual_port: int) -> None:
|
||||
anthropic_base = _claude_proxy_base_url(actual_port)
|
||||
openai_base = f"http://127.0.0.1:{actual_port}/v1"
|
||||
click.echo(" Configure Continue in your IDE:")
|
||||
click.echo(f" Edit {config_file} and set, per model:")
|
||||
click.echo(f' "apiBase": "{openai_base}" # OpenAI-compatible models')
|
||||
|
|
@ -5695,26 +5844,16 @@ def opencode(
|
|||
click.echo("Install OpenCode: https://opencode.ai")
|
||||
raise SystemExit(1)
|
||||
|
||||
env, env_vars_display = _build_opencode_launch_env(
|
||||
port, os.environ, project=_project_name_from_cwd(), include_mcp=not no_mcp
|
||||
)
|
||||
# Register our proxy client marker BEFORE _ensure_proxy so that another
|
||||
# wrapper's cleanup sees us as an active client and doesn't terminate a
|
||||
# shared proxy during the startup gap.
|
||||
_register_proxy_client(port)
|
||||
|
||||
# Inject Headroom provider into OpenCode config so traffic routes through proxy.
|
||||
inject_opencode_provider_config(port)
|
||||
if memory:
|
||||
mem_dir = Path.cwd() / ".headroom"
|
||||
_inject_memory_mcp_config(
|
||||
os.environ.get("USER", os.environ.get("USERNAME", "default")),
|
||||
)
|
||||
|
||||
_launch_tool(
|
||||
binary=opencode_bin,
|
||||
args=opencode_args,
|
||||
env=env,
|
||||
port=port,
|
||||
no_proxy=no_proxy,
|
||||
tool_label="OPENCODE",
|
||||
env_vars_display=env_vars_display,
|
||||
# Resolve port before config injection so the provider block and MCP
|
||||
# URL both point at the port the proxy will actually be on.
|
||||
_opencode_proxy, actual_port = _ensure_proxy(
|
||||
port,
|
||||
no_proxy,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="opencode",
|
||||
|
|
@ -5724,6 +5863,57 @@ def opencode(
|
|||
region=region,
|
||||
)
|
||||
|
||||
# If the proxy fell back to a different port, move our marker so
|
||||
# cleanup tracking stays accurate and update MCP config.
|
||||
if actual_port != port:
|
||||
_unregister_proxy_client(port)
|
||||
_register_proxy_client(actual_port)
|
||||
if not no_mcp:
|
||||
from headroom.mcp_registry import OpencodeRegistrar
|
||||
|
||||
_setup_headroom_mcp(OpencodeRegistrar(), actual_port, verbose=verbose, force=True)
|
||||
|
||||
env, env_vars_display = _build_opencode_launch_env(
|
||||
actual_port, os.environ, project=_project_name_from_cwd(), include_mcp=not no_mcp
|
||||
)
|
||||
|
||||
# Inject Headroom provider into OpenCode config so traffic routes through proxy.
|
||||
inject_opencode_provider_config(actual_port)
|
||||
if memory:
|
||||
mem_dir = Path.cwd() / ".headroom"
|
||||
_inject_memory_mcp_config(
|
||||
os.environ.get("USER", os.environ.get("USERNAME", "default")),
|
||||
)
|
||||
|
||||
# Proxy already started by _ensure_proxy above; tell _launch_tool to
|
||||
# skip duplicate startup.
|
||||
try:
|
||||
_launch_tool(
|
||||
binary=opencode_bin,
|
||||
args=opencode_args,
|
||||
env=env,
|
||||
port=actual_port,
|
||||
no_proxy=True,
|
||||
tool_label="OPENCODE",
|
||||
env_vars_display=env_vars_display,
|
||||
learn=learn,
|
||||
memory=memory,
|
||||
agent_type="opencode",
|
||||
code_graph=code_graph,
|
||||
backend=backend,
|
||||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
)
|
||||
finally:
|
||||
if _opencode_proxy and _opencode_proxy.poll() is None:
|
||||
_other = _live_proxy_clients(actual_port, exclude_self=True)
|
||||
if not _other:
|
||||
_opencode_proxy.terminate()
|
||||
try:
|
||||
_opencode_proxy.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_opencode_proxy.kill()
|
||||
|
||||
|
||||
def _opencode_home_dir() -> Path:
|
||||
"""Return the OpenCode home/config directory."""
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def test_subcommand_verbose_flag_still_works() -> None:
|
|||
completed = SimpleNamespace(returncode=0)
|
||||
|
||||
with patch("headroom.cli.wrap.shutil.which", return_value="claude"):
|
||||
with patch("headroom.cli.wrap._ensure_proxy", return_value=None):
|
||||
with patch("headroom.cli.wrap._ensure_proxy", return_value=(None, 8787)):
|
||||
with patch("headroom.cli.wrap._setup_rtk", return_value=None):
|
||||
with patch("headroom.cli.wrap.subprocess.run", return_value=completed):
|
||||
result = runner.invoke(main, ["wrap", "claude", "-v"])
|
||||
|
|
|
|||
|
|
@ -69,9 +69,10 @@ def _invoke_wrap_claude(
|
|||
monkeypatch.setattr(wrap_mod, "_restore_claude_wrap_base_url", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(wrap_mod, "_print_telemetry_notice", lambda: None)
|
||||
|
||||
def fake_ensure_proxy(*args: object, **kwargs: object) -> None:
|
||||
def fake_ensure_proxy(*args: object, **kwargs: object) -> tuple[None, int]:
|
||||
captured["ensure_args"] = args
|
||||
captured["ensure_kwargs"] = kwargs
|
||||
return None, args[0] if args else 8787
|
||||
|
||||
def fake_run(cmd: list[str], *, env: dict[str, str]) -> _Completed:
|
||||
captured["child_cmd"] = cmd
|
||||
|
|
@ -331,13 +332,14 @@ def test_ensure_proxy_restarts_idle_proxy_for_vertex_api_url_mismatch(
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_mod._ensure_proxy(
|
||||
proc, actual_port = wrap_mod._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
vertex_api_url="https://new-gateway.example.com/vertex/v1",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
assert calls[1][2]["vertex_api_url"] == "https://new-gateway.example.com/vertex/v1"
|
||||
|
|
@ -375,9 +377,10 @@ def test_ensure_proxy_restarts_idle_proxy_to_clear_vertex_api_url(
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_mod._ensure_proxy(8787, False, clear_vertex_api_url=True)
|
||||
proc, actual_port = wrap_mod._ensure_proxy(8787, False, clear_vertex_api_url=True)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
assert calls[1][2]["vertex_api_url"] is None
|
||||
|
|
|
|||
|
|
@ -1070,7 +1070,7 @@ def test_launch_tool_ignores_sigint_in_wrapper(
|
|||
class FakeCompleted:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *args, **kwargs: (None, 8787))
|
||||
monkeypatch.setattr(
|
||||
wrap_mod.signal, "signal", lambda sig, fn: signal_handlers.setdefault(sig, fn)
|
||||
)
|
||||
|
|
@ -1527,3 +1527,73 @@ class TestCodexProjectHeaderConfig:
|
|||
assert "X-Headroom-Project" not in cleaned
|
||||
assert "[model_providers.headroom]" not in cleaned
|
||||
assert 'model = "gpt-4o"' in cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: codex delegates port resolution to _ensure_proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCodexPortResolution:
|
||||
"""codex() uses _ensure_proxy() to resolve ports (not early _find_available_port).
|
||||
|
||||
Regression for headroom#1406 round 2 review: codex() must follow
|
||||
the same selected-port contract as other wrappers (aider, copilot, etc.)
|
||||
so that a healthy existing proxy on the requested port is reused instead
|
||||
of skipped by a blind socket probe.
|
||||
"""
|
||||
|
||||
def test_delegates_to_ensure_proxy(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""codex() calls _ensure_proxy and uses the returned port."""
|
||||
_set_test_home(monkeypatch, Path("/tmp/test_headroom_codex"))
|
||||
|
||||
captured_port: list[int] = []
|
||||
|
||||
# Mock _ensure_proxy to capture the requested port
|
||||
def mock_ensure_proxy(port: int, no_proxy: bool, **kwargs: object) -> tuple[None, int]:
|
||||
captured_port.append(port)
|
||||
# Simulate port fallback: requested 8787, actual 8788
|
||||
return None, 8788
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", mock_ensure_proxy)
|
||||
|
||||
# Mock all heavy dependencies
|
||||
monkeypatch.setattr(
|
||||
wrap_mod, "_codex_config_paths", lambda: (Path("/dev/null"), Path("/dev/null"))
|
||||
)
|
||||
monkeypatch.setattr(wrap_mod, "_snapshot_codex_config_if_unwrapped", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_rtk_binary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_setup_lean_ctx_agent", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_inject_rtk_instructions", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_codex_home_dir", lambda: Path("/tmp"))
|
||||
monkeypatch.setattr(wrap_mod, "_setup_headroom_mcp", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_setup_serena_mcp", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod, "_disable_serena_mcp", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("shutil.which", lambda x: "/usr/bin/codex" if x == "codex" else None)
|
||||
monkeypatch.setattr(wrap_mod, "_build_codex_launch_env", lambda port, env: ({}, []))
|
||||
monkeypatch.setattr(wrap_mod, "_inject_codex_provider_config", lambda port: None)
|
||||
monkeypatch.setattr(wrap_mod, "_project_name_from_cwd", lambda: None)
|
||||
monkeypatch.setattr(wrap_mod, "_live_proxy_clients", lambda *a, **kw: [])
|
||||
|
||||
# Intercept _launch_tool to verify port propagation
|
||||
launch_kw: dict = {}
|
||||
|
||||
def mock_launch_tool(**kwargs: object) -> None:
|
||||
launch_kw.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_launch_tool", mock_launch_tool)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["wrap", "codex", "--port", "8787", "--no-rtk", "--no-mcp", "--no-serena"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert captured_port == [8787], (
|
||||
f"_ensure_proxy called with {captured_port}, expected [8787]"
|
||||
)
|
||||
assert launch_kw.get("port") == 8788, (
|
||||
f"_launch_tool port={launch_kw.get('port')}, expected 8788 "
|
||||
"(the actual_port from _ensure_proxy fallback)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ once with confusing diffs.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
|
@ -270,10 +271,10 @@ def test_run_proxy_only_watcher_calls_setup_lines_callback(
|
|||
|
||||
callback_calls: list[None] = []
|
||||
|
||||
def fake_setup() -> None:
|
||||
def fake_setup(_port: int) -> None:
|
||||
callback_calls.append(None)
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *a, **kw: fake_proc)
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *a, **kw: (fake_proc, 8787))
|
||||
# Replace time.sleep with a no-op so the loop spins quickly.
|
||||
monkeypatch.setattr(wrap_mod.time, "sleep", lambda _s: None)
|
||||
# Replace _make_cleanup to avoid side-effects on real ports/files.
|
||||
|
|
@ -321,7 +322,7 @@ def test_run_proxy_only_watcher_keyboardinterrupt_shuts_down_cleanly(
|
|||
if sleep_calls["n"] >= 1:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *a, **kw: _FakeProc())
|
||||
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *a, **kw: (_FakeProc(), 8787))
|
||||
monkeypatch.setattr(wrap_mod.time, "sleep", raising_sleep)
|
||||
monkeypatch.setattr(wrap_mod, "_make_cleanup", lambda holder, port: lambda *a, **kw: None)
|
||||
monkeypatch.setattr(wrap_mod.signal, "signal", lambda *a, **kw: None)
|
||||
|
|
@ -337,7 +338,7 @@ def test_run_proxy_only_watcher_keyboardinterrupt_shuts_down_cleanly(
|
|||
learn=False,
|
||||
memory=False,
|
||||
agent_type="cursor",
|
||||
print_setup_lines=lambda: None,
|
||||
print_setup_lines=lambda _port: None,
|
||||
)
|
||||
|
||||
inv = runner.invoke(_cmd)
|
||||
|
|
@ -368,7 +369,7 @@ def test_run_proxy_only_watcher_unexpected_exception_returns_exit_1(
|
|||
learn=False,
|
||||
memory=False,
|
||||
agent_type="cline",
|
||||
print_setup_lines=lambda: None,
|
||||
print_setup_lines=lambda _port: None,
|
||||
)
|
||||
|
||||
inv = runner.invoke(_cmd)
|
||||
|
|
@ -404,7 +405,7 @@ def test_run_proxy_only_watcher_calls_cleanup_on_finally(
|
|||
learn=False,
|
||||
memory=False,
|
||||
agent_type="cline",
|
||||
print_setup_lines=lambda: None,
|
||||
print_setup_lines=lambda _port: None,
|
||||
)
|
||||
|
||||
inv = runner.invoke(_cmd)
|
||||
|
|
@ -789,3 +790,65 @@ def test_resolve_1m_model_falls_back_to_default_when_unset() -> None:
|
|||
"""With no model selected, fall back to the default Opus carrying [1m]."""
|
||||
assert wrap_mod._resolve_1m_model(None) == "claude-opus-4-8[1m]"
|
||||
assert wrap_mod._resolve_1m_model(" ") == "claude-opus-4-8[1m]"
|
||||
|
||||
|
||||
class TestFindAvailablePort:
|
||||
"""Tests for _find_available_port (Vite-style port fallback)."""
|
||||
|
||||
def test_port_free_returns_same(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When port is free, returns the same port."""
|
||||
monkeypatch.setattr(wrap_mod, "_port_bind_error", lambda port: None)
|
||||
assert wrap_mod._find_available_port(8787) == 8787
|
||||
|
||||
def test_port_busy_finds_next(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When port is busy, returns the next free port."""
|
||||
|
||||
def mock_bind(port: int) -> OSError | None:
|
||||
if port == 8787:
|
||||
return OSError(errno.EADDRINUSE, "Address in use")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_port_bind_error", mock_bind)
|
||||
assert wrap_mod._find_available_port(8787) == 8788
|
||||
|
||||
def test_multiple_busy_ports(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When multiple consecutive ports are busy, skips all of them."""
|
||||
|
||||
def mock_bind(port: int) -> OSError | None:
|
||||
if port in (8787, 8788, 8789):
|
||||
return OSError(errno.EADDRINUSE, "Address in use")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_port_bind_error", mock_bind)
|
||||
assert wrap_mod._find_available_port(8787) == 8790
|
||||
|
||||
def test_propagates_unexpected_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Errors other than EADDRINUSE/EACCES (e.g. EADDRNOTAVAIL) propagate."""
|
||||
monkeypatch.setattr(
|
||||
wrap_mod,
|
||||
"_port_bind_error",
|
||||
lambda port: OSError(errno.EADDRNOTAVAIL, "Address not available"),
|
||||
)
|
||||
with pytest.raises(OSError, match="Address not available"):
|
||||
wrap_mod._find_available_port(8787)
|
||||
|
||||
def test_propagates_eaddrinuse_with_eacces(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Both EADDRINUSE and EACCES are skipped (not propagated)."""
|
||||
|
||||
def mock_bind(port: int) -> OSError | None:
|
||||
if port == 8787:
|
||||
return OSError(errno.EACCES, "Permission denied")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wrap_mod, "_port_bind_error", mock_bind)
|
||||
assert wrap_mod._find_available_port(8787) == 8788
|
||||
|
||||
def test_exhausts_range(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When all ports in range are busy, raises RuntimeError."""
|
||||
monkeypatch.setattr(
|
||||
wrap_mod,
|
||||
"_port_bind_error",
|
||||
lambda port: OSError(errno.EADDRINUSE, "Address in use"),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="No available port found"):
|
||||
wrap_mod._find_available_port(8787, max_attempts=3)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
|
|
@ -45,9 +47,10 @@ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> No
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == ["start:default"]
|
||||
|
||||
|
||||
|
|
@ -65,9 +68,10 @@ def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkey
|
|||
"headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == ["start:default"]
|
||||
|
||||
|
||||
|
|
@ -93,11 +97,13 @@ def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch)
|
|||
monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
|
||||
monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False)
|
||||
monkeypatch.setattr(wrap_cli, "_port_bind_error", lambda port: None)
|
||||
monkeypatch.setattr(wrap_cli, "_find_available_port", lambda port, **kw: port)
|
||||
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == ["start"]
|
||||
|
||||
|
||||
|
|
@ -108,8 +114,10 @@ def test_ensure_proxy_reports_unbindable_port_before_starting_subprocess(monkeyp
|
|||
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
||||
monkeypatch.setattr(
|
||||
wrap_cli,
|
||||
"_port_bind_error",
|
||||
lambda port: PermissionError(10013, "access denied by OS port reservation"),
|
||||
"_find_available_port",
|
||||
lambda port, **kw: (_ for _ in ()).throw(
|
||||
OSError(errno.EADDRNOTAVAIL, "address not available")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start"))
|
||||
|
||||
|
|
@ -121,8 +129,6 @@ def test_ensure_proxy_reports_unbindable_port_before_starting_subprocess(monkeyp
|
|||
raise AssertionError("expected unbindable port to raise before starting proxy")
|
||||
|
||||
assert "Port 8787 is unavailable" in message
|
||||
assert "Windows" in message
|
||||
assert "headroom wrap cursor --port 8788" in message
|
||||
assert calls == []
|
||||
|
||||
|
||||
|
|
@ -150,9 +156,10 @@ def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) ->
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == ["restart:default:8787"]
|
||||
|
||||
|
||||
|
|
@ -174,9 +181,10 @@ def test_ensure_proxy_leaves_active_stale_persistent_deployment_running(monkeypa
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_defers_persistent_restart_when_http_wrapper_attached(
|
||||
|
|
@ -209,9 +217,10 @@ def test_ensure_proxy_defers_persistent_restart_when_http_wrapper_attached(
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None:
|
||||
|
|
@ -273,9 +282,10 @@ def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
|
||||
|
|
@ -309,13 +319,14 @@ def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monke
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.individual.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
assert calls[1][2]["openai_api_url"] == "https://api.individual.githubcopilot.com"
|
||||
|
|
@ -347,9 +358,10 @@ def test_ensure_proxy_reuses_agent_proxy_without_savings_profile(monkeypatch) ->
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_restarts_for_explicit_agent_savings_profile(monkeypatch) -> None:
|
||||
|
|
@ -376,9 +388,10 @@ def test_ensure_proxy_restarts_for_explicit_agent_savings_profile(monkeypatch) -
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="codex")
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
|
||||
|
|
@ -424,9 +437,10 @@ def test_ensure_proxy_reuses_agent_proxy_with_savings_profile(monkeypatch) -> No
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, agent_type="cursor")
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -> None:
|
||||
|
|
@ -454,9 +468,10 @@ def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_defers_version_restart_when_http_wrapper_attached(monkeypatch) -> None:
|
||||
|
|
@ -489,9 +504,10 @@ def test_ensure_proxy_defers_version_restart_when_http_wrapper_attached(monkeypa
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_defers_flag_restart_when_other_wrapper_attached(monkeypatch) -> None:
|
||||
|
|
@ -523,9 +539,10 @@ def test_ensure_proxy_defers_flag_restart_when_other_wrapper_attached(monkeypatc
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_restarts_for_flags_when_no_other_wrapper(monkeypatch) -> None:
|
||||
|
|
@ -554,9 +571,10 @@ def test_ensure_proxy_restarts_for_flags_when_no_other_wrapper(monkeypatch) -> N
|
|||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
|
||||
|
|
@ -594,13 +612,14 @@ def test_ensure_proxy_restarts_persistent_deployment_for_feature_mismatch(monkey
|
|||
)
|
||||
|
||||
# Request openai_api_url that differs from running config (None)
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
# Proxy should be killed and restarted due to openai_api_url mismatch
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
|
|
@ -640,9 +659,10 @@ def test_ensure_proxy_restarts_persistent_deployment_for_memory_mismatch(monkeyp
|
|||
)
|
||||
|
||||
# Request memory that differs from running config (False)
|
||||
result = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
proc, actual_port = wrap_cli._ensure_proxy(8787, False, memory=True)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
# Proxy should be killed and restarted due to memory mismatch
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
|
|
@ -682,13 +702,14 @@ def test_ensure_proxy_restarts_recovered_persistent_for_openai_api_url_mismatch(
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.business.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == [("restart", "default", 8787)]
|
||||
|
||||
|
||||
|
|
@ -714,13 +735,14 @@ def test_ensure_proxy_restarts_recovered_persistent_when_config_unavailable(monk
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.business.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == [("restart", "default", 8787)]
|
||||
|
||||
|
||||
|
|
@ -757,14 +779,15 @@ def test_ensure_proxy_reuses_persistent_deployment_when_features_match(monkeypat
|
|||
)
|
||||
|
||||
# Request same features as running config
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
memory=True,
|
||||
openai_api_url="https://api.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
|
||||
|
||||
def test_ensure_proxy_recovered_persistent_deployment_checks_feature_mismatch(monkeypatch) -> None:
|
||||
|
|
@ -806,11 +829,12 @@ def test_ensure_proxy_recovered_persistent_deployment_checks_feature_mismatch(mo
|
|||
),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(
|
||||
proc, actual_port = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert proc is None
|
||||
assert actual_port == 8787
|
||||
assert calls == [("restart", "default", 8787)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue