mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-14 11:06:13 +05:00
Reload stale OpenCode frames after server recovery (#1860)
## Summary
- monitor the proxied OpenCode health endpoint from the stable
ArchiveBox parent page
- reload the iframe once after an unavailable -> healthy transition
- trigger an immediate check again when the browser comes online or the
tab becomes visible
## Why
A real RC416 -> RC417 DigestBox restart left the already-open OpenCode
1.17.14 SPA showing its fatal error page against the newly started
OpenCode 1.17.15 server. A full page reload recovered immediately. The
parent ArchiveBox document survives the container restart, so it can
perform that one iframe reload automatically after the server is healthy
again.
## Verification
- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor
- focused pre-commit hooks pass
- real stale-tab restart reproduction will be repeated on DigestBox
after release
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Reloads stale OpenCode iframes after the ArchiveBox server recovers from
a restart, so the previously fatal error page is replaced automatically.
The parent page polls the OpenCode health endpoint every 3 seconds,
reloading the iframe once when the server transitions from unavailable
to healthy or when the server version changes, and also re-checks
immediately when the browser comes online or the tab becomes visible.
- Health monitoring uses a separate endpoint that reports status without
starting the server; recovery restarts a running but unhealthy OpenCode
process, and polling skips while hidden when the server is down.
**Verification**
- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor.
- Focused pre-commit hooks pass.
<sup>Written for commit a33b333b6a.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1860?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
commit
a71ef00b79
@ -206,6 +206,78 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const frame = document.querySelector(".opencode-agent-frame");
|
||||
if (!frame) return;
|
||||
|
||||
const healthUrl = "{{ proxy_prefix|escapejs }}/global/health";
|
||||
const monitorUrl = "{{ proxy_prefix|escapejs }}/_archivebox/health";
|
||||
const fetchOptions = {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
redirect: "manual",
|
||||
};
|
||||
let checking = false;
|
||||
let waking = false;
|
||||
let wasUnavailable = false;
|
||||
let healthyChecks = 0;
|
||||
let knownVersion = "{{ opencode_version|escapejs }}";
|
||||
const wake = () => {
|
||||
if (waking) return;
|
||||
waking = true;
|
||||
fetch(healthUrl, fetchOptions)
|
||||
.catch(() => {})
|
||||
.finally(() => { waking = false; });
|
||||
};
|
||||
const check = async () => {
|
||||
if (checking || (document.hidden && wasUnavailable)) return;
|
||||
checking = true;
|
||||
try {
|
||||
const response = await fetch(monitorUrl, fetchOptions);
|
||||
if (!response.ok || response.redirected) {
|
||||
wasUnavailable = true;
|
||||
healthyChecks = 0;
|
||||
if (response.status === 503) {
|
||||
wake();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const health = await response.json();
|
||||
if (!health || health.healthy !== true) {
|
||||
wasUnavailable = true;
|
||||
healthyChecks = 0;
|
||||
return;
|
||||
}
|
||||
const currentVersion = typeof health.version === "string" ? health.version : "";
|
||||
if (knownVersion && currentVersion && currentVersion !== knownVersion) {
|
||||
wasUnavailable = true;
|
||||
}
|
||||
if (currentVersion) {
|
||||
knownVersion = currentVersion;
|
||||
}
|
||||
if (wasUnavailable && ++healthyChecks >= 2) {
|
||||
wasUnavailable = false;
|
||||
healthyChecks = 0;
|
||||
frame.contentWindow.location.reload();
|
||||
}
|
||||
} catch {
|
||||
wasUnavailable = true;
|
||||
healthyChecks = 0;
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.setInterval(check, 3000);
|
||||
window.addEventListener("online", check);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) check();
|
||||
});
|
||||
check();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
from django.urls import path, re_path
|
||||
|
||||
from archivebox.opencode.views import agent_view, opencode_proxy_view
|
||||
from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", agent_view, name="opencode-agent"),
|
||||
path("opencode/_archivebox/health", agent_health_view, name="opencode-health"),
|
||||
re_path(
|
||||
r"^opencode(?:/(?P<path>.*))?$",
|
||||
opencode_proxy_view,
|
||||
|
||||
@ -26,6 +26,7 @@ from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseForbidden,
|
||||
JsonResponse,
|
||||
StreamingHttpResponse,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
@ -378,14 +379,29 @@ def _health(settings: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _opencode_version(settings: dict) -> str:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings['origin']}/global/health",
|
||||
timeout=2,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return str(data.get("version") or "") if isinstance(data, dict) else ""
|
||||
except (requests.RequestException, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
|
||||
global _PROCESS
|
||||
started_process: subprocess.Popen | None = None
|
||||
workdir = settings["workdir"].resolve()
|
||||
|
||||
with _PROCESS_LOCK:
|
||||
if (_PROCESS is not None and _PROCESS.poll() is None) or _health(settings):
|
||||
if _health(settings):
|
||||
return True, ""
|
||||
if _PROCESS is not None and _PROCESS.poll() is None:
|
||||
_stop_owned_process(_PROCESS)
|
||||
|
||||
try:
|
||||
binary, git_binary, binary_env = _resolve_binary(
|
||||
@ -494,10 +510,15 @@ def agent_view(request: HttpRequest):
|
||||
settings["archivebox_api_url"] = api_url
|
||||
ok, error = _ensure_opencode(settings)
|
||||
recent_session_id = ""
|
||||
opencode_version = ""
|
||||
if ok:
|
||||
try:
|
||||
with _SESSION_LOCK:
|
||||
recent_session_id = _ensure_default_session(settings)
|
||||
opencode_version = _opencode_version(settings)
|
||||
if not opencode_version:
|
||||
ok = False
|
||||
error = "OpenCode health check did not report its version."
|
||||
except (requests.RequestException, RuntimeError, ValueError) as err:
|
||||
ok = False
|
||||
error = f"OpenCode project initialization failed: {err}"
|
||||
@ -514,6 +535,8 @@ def agent_view(request: HttpRequest):
|
||||
# open the durable session URL so a fresh browser does not land on the
|
||||
# transient new-session route and appear to have lost prior sessions.
|
||||
"proxy_url": _project_route(settings["workdir"], recent_session_id),
|
||||
"proxy_prefix": _PROXY_PREFIX,
|
||||
"opencode_version": opencode_version,
|
||||
"workdir": str(settings["workdir"].resolve()),
|
||||
"recent_session_id": recent_session_id,
|
||||
}
|
||||
@ -525,6 +548,23 @@ def agent_view(request: HttpRequest):
|
||||
)
|
||||
|
||||
|
||||
def agent_health_view(request: HttpRequest):
|
||||
config = _machine_config()
|
||||
_require_enabled(config)
|
||||
auth_response = _require_superuser(request)
|
||||
if auth_response:
|
||||
return auth_response
|
||||
|
||||
version = _opencode_version(_settings(config))
|
||||
healthy = bool(version)
|
||||
response = JsonResponse(
|
||||
{"healthy": healthy, "version": version},
|
||||
status=200 if healthy else 503,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _proxy_url(settings: dict, path: str | None) -> str:
|
||||
rel = "/" if not path else f"/{path}"
|
||||
return urljoin(settings["origin"], rel)
|
||||
|
||||
@ -186,6 +186,15 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
|
||||
assert f'<iframe src="{session_path}"'.encode() in response.content
|
||||
assert b'id="header"' in response.content
|
||||
assert b'id="progress-monitor"' in response.content
|
||||
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
|
||||
assert response.context["opencode_version"]
|
||||
assert b"const healthUrl" in response.content
|
||||
assert b"/admin/agent/opencode/global/health" in response.content
|
||||
assert b"/admin/agent/opencode/_archivebox/health" in response.content
|
||||
assert b'redirect: "manual"' in response.content
|
||||
assert b"let waking = false" in response.content
|
||||
assert b"wake();" in response.content
|
||||
assert b"frame.contentWindow.location.reload()" in response.content
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'"
|
||||
|
||||
@ -199,6 +208,21 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
|
||||
assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
|
||||
|
||||
|
||||
def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode):
|
||||
from archivebox.opencode import views
|
||||
|
||||
views._stop_owned_process()
|
||||
response = admin_client.get(
|
||||
"/admin/agent/opencode/_archivebox/health",
|
||||
HTTP_HOST=ADMIN_TEST_HOST,
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"healthy": False, "version": ""}
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert views._PROCESS is None
|
||||
|
||||
|
||||
def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode):
|
||||
workdir = str(live_opencode.config.data_dir.resolve())
|
||||
encoded_workdir = quote(workdir)
|
||||
@ -261,6 +285,22 @@ def test_concurrent_opencode_startup_waits_until_server_is_ready(live_opencode):
|
||||
assert views._health(live_opencode.settings)
|
||||
|
||||
|
||||
def test_opencode_restarts_an_unhealthy_owned_process(live_opencode):
|
||||
from archivebox.opencode import views
|
||||
|
||||
old_process = views._PROCESS
|
||||
settings = {**live_opencode.settings, "port": _free_port()}
|
||||
settings["origin"] = f"http://{settings['host']}:{settings['port']}"
|
||||
|
||||
ok, error = views._ensure_opencode(settings)
|
||||
|
||||
assert ok, error
|
||||
assert old_process is not None
|
||||
assert old_process.poll() is not None
|
||||
assert views._PROCESS is not old_process
|
||||
assert views._health(settings)
|
||||
|
||||
|
||||
def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
|
||||
response = admin_client.get(
|
||||
"/admin/agent/opencode/global/event",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user