diff --git a/archivebox/opencode/views.py b/archivebox/opencode/views.py index c689abee..286a54e9 100644 --- a/archivebox/opencode/views.py +++ b/archivebox/opencode/views.py @@ -37,6 +37,7 @@ _PROCESS: subprocess.Popen | None = None _PROCESS_LOCK = threading.Lock() _SESSION_LOCK = threading.Lock() _LOGGER = logging.getLogger(__name__) +_PROCESS_HEALTH_GRACE = 5 _PROXY_PREFIX = "/admin/agent/opencode" _PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/") _PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/") @@ -401,6 +402,11 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]: if _health(settings): return True, "" if _PROCESS is not None and _PROCESS.poll() is None: + deadline = time.monotonic() + min(_PROCESS_HEALTH_GRACE, settings["timeout"]) + while time.monotonic() < deadline and _PROCESS.poll() is None: + if _health(settings): + return True, "" + time.sleep(0.25) _stop_owned_process(_PROCESS) try: diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py index 4bd89e2f..c369f100 100644 --- a/archivebox/tests/test_opencode_agent.py +++ b/archivebox/tests/test_opencode_agent.py @@ -1,7 +1,9 @@ import asyncio import os +import signal import socket import subprocess +import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import SimpleNamespace @@ -301,6 +303,30 @@ def test_opencode_restarts_an_unhealthy_owned_process(live_opencode): assert views._health(settings) +def test_opencode_preserves_a_transiently_unhealthy_owned_process(live_opencode): + from archivebox.opencode import views + + process = views._PROCESS + assert process is not None + views._signal_owned_process(process, signal.SIGSTOP) + + def resume_process(): + time.sleep(2.5) + views._signal_owned_process(process, signal.SIGCONT) + + try: + with ThreadPoolExecutor(max_workers=1) as executor: + resumed = executor.submit(resume_process) + ok, error = views._ensure_opencode(live_opencode.settings) + resumed.result() + finally: + views._signal_owned_process(process, signal.SIGCONT) + + assert ok, error + assert views._PROCESS is process + assert process.poll() is None + + def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode): response = admin_client.get( "/admin/agent/opencode/global/event",