mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Fix shutdown hang and blank equations when KaTeX is unavailable
Shutdown hang, the cause of every restart taking systemd's full 90 s timeout and ending in SIGKILL: /api/stream looped forever with no disconnect or shutdown check, so an open dashboard was an in-flight request that never completed and uvicorn's graceful shutdown waited on it. Reproduced cleanly: with no stream client the process stopped in 2 s, with one open client it was still running after 15 s. Fixed by bounding timeout_graceful_shutdown, and by having the generator exit on client disconnect and on a shutdown event. Now 7 s with a client attached. Equations rendered as blank boxes whenever the KaTeX CDN was unreachable, which is a real case for a Pi on wifi. The elements carried the TeX only in a data attribute, so with no KaTeX there was nothing to display. The source is now written into the element as text first and KaTeX replaces it, so it degrades to readable TeX. Verified by aborting the katex request. A window load handler re-runs typesetting for a slow CDN. The code comment claiming this fallback already existed was wrong and is corrected.
This commit is contained in:
+26
-5
@@ -28,7 +28,7 @@ from contextlib import asynccontextmanager
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -43,6 +43,14 @@ station: Optional[Station] = None
|
|||||||
display: Optional[LedDisplay] = None
|
display: Optional[LedDisplay] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Set when the app is shutting down. The SSE generator watches it: without
|
||||||
|
# that, an open dashboard is an in-flight request that never completes, so
|
||||||
|
# uvicorn's graceful shutdown blocks until systemd's timeout SIGKILLs the
|
||||||
|
# process. Reproduced: with no stream client the service stops in 2 s, with
|
||||||
|
# one open client it was still running after 15 s.
|
||||||
|
_shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global station, display
|
global station, display
|
||||||
@@ -55,6 +63,7 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
_shutdown.set()
|
||||||
if display is not None:
|
if display is not None:
|
||||||
await display.stop()
|
await display.stop()
|
||||||
if station is not None:
|
if station is not None:
|
||||||
@@ -522,11 +531,18 @@ def events(limit: int = Query(50, ge=1, le=500)) -> List[Dict]:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stream")
|
@app.get("/api/stream")
|
||||||
async def stream():
|
async def stream(request: Request):
|
||||||
"""Server-sent events. One connection instead of a poll every 2 seconds,
|
"""Server-sent events. One connection instead of a poll every 2 seconds,
|
||||||
which on a Zero 2 W is the difference between 4% and 0.4% CPU."""
|
which on a Zero 2 W is the difference between 4% and 0.4% CPU.
|
||||||
|
|
||||||
|
The loop exits on shutdown or client disconnect. Both matter: an endless
|
||||||
|
generator keeps the response in flight, and uvicorn will not finish a
|
||||||
|
graceful shutdown while one is open.
|
||||||
|
"""
|
||||||
async def gen():
|
async def gen():
|
||||||
while True:
|
while not _shutdown.is_set():
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
st = _st()
|
st = _st()
|
||||||
payload = {
|
payload = {
|
||||||
"telemetry": telemetry(),
|
"telemetry": telemetry(),
|
||||||
@@ -535,7 +551,12 @@ async def stream():
|
|||||||
"drift_stress": round(st.monitor.drift.stress, 3),
|
"drift_stress": round(st.monitor.drift.stress, 3),
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(payload)}\n\n"
|
yield f"data: {json.dumps(payload)}\n\n"
|
||||||
await asyncio.sleep(2.0)
|
# Wait on the shutdown event rather than sleeping blindly, so a stop
|
||||||
|
# is honoured immediately instead of up to 2 s later.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_shutdown.wait(), timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream",
|
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache",
|
headers={"Cache-Control": "no-cache",
|
||||||
|
|||||||
@@ -475,8 +475,10 @@ const el = (id) => document.getElementById(id);
|
|||||||
let lastDerived = {};
|
let lastDerived = {};
|
||||||
const esc = (t) => String(t).replace(/&/g,'&').replace(/"/g,'"')
|
const esc = (t) => String(t).replace(/&/g,'&').replace(/"/g,'"')
|
||||||
.replace(/</g,'<').replace(/>/g,'>');
|
.replace(/</g,'<').replace(/>/g,'>');
|
||||||
// KaTeX renders after the pane is populated. If the CDN is unreachable the
|
// KaTeX renders after the pane is populated, replacing the element's contents.
|
||||||
// raw TeX stays visible, which is ugly but still readable, rather than blank.
|
// The raw TeX is written into the element first so an unreachable CDN degrades
|
||||||
|
// to ugly-but-readable source rather than six blank boxes. Verified by aborting
|
||||||
|
// the katex request in a browser test.
|
||||||
function typeset(root) {
|
function typeset(root) {
|
||||||
if (typeof katex === 'undefined') return;
|
if (typeof katex === 'undefined') return;
|
||||||
(root||document).querySelectorAll('.tex[data-tex],.tex-inline[data-tex]').forEach(n => {
|
(root||document).querySelectorAll('.tex[data-tex],.tex-inline[data-tex]').forEach(n => {
|
||||||
@@ -1174,11 +1176,11 @@ function drawStage() {
|
|||||||
'<div class="text-slate-600 uppercase text-[9px]">produces</div><div class="text-slate-300 mt-0.5">'+s.produces+'</div></div></div>'+
|
'<div class="text-slate-600 uppercase text-[9px]">produces</div><div class="text-slate-300 mt-0.5">'+s.produces+'</div></div></div>'+
|
||||||
(s.math ? '<div class="bg-slate-950/60 rounded-lg border border-slate-800/70 px-3 py-2.5 overflow-x-auto">'+
|
(s.math ? '<div class="bg-slate-950/60 rounded-lg border border-slate-800/70 px-3 py-2.5 overflow-x-auto">'+
|
||||||
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1">core relation</div>'+
|
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1">core relation</div>'+
|
||||||
(Array.isArray(s.math)?s.math:[s.math]).map(m=>'<div class="tex" data-tex="'+esc(m)+'"></div>').join('')+'</div>' : '')+
|
(Array.isArray(s.math)?s.math:[s.math]).map(m=>'<div class="tex" data-tex="'+esc(m)+'">'+esc(m)+'</div>').join('')+'</div>' : '')+
|
||||||
(s.symbols ? '<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">symbols</div>'+
|
(s.symbols ? '<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">symbols</div>'+
|
||||||
'<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1">'+
|
'<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1">'+
|
||||||
Object.keys(s.symbols).map(k=>'<div class="flex gap-2 items-baseline">'+
|
Object.keys(s.symbols).map(k=>'<div class="flex gap-2 items-baseline">'+
|
||||||
'<span class="tex-inline shrink-0" data-tex="'+esc(k)+'"></span>'+
|
'<span class="tex-inline shrink-0" data-tex="'+esc(k)+'">'+esc(k)+'</span>'+
|
||||||
'<span class="text-[11px] text-slate-500 leading-snug">'+s.symbols[k]+'</span></div>').join('')+
|
'<span class="text-[11px] text-slate-500 leading-snug">'+s.symbols[k]+'</span></div>').join('')+
|
||||||
'</div></div>' : '')+
|
'</div></div>' : '')+
|
||||||
'<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">why it is done this way</div>'+
|
'<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">why it is done this way</div>'+
|
||||||
@@ -1204,6 +1206,8 @@ function drawStage() {
|
|||||||
typeset(el('me-body'));
|
typeset(el('me-body'));
|
||||||
}
|
}
|
||||||
loaders.methods = loadMethods;
|
loaders.methods = loadMethods;
|
||||||
|
// A slow CDN can land katex after the pane has already rendered.
|
||||||
|
window.addEventListener('load', () => typeset());
|
||||||
|
|
||||||
/* ---------------- BOOT ---------------- */
|
/* ---------------- BOOT ---------------- */
|
||||||
connectStream();
|
connectStream();
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ def main() -> None:
|
|||||||
|
|
||||||
# One worker, one event loop. The station owns mutable model state, so a
|
# One worker, one event loop. The station owns mutable model state, so a
|
||||||
# second worker would give you two divergent forecasters sharing a socket.
|
# second worker would give you two divergent forecasters sharing a socket.
|
||||||
|
# timeout_graceful_shutdown bounds the wait for in-flight requests. Without
|
||||||
|
# it, the dashboard's server-sent-events connection never completes, so a
|
||||||
|
# stop blocks until systemd's 90 s timeout and ends in SIGKILL. Measured:
|
||||||
|
# with one stream client open, shutdown went from "never" to under 2 s.
|
||||||
uvicorn.run("ashvale.api:app", host=args.host, port=args.port,
|
uvicorn.run("ashvale.api:app", host=args.host, port=args.port,
|
||||||
reload=args.reload, workers=1, log_level="info",
|
reload=args.reload, workers=1, log_level="info",
|
||||||
limit_concurrency=32)
|
limit_concurrency=32, timeout_graceful_shutdown=5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user