Merge pull request #4776 from nesquena/stage-4774-release
Release v0.51.603 — Release VJ (cache app-shell template, #4774)
This commit is contained in:
4 files changed
+157
-14
No files matched your search
@@ -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/<id>`) 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
|
||||
|
||||
+47
-8
@@ -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/<id>` 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,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Regression tests for the app-shell template cache (_render_index_shell_base).
|
||||
|
||||
The `/`, `/index.html`, and `/session/<id>` 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
|
||||
@@ -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/<id>"
|
||||
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/<id>"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user