diff --git a/api/routes.py b/api/routes.py index ecd7912a..a8f9bd8e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2509,6 +2509,34 @@ def _handle_plugins(handler, parsed) -> bool: ) +_SHELL_ERROR_HTML = """ + + + + + Hermes is restarting + + +
+

Hermes is restarting…

+

The WebUI shell could not load cleanly. Refresh in a moment if this page does not update automatically.

+
+ +""" + + +def _serve_shell_unavailable(handler, exc: Exception) -> bool: + """Return HTML for shell-route failures so `/` never renders JSON.""" + logger.warning("Failed to serve WebUI shell route: %s", exc) + t( + handler, + _SHELL_ERROR_HTML, + status=503, + content_type="text/html; charset=utf-8", + ) + return True + + def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" @@ -2520,17 +2548,20 @@ def handle_get(handler, parsed) -> bool: return _serve_static(handler, stripped) if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): - from urllib.parse import quote - from api.updates import WEBUI_VERSION - version_token = quote(WEBUI_VERSION, safe="") - from api.extensions import inject_extension_tags + try: + from urllib.parse import quote + from api.updates import WEBUI_VERSION + version_token = quote(WEBUI_VERSION, safe="") + from api.extensions import inject_extension_tags - html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token) - return t( - handler, - inject_extension_tags(html), - content_type="text/html; charset=utf-8", - ) + html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token) + return t( + handler, + inject_extension_tags(html), + content_type="text/html; charset=utf-8", + ) + except Exception as exc: + return _serve_shell_unavailable(handler, exc) if parsed.path == "/login": _settings = load_settings() diff --git a/docs/pr-media/1835/home-shell-normal.png b/docs/pr-media/1835/home-shell-normal.png new file mode 100644 index 00000000..4a08f3f2 Binary files /dev/null and b/docs/pr-media/1835/home-shell-normal.png differ diff --git a/tests/test_home_route_html_error.py b/tests/test_home_route_html_error.py new file mode 100644 index 00000000..69e5e213 --- /dev/null +++ b/tests/test_home_route_html_error.py @@ -0,0 +1,58 @@ +"""Regression coverage for the shell/home route fallback. + +The WebUI shell should never render a JSON error page for `/`, even if +index.html serving fails during a restart/update race. API routes still keep +their normal JSON error behavior; this only pins the shell route contract. +""" + +from urllib.parse import urlparse + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def header(self, name): + for key, value in self.sent_headers: + if key.lower() == name.lower(): + return value + return None + + +class _BrokenIndexPath: + def read_text(self, *args, **kwargs): + raise RuntimeError("simulated index.html read failure") + + +def test_home_route_internal_error_returns_html_503_not_json(monkeypatch): + from api import routes + + monkeypatch.setattr(routes, "_INDEX_HTML_PATH", _BrokenIndexPath()) + + handler = _FakeHandler() + assert routes.handle_get(handler, urlparse("http://example.com/")) is True + + assert handler.status == 503 + assert (handler.header("Content-Type") or "").startswith("text/html; charset=utf-8") + assert handler.header("Cache-Control") == "no-store" + + body = bytes(handler.body).decode("utf-8") + assert "Hermes is restarting" in body + assert "application/json" not in (handler.header("Content-Type") or "") + assert '"error"' not in body