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:
parent
18a3fa57bd
commit
ad235d95a7
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue