fix(web): guard _serve_index against a missing or unreadable index.html

mount_spa degrades to a JSON 404 catch-all when the dist directory is
fully missing, but _serve_index read index.html unguarded — so a dist
dir that exists while index.html is missing (partial build, wiped dist,
permissions) raised FileNotFoundError on EVERY request instead of
returning a useful error.

Catch OSError around the read and return the same JSON 404 payload the
fully-missing-dist path uses, so clients get a consistent signal. The
route recovers automatically once a rebuild restores the file.
This commit is contained in:
Teknium 2026-07-21 05:41:20 -07:00
parent 18a3fa57bd
commit ad235d95a7
2 changed files with 65 additions and 1 deletions

View File

@ -17927,7 +17927,18 @@ def mount_spa(application: FastAPI):
``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right
auth scheme for /api/pty and /api/ws (ticket vs token).
"""
html = _index_path.read_text(encoding="utf-8")
try:
html = _index_path.read_text(encoding="utf-8")
except OSError:
# The dist dir existed at mount time but index.html is missing or
# unreadable now (partial build, wiped dist, permissions). Without
# this guard every request raises FileNotFoundError (500). Return
# the same JSON 404 payload mount_spa uses for a fully-missing
# dist so clients get a clear, consistent signal.
return JSONResponse(
{"error": "Frontend not built. Run: cd web && npm run build"},
status_code=404,
)
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
gated = bool(getattr(app.state, "auth_required", False))
gated_js = "true" if gated else "false"

View File

@ -9179,3 +9179,56 @@ class TestDesktopCronTicker:
with self._client():
assert not called.wait(0.5), "ticker must not run outside the desktop app"
class TestServeIndexMissingIndex:
"""_serve_index must not raise per-request when index.html vanishes
(partial build, wiped dist) after mount_spa saw an existing dist dir.
It should return the same JSON 404 payload mount_spa emits for a
fully-missing dist."""
@staticmethod
def _client_with_dist(tmp_path, monkeypatch, *, write_index: bool):
from fastapi import FastAPI
from starlette.testclient import TestClient
import hermes_cli.web_server as ws
dist = tmp_path / "web_dist"
(dist / "assets").mkdir(parents=True)
if write_index:
(dist / "index.html").write_text(
"<html><head></head><body>SPA</body></html>", encoding="utf-8"
)
monkeypatch.setattr(ws, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
spa_app = FastAPI()
ws.mount_spa(spa_app)
return TestClient(spa_app), dist
def test_missing_index_inside_existing_dist_returns_json_404(
self, tmp_path, monkeypatch
):
client, _dist = self._client_with_dist(
tmp_path, monkeypatch, write_index=False
)
for route in ("/", "/chat"):
resp = client.get(route)
assert resp.status_code == 404
assert resp.json()["error"] == (
"Frontend not built. Run: cd web && npm run build"
)
def test_index_deleted_after_mount_returns_json_404(self, tmp_path, monkeypatch):
client, dist = self._client_with_dist(tmp_path, monkeypatch, write_index=True)
assert client.get("/chat").status_code == 200 # healthy first
(dist / "index.html").unlink()
resp = client.get("/chat")
assert resp.status_code == 404
assert "Frontend not built" in resp.json()["error"]
# And recovers once the index reappears (e.g. a rebuild finished).
(dist / "index.html").write_text(
"<html><head></head><body>SPA-rebuilt</body></html>", encoding="utf-8"
)
resp = client.get("/chat")
assert resp.status_code == 200
assert "SPA-rebuilt" in resp.text