Track OpenCode readiness separately from liveness

This commit is contained in:
Nick Sweeting 2026-09-03 00:14:34 -07:00
parent 8a12e3399c
commit 91aa70cc73
No known key found for this signature in database
2 changed files with 37 additions and 5 deletions

View File

@ -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)

View File

@ -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",