diff --git a/src/vouch/http_server.py b/src/vouch/http_server.py index 081cda24..da91c9a1 100644 --- a/src/vouch/http_server.py +++ b/src/vouch/http_server.py @@ -55,6 +55,7 @@ import uvicorn import yaml from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -201,18 +202,31 @@ async def _rpc(request: Request) -> JSONResponse: )) agent = request.headers.get("X-Vouch-Agent") - reset = jsonl_server._actor.set(agent) if agent else None bearer = trust_mod.matched_bearer_token( request.headers.get("authorization"), tuple(getattr(request.app.state, "vouch_bearer_tokens", ()) or ()), ) trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer) - try: - with trust_mod.trust_context(trust): - response = jsonl_server.handle_request(envelope) - finally: - if reset is not None: - jsonl_server._actor.reset(reset) + + def _dispatch() -> dict[str, Any]: + # The actor and the trust marker are both ContextVars. They're set + # here, inside the worker, so each request mutates only its own copy + # of the context — set on the event loop instead, two concurrent + # calls would overwrite each other's caller identity. + reset = jsonl_server._actor.set(agent) if agent else None + try: + with trust_mod.trust_context(trust): + return jsonl_server.handle_request(envelope) + finally: + if reset is not None: + jsonl_server._actor.reset(reset) + + # handle_request is synchronous and reads the whole KB off disk — seconds + # of work on a large one. Called inline it blocks the event loop, so every + # other request (health probes included) queues behind it and clients see + # the endpoint as hung. The /mcp surface already dispatches its sync tools + # through a worker thread; this keeps /rpc consistent with it. + response = await run_in_threadpool(_dispatch) return _json(200, response) diff --git a/src/vouch/web/console.py b/src/vouch/web/console.py index 8d768d40..e778bde5 100644 --- a/src/vouch/web/console.py +++ b/src/vouch/web/console.py @@ -66,6 +66,21 @@ def resolve_console_dir( return None +# The SPA shell names the hashed bundle, so it must be revalidated on every +# load. Served with only Last-Modified a browser applies heuristic freshness +# and keeps replaying the shell it cached before — after an upgrade that means +# the previous console build running against the new backend, which fails in +# whatever way the response shapes changed. `no-cache` still allows a 304 off +# the ETag, so the cost is one conditional request. Everything vite emits into +# assets/ is content-hashed and can be kept forever. +_SHELL_CACHE = {"cache-control": "no-cache"} +_ASSET_CACHE = {"cache-control": "public, max-age=31536000, immutable"} + + +def _cache_headers(rel: str) -> dict[str, str]: + return _ASSET_CACHE if rel.startswith("assets/") else _SHELL_CACHE + + def _err(status: int, code: str, message: str) -> JSONResponse: """The vouch-native error envelope the SPA already understands.""" return JSONResponse( @@ -182,8 +197,8 @@ async def _spa(request: Request) -> Response: except ValueError: candidate = index if candidate.is_file(): - return FileResponse(candidate) - return FileResponse(index) + return FileResponse(candidate, headers=_cache_headers(rel)) + return FileResponse(index, headers=_SHELL_CACHE) routes = [ Route("/proxy", _proxy, methods=_PROXY_METHODS), diff --git a/tests/test_console.py b/tests/test_console.py index 67cbff19..c716bafd 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -112,6 +112,29 @@ def test_real_static_asset_is_served(console_dir: Path) -> None: assert "console.log" in res.text +def test_index_is_revalidated_on_every_load(console_dir: Path) -> None: + """The SPA shell must never be served from cache without a revalidation. + + index.html names the hashed bundle. With only Last-Modified on it a browser + applies heuristic freshness and keeps replaying the shell it saw before — + so after `pip install -U vouch-kb` a returning reviewer silently runs the + *previous* console build against the new backend, and any response shape + that changed in between crashes the render tree. + """ + client = _loopback_client(build_console_app(console_dir)) + for path in ("/", "/review"): + res = client.get(path) + assert res.status_code == 200 + assert "no-cache" in res.headers.get("cache-control", ""), path + + +def test_hashed_assets_stay_cacheable(console_dir: Path) -> None: + """Only the shell is revalidated — content-hashed assets are immutable.""" + res = _loopback_client(build_console_app(console_dir)).get("/assets/app.js") + assert res.status_code == 200 + assert "immutable" in res.headers.get("cache-control", "") + + # --- the /proxy bridge ------------------------------------------------------ diff --git a/tests/test_http_server.py b/tests/test_http_server.py index d6f3aaa8..8b33cf5b 100644 --- a/tests/test_http_server.py +++ b/tests/test_http_server.py @@ -11,6 +11,7 @@ import pytest +from vouch import http_server from vouch.http_server import _VouchHTTPServer, make_server, run_http from vouch.models import Claim, ProposalStatus from vouch.storage import KBStore @@ -231,3 +232,47 @@ def test_negative_content_length_rejected(base_url: str) -> None: return status_line = response.partition(b"\r\n")[0] assert b" 4" in status_line, status_line + + +# --- event loop stays responsive ------------------------------------------ + + +def test_slow_rpc_does_not_block_other_requests( + kb: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A slow kb.* call must not stall every other request on the server. + + ``handle_request`` is synchronous and CPU/IO-bound — on a real KB a + ``kb.status`` can run for seconds. Dispatched inline from the async + endpoint it blocks the event loop, so liveness probes and every other + in-flight request queue behind it and the console reports the endpoint + as unreachable. It has to run off the loop. + """ + import time + + real = http_server.jsonl_server.handle_request + + def slow(envelope: dict) -> dict: + time.sleep(1.0) + return real(envelope) + + monkeypatch.setattr(http_server.jsonl_server, "handle_request", slow) + + gen = _serve(make_server("127.0.0.1", 0)) + url = next(gen) + try: + rpc = threading.Thread( + target=_post, args=(url, {"id": "slow", "method": "kb.status"}), daemon=True + ) + rpc.start() + time.sleep(0.2) # let the slow call reach the handler + start = time.monotonic() + code, body = _get(url, "/health") + elapsed = time.monotonic() - start + rpc.join(timeout=10) + finally: + with pytest.raises(StopIteration): + next(gen) + + assert code == 200 and body == {"ok": True} + assert elapsed < 0.5, f"/health waited {elapsed:.2f}s behind the slow rpc call"