diff --git a/CHANGELOG.md b/CHANGELOG.md index e74a3b4e..47b5a2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template) + +### Changed + +- **The app shell (`/`, `/index.html`, `/session/`) is now served from an in-process template cache instead of re-reading and re-rendering `static/index.html` (~190 KB) on every navigation.** These are the hottest routes; each request previously re-read the file from disk and re-applied two process-constant token substitutions (`__WEBUI_VERSION__`, `__MAX_UPLOAD_BYTES__`). The partially rendered template is now cached and invalidated on `(size, mtime_ns)` change — mirroring the existing static-asset cache — so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Measured ~5× faster shell render in a local microbenchmark and removes per-request extension-manifest disk reads. Thanks @boriken72. (#4774) + ## [v0.51.602] — 2026-06-23 — Release VI (sidebar fetches only the active source bucket) ### Changed diff --git a/api/routes.py b/api/routes.py index 019fbf1c..31c6ac34 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8849,6 +8849,48 @@ def _save_saved_prompts(prompts: list) -> None: p.write_text(json.dumps(prompts, ensure_ascii=False, indent=2), encoding="utf-8") +# In-process cache for the app-shell template. The `/`, `/index.html`, and +# `/session/` routes are the hottest navigations and each re-read the +# ~190 KB static/index.html from disk and re-ran the two process-constant +# substitutions (__WEBUI_VERSION__, __MAX_UPLOAD_BYTES__) on every request. +# Those values are fixed for the process lifetime, so we cache the partially +# rendered template here, keyed by (size, nanosecond mtime) exactly like +# _STATIC_CACHE so a redeploy is picked up without a restart. The two values +# that genuinely vary per request — the per-session CSRF token and the runtime +# extension tags (inject_extension_tags) — are still applied on each request +# against the cached base, so caching changes no observable output. +_INDEX_SHELL_CACHE: dict = {} +_INDEX_SHELL_CACHE_LOCK = threading.Lock() + + +def _render_index_shell_base() -> str: + """Return static/index.html with the process-constant tokens substituted. + + Cached and invalidated on (size, mtime_ns) change. The CSRF token and + extension-tag injection are intentionally NOT applied here — they vary per + request and are applied by the caller against this base string. + """ + from api.updates import WEBUI_VERSION + + st = _INDEX_HTML_PATH.stat() + sig = (st.st_size, st.st_mtime_ns) + with _INDEX_SHELL_CACHE_LOCK: + cached = _INDEX_SHELL_CACHE.get("base") + if cached and cached[0] == sig: + return cached[1] + from urllib.parse import quote + + version_token = quote(WEBUI_VERSION, safe="") + base = ( + _INDEX_HTML_PATH.read_text(encoding="utf-8") + .replace("__WEBUI_VERSION__", version_token) + .replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES)) + ) + with _INDEX_SHELL_CACHE_LOCK: + _INDEX_SHELL_CACHE["base"] = (sig, base) + return base + + def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" @@ -8870,9 +8912,6 @@ def handle_get(handler, parsed) -> bool: if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): 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 csrf_token = "" @@ -8886,11 +8925,11 @@ def handle_get(handler, parsed) -> bool: except Exception: csrf_token = "" - html = ( - _INDEX_HTML_PATH.read_text(encoding="utf-8") - .replace("__WEBUI_VERSION__", version_token) - .replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES)) - .replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token)) + # The disk read + process-constant token substitutions are cached; + # only the per-session CSRF token and per-request extension tags are + # applied here (see _render_index_shell_base). + html = _render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps(csrf_token) ) return t( handler, diff --git a/tests/test_index_shell_template_cache.py b/tests/test_index_shell_template_cache.py new file mode 100644 index 00000000..de279a55 --- /dev/null +++ b/tests/test_index_shell_template_cache.py @@ -0,0 +1,87 @@ +"""Regression tests for the app-shell template cache (_render_index_shell_base). + +The `/`, `/index.html`, and `/session/` routes are the hottest navigations. +Each previously re-read the ~190 KB static/index.html from disk and re-ran the +two process-constant token substitutions (__WEBUI_VERSION__, +__MAX_UPLOAD_BYTES__) on every request. _render_index_shell_base() caches the +partially rendered template, keyed by (size, mtime_ns) like _STATIC_CACHE, while +the per-session CSRF token and per-request extension tags stay per-request. + +These tests guard the load-bearing invariant: caching must change no observable +output, only avoid redundant work. +""" +from __future__ import annotations + +import json +import os +import time +from urllib.parse import quote + +import api.routes as routes +from api.updates import WEBUI_VERSION + + +def _old_inline_render(csrf_token: str) -> str: + """Reproduce the pre-cache inline render exactly, for equivalence checks.""" + return ( + routes._INDEX_HTML_PATH.read_text(encoding="utf-8") + .replace("__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")) + .replace("__MAX_UPLOAD_BYTES__", str(routes.MAX_UPLOAD_BYTES)) + .replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token)) + ) + + +def test_base_substitutes_process_constants_but_not_csrf(): + base = routes._render_index_shell_base() + assert "__WEBUI_VERSION__" not in base + assert "__MAX_UPLOAD_BYTES__" not in base + # CSRF must remain a placeholder — it varies per request and is applied by + # the caller, not baked into the shared cache. + assert "__CSRF_TOKEN_JSON__" in base + + +def test_cached_render_is_byte_identical_to_old_inline(): + csrf = "test-csrf-abc123" + new = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps(csrf) + ) + assert new == _old_inline_render(csrf) + + +def test_csrf_token_varies_per_request(): + a = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps("AAA") + ) + b = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps("BBB") + ) + assert json.dumps("AAA") in a + assert json.dumps("BBB") in b + assert a != b + + +def test_second_call_returns_cached_object(): + # Warm the cache, then assert the identical object comes back (no re-read). + first = routes._render_index_shell_base() + second = routes._render_index_shell_base() + assert first is second + + +def test_cache_invalidates_on_mtime_change(tmp_path, monkeypatch): + # Point the module at a temp copy so we can mutate its mtime safely. + src = routes._INDEX_HTML_PATH.read_text(encoding="utf-8") + fake = tmp_path / "index.html" + fake.write_text(src, encoding="utf-8") + monkeypatch.setattr(routes, "_INDEX_HTML_PATH", fake) + # Reset the shared cache so this test is order-independent. + monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {}) + + routes._render_index_shell_base() + sig_before = routes._INDEX_SHELL_CACHE["base"][0] + + future = time.time() + 5 + os.utime(fake, (future, future)) + routes._render_index_shell_base() + sig_after = routes._INDEX_SHELL_CACHE["base"][0] + + assert sig_after != sig_before diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 0e10001f..380e110f 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -344,13 +344,24 @@ class TestIndexHtmlIntegration: def test_index_route_url_encodes_asset_version(self): src = ROUTES.read_text(encoding="utf-8") - idx = src.find('parsed.path in ("/", "/index.html")') - if idx == -1: - idx = src.find('parsed.path.startswith("/session/")') - assert idx != -1, "routes.py must handle /, /index.html, and /session/" - block = src[idx:idx + 800] + # #4774 moved the app-shell render (incl. the version-token substitution) + # out of the route handler and into the cached `_render_index_shell_base()` + # helper. The security property — the cache-busting version token is + # URL-encoded before it's injected into script src / SW registration — must + # still hold, so assert it in whichever location renders the shell. Check the + # shell-render helper first (its current home), then fall back to the route + # handler block for older layouts. + helper_idx = src.find("def _render_index_shell_base") + if helper_idx != -1: + block = src[helper_idx:helper_idx + 1200] + else: + idx = src.find('parsed.path in ("/", "/index.html")') + if idx == -1: + idx = src.find('parsed.path.startswith("/session/")') + assert idx != -1, "routes.py must handle /, /index.html, and /session/" + block = src[idx:idx + 800] assert "quote(WEBUI_VERSION, safe=\"\")" in block, ( - "index route must URL-encode the cache-busting version token before " + "the app-shell render must URL-encode the cache-busting version token before " "injecting it into script src attributes and service worker registration" )