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