From 944907bfeeb45bb650907f0d7277f54024c69a86 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 2 Sep 2026 23:56:46 -0700 Subject: [PATCH] Keep OpenCode proxy traffic out of recovery --- archivebox/opencode/views.py | 44 ++++++++++++++++--------- archivebox/tests/test_opencode_agent.py | 25 ++++++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/archivebox/opencode/views.py b/archivebox/opencode/views.py index 286a54e9..1f8770c2 100644 --- a/archivebox/opencode/views.py +++ b/archivebox/opencode/views.py @@ -105,6 +105,7 @@ def _stop_owned_process(process: subprocess.Popen | None = None) -> None: if owned_process is None: return if owned_process.poll() is None: + _signal_owned_process(owned_process, signal.SIGCONT) _signal_owned_process(owned_process, signal.SIGTERM) try: owned_process.wait(timeout=5) @@ -369,11 +370,15 @@ def _ensure_default_session(settings: dict) -> str: return session_id -def _health(settings: dict) -> bool: +def _owned_process_running() -> bool: + return _PROCESS is not None and _PROCESS.poll() is None + + +def _health(settings: dict, timeout: float = 2) -> bool: try: response = requests.get( f"{settings['origin']}/global/health", - timeout=2, + timeout=timeout, ) return response.status_code == 200 except requests.RequestException: @@ -399,15 +404,20 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]: workdir = settings["workdir"].resolve() with _PROCESS_LOCK: - if _health(settings): - return True, "" - if _PROCESS is not None and _PROCESS.poll() is None: + if _owned_process_running(): deadline = time.monotonic() + min(_PROCESS_HEALTH_GRACE, settings["timeout"]) - while time.monotonic() < deadline and _PROCESS.poll() is None: - if _health(settings): + while _owned_process_running(): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + if _health(settings, timeout=min(2, remaining)): return True, "" - time.sleep(0.25) + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(0.25, remaining)) _stop_owned_process(_PROCESS) + elif _health(settings): + return True, "" try: binary, git_binary, binary_env = _resolve_binary( @@ -609,11 +619,12 @@ async def _event_chunks( params: tuple[tuple[str, str], ...], headers: dict[str, str], ): - ok, error = await sync_to_async(_ensure_opencode, thread_sensitive=False)(settings) - if not ok: - _LOGGER.warning("OpenCode event stream unavailable: %s", error) - yield b'event: error\ndata: {"error":"OpenCode upstream unavailable"}\n\n' - return + if not _owned_process_running(): + ok, error = await sync_to_async(_ensure_opencode, thread_sensitive=False)(settings) + if not ok: + _LOGGER.warning("OpenCode event stream unavailable: %s", error) + yield b'event: error\ndata: {"error":"OpenCode upstream unavailable"}\n\n' + return timeout = httpx.Timeout(settings["timeout"], read=None) url = _proxy_url(settings, path) @@ -735,9 +746,10 @@ def opencode_proxy_view(request: HttpRequest, path: str | None = None): response.headers["X-Accel-Buffering"] = "no" return response - ok, error = _ensure_opencode(settings) - if not ok: - return _proxy_error_response(error) + if path == "global/health" or not _owned_process_running(): + ok, error = _ensure_opencode(settings) + if not ok: + return _proxy_error_response(error) try: method = request.method or "GET" diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py index c369f100..2bb6b34c 100644 --- a/archivebox/tests/test_opencode_agent.py +++ b/archivebox/tests/test_opencode_agent.py @@ -317,7 +317,9 @@ def test_opencode_preserves_a_transiently_unhealthy_owned_process(live_opencode) try: with ThreadPoolExecutor(max_workers=1) as executor: resumed = executor.submit(resume_process) + started_at = time.monotonic() ok, error = views._ensure_opencode(live_opencode.settings) + elapsed = time.monotonic() - started_at resumed.result() finally: views._signal_owned_process(process, signal.SIGCONT) @@ -325,6 +327,29 @@ def test_opencode_preserves_a_transiently_unhealthy_owned_process(live_opencode) assert ok, error assert views._PROCESS is process assert process.poll() is None + assert elapsed < views._PROCESS_HEALTH_GRACE + + +def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_opencode): + from archivebox.opencode import views + + workdir = quote(str(live_opencode.config.data_dir.resolve())) + executor = ThreadPoolExecutor(max_workers=1) + views._PROCESS_LOCK.acquire() + try: + request = executor.submit( + admin_client.get, + f"/admin/agent/opencode/path?directory={workdir}", + HTTP_HOST=ADMIN_TEST_HOST, + HTTP_SEC_FETCH_SITE="same-origin", + ) + response = request.result(timeout=5) + finally: + views._PROCESS_LOCK.release() + executor.shutdown(wait=True) + + assert response.status_code == 200 + assert str(live_opencode.config.data_dir.resolve()).encode() in response.content def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):