fix(booth): templates were hot-reloading into a live service running older Python
19 of 25 live booths returned 500 with `UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new code was broken — the service was running both at once. `booth.service` sets WorkingDirectory to this repo, so the repo IS the deployment root: no build step, no staging copy, the live service imports these files. Python is read once when the process starts. Jinja's FileSystemLoader re-reads a template on EVERY render. So the two halves of the service had different staleness rules, and editing booth.html deployed it instantly against Python from 22:03 that had never heard of the context the new markup wanted. The failure mode is worth naming precisely, because it is invisible to the suite by construction: the skew exists between a running process and the disk underneath it, so every test can pass against a tree that is simultaneously serving 500s. No amount of green catches this. The operator found it. Fixed at the source rather than with a reminder to restart. The template Environment is built here with auto_reload=False, so templates are cached at startup exactly like the Python, and there is ONE rule: nothing takes effect until you restart. The price is that template work needs a restart to see — that price is the entire point, and it is cheaper than a page of 500s while someone is reviewing. Building the Environment by hand means autoescape no longer comes from the Jinja2Templates constructor, so it is explicit and load-bearing: booth names, item names and mark text are all agent- or operator-authored strings that land in HTML. Verified escaped, not merely configured. Two tests hold the line — one on the snapshot property, one on the `dur` filter that is no longer incidental to the constructor. The environment is reachable at app.state.templates because a promise about the deployed service needs an assertion, and an assertion needs the env the app actually renders with. Also records the foot-gun in CLAUDE.md and persistent-memory: anyone editing this repo while the operator may be using the service is editing production. 244 tests. No version bump — the release tier for U2 is still the operator's call, and this rides with it.
This commit is contained in:
+31
-2
@@ -45,6 +45,7 @@ from fastapi.responses import (
|
||||
Response,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
try:
|
||||
import markdown as _markdown
|
||||
@@ -522,8 +523,32 @@ def create_app(
|
||||
ttl_seconds = ttl_hours * 3600.0
|
||||
max_upload_bytes = int(max_upload_mb * 1024 * 1024)
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.filters["dur"] = human_dur
|
||||
# TEMPLATES ARE CACHED AT STARTUP, DELIBERATELY — `auto_reload=False`.
|
||||
#
|
||||
# `booth.service` runs uvicorn with WorkingDirectory set to this repo, so the
|
||||
# repo IS the deployment root: there is no build step and no staging copy.
|
||||
# Jinja's default FileSystemLoader re-reads a template from disk on every
|
||||
# render, while the Python stays as it was when the process started. That
|
||||
# gives the two halves of the service different staleness rules, and editing
|
||||
# a template deploys it INSTANTLY against Python that may know nothing about
|
||||
# the context it wants.
|
||||
#
|
||||
# It cost an outage on 2026-09-21: 19 of 25 live booths returned 500 with
|
||||
# `UndefinedError: 'item_marks' is undefined` — new markup, old context, both
|
||||
# running at once, and neither version broken on its own. The Python had
|
||||
# started at 22:03 and the templates were from 23:40.
|
||||
#
|
||||
# With reload off there is ONE rule — nothing takes effect until you restart
|
||||
# — so the running process is always a coherent snapshot of one commit. The
|
||||
# price is that template work needs a `systemctl --user restart
|
||||
# booth.service` to see; that price is the whole point.
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
auto_reload=False,
|
||||
)
|
||||
env.filters["dur"] = human_dur
|
||||
templates = Jinja2Templates(env=env)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -548,6 +573,10 @@ def create_app(
|
||||
task.cancel()
|
||||
|
||||
app = FastAPI(title="The Booth", lifespan=lifespan)
|
||||
# The template environment, reachable for assertion: the snapshot property
|
||||
# above is a promise about the DEPLOYED service, so it needs a test, and a
|
||||
# test needs a handle on the env that the app actually renders with.
|
||||
app.state.templates = templates
|
||||
|
||||
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
|
||||
base_ctx = {
|
||||
|
||||
Reference in New Issue
Block a user