diff --git a/archivebox/opencode/views.py b/archivebox/opencode/views.py index 4e1e4346..7570a0ed 100644 --- a/archivebox/opencode/views.py +++ b/archivebox/opencode/views.py @@ -34,6 +34,7 @@ from django.views.decorators.csrf import csrf_exempt _PROCESS: subprocess.Popen | None = None +_PROCESS_READY: subprocess.Popen | None = None _PROCESS_LOCK = threading.Lock() _SESSION_LOCK = threading.Lock() _LOGGER = logging.getLogger(__name__) @@ -100,7 +101,7 @@ def _signal_owned_process(process: subprocess.Popen, sig: signal.Signals) -> Non def _stop_owned_process(process: subprocess.Popen | None = None) -> None: - global _PROCESS + global _PROCESS, _PROCESS_READY owned_process = process or _PROCESS if owned_process is None: return @@ -114,6 +115,8 @@ def _stop_owned_process(process: subprocess.Popen | None = None) -> None: owned_process.wait() if _PROCESS is owned_process: _PROCESS = None + if _PROCESS_READY is owned_process: + _PROCESS_READY = None atexit.register(_stop_owned_process) @@ -374,6 +377,10 @@ def _owned_process_running() -> bool: return _PROCESS is not None and _PROCESS.poll() is None +def _owned_process_ready() -> bool: + return _owned_process_running() and _PROCESS_READY is _PROCESS + + def _health(settings: dict, timeout: float = 2) -> bool: try: response = requests.get( @@ -399,7 +406,7 @@ def _opencode_version(settings: dict) -> str: def _ensure_opencode(settings: dict) -> tuple[bool, str]: - global _PROCESS + global _PROCESS, _PROCESS_READY started_process: subprocess.Popen | None = None workdir = settings["workdir"].resolve() @@ -412,6 +419,7 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]: break if _health(settings, timeout=min(2, remaining)): if time.monotonic() <= deadline: + _PROCESS_READY = _PROCESS return True, "" break remaining = deadline - time.monotonic() @@ -495,6 +503,7 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]: stdout=subprocess.DEVNULL, start_new_session=True, ) + _PROCESS_READY = None started_process = _PROCESS except FileNotFoundError: return False, f"OpenCode binary not found: {settings['binary']}" @@ -502,10 +511,13 @@ def _ensure_opencode(settings: dict) -> tuple[bool, str]: deadline = time.monotonic() + settings["timeout"] while time.monotonic() < deadline: if _health(settings): + _PROCESS_READY = started_process return True, "" if started_process and started_process.poll() is not None: if _PROCESS is started_process: _PROCESS = None + if _PROCESS_READY is started_process: + _PROCESS_READY = None return False, "OpenCode exited before the web server became ready." time.sleep(0.25) @@ -621,7 +633,7 @@ async def _event_chunks( params: tuple[tuple[str, str], ...], headers: dict[str, str], ): - if not _owned_process_running(): + if not _owned_process_ready(): ok, error = await sync_to_async(_ensure_opencode, thread_sensitive=False)(settings) if not ok: _LOGGER.warning("OpenCode event stream unavailable: %s", error) @@ -748,7 +760,7 @@ def opencode_proxy_view(request: HttpRequest, path: str | None = None): response.headers["X-Accel-Buffering"] = "no" return response - if path == "global/health" or not _owned_process_running(): + if path == "global/health" or not _owned_process_ready(): ok, error = _ensure_opencode(settings) if not ok: return _proxy_error_response(error) diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py index 2bb6b34c..65c21b27 100644 --- a/archivebox/tests/test_opencode_agent.py +++ b/archivebox/tests/test_opencode_agent.py @@ -311,7 +311,7 @@ def test_opencode_preserves_a_transiently_unhealthy_owned_process(live_opencode) views._signal_owned_process(process, signal.SIGSTOP) def resume_process(): - time.sleep(2.5) + time.sleep(1.5) views._signal_owned_process(process, signal.SIGCONT) try: @@ -334,6 +334,7 @@ def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_openc from archivebox.opencode import views workdir = quote(str(live_opencode.config.data_dir.resolve())) + assert views._owned_process_ready() executor = ThreadPoolExecutor(max_workers=1) views._PROCESS_LOCK.acquire() try: @@ -352,6 +353,25 @@ def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_openc assert str(live_opencode.config.data_dir.resolve()).encode() in response.content +def test_opencode_proxy_waits_for_owned_process_readiness(admin_client, live_opencode): + from archivebox.opencode import views + + process = views._PROCESS + assert process is not None + views._PROCESS_READY = None + workdir = quote(str(live_opencode.config.data_dir.resolve())) + + response = admin_client.get( + f"/admin/agent/opencode/path?directory={workdir}", + HTTP_HOST=ADMIN_TEST_HOST, + HTTP_SEC_FETCH_SITE="same-origin", + ) + + assert response.status_code == 200 + assert views._PROCESS is process + assert views._PROCESS_READY is process + + def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode): response = admin_client.get( "/admin/agent/opencode/global/event",