فهرست منبع

fix(deploy): kiosk picks up new builds without operator intervention

  Reproduced live during the #1133 rollout: the SpoolBuddy display kept
  serving the pre-fix picker for hours after every cache-clear,
  chromium-restart, and pkill attempt because a chain of stale state
  across HTTP cache + Service Worker + persistent profile prevented
  fresh code from reaching the running tab.

  Three independent changes — any one of them sufficient on a clean
  profile, but all three needed to escape an already-corrupted one:

  (1) backend/app/main.py — index.html now served with
  Cache-Control: no-cache, must-revalidate on both / and the SPA
  catch-all. Vite emits content-hashed JS/CSS bundle filenames so the
  assets themselves are safe to cache forever, but the HTML wrapping
  them is the only file that knows which hash is current. Without
  explicit cache directives Chromium falls back to heuristic caching
  (typically 10% of time since Last-Modified) and on long-running
  kiosks happily serves stale HTML across browser restarts. That stale
  HTML references an old bundle hash which is also still in disk
  cache, so the kiosk runs pre-deploy JS forever without ever knowing
  why.

  (2) frontend/public/sw.js — CACHE_NAME bumped from bambuddy-v25 to
  bambuddy-v26 so any client that fetches the new sw.js drops its old
  CacheStorage. The SW does network-first for HTML/JS/CSS but
  intercepts and falls back to cache, and cache-control on HTTP
  responses doesn't reach into the SW's own cache layer.

  (3) spoolbuddy/install/install.sh — generated kiosk launcher now uses
  --user-data-dir=/tmp/spoolbuddy-kiosk-userdata with a pre-launch
  rm -rf, so every kiosk restart starts from a clean slate (no HTTP
  cache, no SW registration, no IndexedDB). Trade-off is a slightly
  slower first paint and zero offline support; neither matters for a
  single-purpose kiosk facing a backend on the same LAN, and the
  guarantee that next-deploy-just-works is worth far more.

  4 new tests in test_static_html_cache_headers.py: index.html on /
  and SPA catch-all paths emit Cache-Control: no-cache,
  must-revalidate; API routes are unaffected (no leak of HTML cache
  directive onto endpoints we want React Query to cache aggressively).

  For existing kiosks already trapped by an old persistent profile,
  operator runs once: rm -rf ~/.config/chromium && systemctl restart
  getty@tty1.service. The new launcher then picks up automatically.
maziggy 4 ماه پیش
والد
کامیت
e9200449ae

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 15 - 2
backend/app/main.py

@@ -4767,7 +4767,7 @@ async def serve_frontend():
     """Serve the React frontend."""
     index_file = app_settings.static_dir / "index.html"
     if index_file.exists():
-        return FileResponse(index_file)
+        return FileResponse(index_file, headers=_HTML_CACHE_HEADERS)
     return {
         "message": "Bambuddy API",
         "docs": "/docs",
@@ -4775,6 +4775,19 @@ async def serve_frontend():
     }
 
 
+# index.html must always be revalidated — Vite emits content-hashed JS/CSS
+# bundles (e.g. `index-JRaF_JhW.js`), so the JS itself is safe to cache
+# forever, but the HTML wrapping it is the only file that knows which hash
+# is current. Without explicit cache-control headers Chromium decides
+# heuristically (typically 10% of the time since Last-Modified) and on
+# long-running kiosks happily serves stale HTML across browser restarts.
+# That stale HTML references an old bundle hash, the old bundle is also
+# in the disk cache, and the user ends up running pre-update JS forever
+# without ever knowing why. ``no-cache`` (revalidate every time, but a
+# 304 is cheap) is the correct setting for an SPA's entry HTML.
+_HTML_CACHE_HEADERS = {"Cache-Control": "no-cache, must-revalidate"}
+
+
 @app.get("/health")
 async def health_check():
     """Health check endpoint."""
@@ -4860,6 +4873,6 @@ async def serve_spa(full_path: str):
 
     index_file = app_settings.static_dir / "index.html"
     if index_file.exists():
-        return FileResponse(index_file)
+        return FileResponse(index_file, headers=_HTML_CACHE_HEADERS)
 
     return {"error": "Frontend not built"}

+ 90 - 0
backend/tests/integration/test_static_html_cache_headers.py

@@ -0,0 +1,90 @@
+"""Tests for the SPA index.html cache-control behaviour.
+
+Background: Vite emits content-hashed JS/CSS bundle filenames (e.g.
+``index-JRaF_JhW.js``), so those assets are safe to cache forever — the
+hash changes when their content changes. The wrapping HTML, however, is
+the only file that knows which hash is current. Without explicit cache
+directives, Chromium falls back to heuristic caching (typically 10% of
+the time since Last-Modified) and on long-running kiosks happily serves
+stale HTML across browser restarts. That stale HTML references an old
+bundle hash, which is also still in disk cache, so the kiosk runs
+pre-deploy JS indefinitely without ever knowing why.
+
+Reproduced in the wild during the #1133 rollout — the SpoolBuddy
+display kept serving the pre-fix picker for hours after every
+cache-clear attempt because Chromium would re-seed its cache from
+disk on next start. Fixed by sending ``no-cache, must-revalidate`` on
+the two routes that serve ``index.html``.
+
+These tests pin that behaviour so it can't silently regress (e.g. a
+later PR adding a third index.html serve route forgetting the headers,
+or someone tightening the policy to ``max-age=N`` and breaking deploys
+in subtle ways).
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+# index.html is served by two distinct routes:
+#   - "/" — root entry
+#   - the SPA catch-all (any unrecognised path that isn't /api/)
+# Both must carry the same headers; testing both individually is the
+# only guard against one being added later without the other.
+HTML_ROUTES = [
+    pytest.param("/", id="root"),
+    # Catch-all routes a path like /spoolbuddy/ to index.html. The trailing
+    # slash matters — without it FastAPI redirects, which would skip the
+    # cache-control middleware. Tested as a real-world client URL.
+    pytest.param("/spoolbuddy/", id="spa-catchall-spoolbuddy"),
+    pytest.param("/printers", id="spa-catchall-printers"),
+]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("path",), HTML_ROUTES)
+async def test_index_html_emits_no_cache_directive(async_client: AsyncClient, path: str):
+    """Every index.html serve must emit ``Cache-Control: no-cache,
+    must-revalidate`` — kiosks rely on this to pick up new builds without
+    operator intervention."""
+    response = await async_client.get(path)
+
+    # Both serve routes should return 200 with HTML content type.
+    assert response.status_code == 200, f"Expected 200 for {path}, got {response.status_code}: {response.text[:200]}"
+    assert response.headers.get("content-type", "").startswith("text/html"), (
+        f"{path} returned non-HTML content-type: {response.headers.get('content-type')}"
+    )
+
+    # The Cache-Control header is the actual contract under test.
+    cache_control = response.headers.get("cache-control", "")
+    assert "no-cache" in cache_control, (
+        f"{path} missing 'no-cache' in Cache-Control header (got: {cache_control!r}). "
+        f"Without this kiosks serve stale HTML across browser restarts and never "
+        f"pick up new builds."
+    )
+    assert "must-revalidate" in cache_control, (
+        f"{path} missing 'must-revalidate' in Cache-Control header (got: {cache_control!r}). "
+        f"This belt-and-braces directive prevents stale-while-revalidate-style "
+        f"intermediaries from serving cached HTML even when it's expired."
+    )
+
+
+@pytest.mark.asyncio
+async def test_api_routes_unaffected_by_html_cache_headers(async_client: AsyncClient):
+    """Defensive: the cache-control directive must NOT leak onto API
+    responses. API responses set their own headers (or none at all) per
+    endpoint; a global ``no-cache`` would silently disable the React
+    Query cache wins we depend on for snappy UI updates."""
+    response = await async_client.get("/api/v1/printers")
+
+    # We don't care about success/failure here — just that no cache
+    # directive was inherited from the HTML serve path. (The endpoint
+    # itself may 401/403 depending on auth state in the test fixture
+    # which is fine; what matters is the response shape.)
+    cache_control = response.headers.get("cache-control", "")
+    assert "no-cache" not in cache_control or "private" in cache_control, (
+        f"API route /api/v1/printers leaked HTML cache-control: {cache_control!r}. "
+        f"If a 'no-cache' directive is intentional on an API endpoint it should be "
+        f"set per-route, not inherited from the SPA HTML path."
+    )

+ 1 - 1
frontend/public/sw.js

@@ -1,5 +1,5 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v25';
+const CACHE_NAME = 'bambuddy-v26';
 const STATIC_CACHE = 'bambuddy-static-v25';
 
 // Static assets to cache on install

+ 31 - 0
spoolbuddy/install/install.sh

@@ -1210,12 +1210,43 @@ for _i in \$(seq 1 60); do
     sleep 1
 done
 
+# Ephemeral user-data-dir under /tmp + wipe on every launch.
+#
+# The kiosk has no per-user state worth persisting (the auth token is in
+# the URL query, not a stored cookie), but the default profile at
+# ~/.config/chromium was accumulating two specific kinds of state across
+# reboots that broke deploys badly:
+#
+#   1. HTTP disk cache holding old index.html across browser restarts.
+#      Chromium's heuristic-cache freshness window kept the old HTML
+#      "fresh" for days, which referenced an old content-hashed bundle,
+#      so newly deployed code never reached the running tab even after
+#      pkill+relaunch. Reproduced in the wild during the #1133 rollout
+#      — the kiosk kept showing the pre-fix picker for hours after every
+#      cache-clear attempt because the persistent profile would re-seed
+#      the cache from disk on next start.
+#   2. A stuck Service Worker registration, which intercepted requests
+#      with its own cache layer (CacheStorage), independent of the HTTP
+#      cache. Even after \`rm -rf Default/Cache/*\` the SW could replay
+#      stale responses from CacheStorage until explicitly unregistered.
+#
+# Wiping the user-data-dir on every launch is the simplest, most
+# bulletproof escape hatch — every kiosk restart is now functionally
+# equivalent to a private-window first-load. Future deploys propagate
+# automatically: the next chromium launch picks up the latest bundle
+# without any extra tooling. Trade-off is a slightly slower first paint
+# (no warm cache) and zero offline support, neither of which matter for
+# a single-purpose kiosk facing a backend on the same LAN.
+USER_DATA_DIR="/tmp/spoolbuddy-kiosk-userdata"
+rm -rf "\$USER_DATA_DIR"
+
 exec chromium --kiosk --no-first-run --disable-infobars \
     --disable-session-crashed-bubble --disable-features=TranslateUI \
     --noerrdialogs --disable-component-update \
     --overscroll-history-navigation=0 \
     --ozone-platform=wayland \
     --disable-crash-reporter --disable-breakpad \
+    --user-data-dir="\$USER_DATA_DIR" \
     "\$kiosk_url"
 EOF
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-JRaF_JhW.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D1m4OtUR.js"></script>
+    <script type="module" crossorigin src="/assets/index-JRaF_JhW.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
   </head>
   <body>

+ 1 - 1
static/sw.js

@@ -1,5 +1,5 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v25';
+const CACHE_NAME = 'bambuddy-v26';
 const STATIC_CACHE = 'bambuddy-static-v25';
 
 // Static assets to cache on install

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است