From e0ad6e3defd613a7fad63cf10e0e34a51686a365 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:35:57 +0900 Subject: [PATCH] fix(serve): dispatch /rpc off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_request reads the whole kb off disk — seconds of work on a large one. called inline from the async endpoint it blocked the loop, so every other request queued behind it. on a 1512-claim kb a liveness probe took 8.4s while a kb.status was in flight, and the console reported the endpoint unreachable while it was answering fine. dispatch now goes through a worker thread, the same way the /mcp surface already runs its sync tools. the actor and trust contextvars move inside the worker so two concurrent calls can't overwrite each other's caller identity. also stops the console serving its spa shell without a cache-control. index.html names the hashed bundle, so with only last-modified a browser applies heuristic freshness and keeps replaying the shell it cached before — after an upgrade that means the previous build running against the new backend, which crashed the render tree once the list envelope changed shape. the shell is no-cache now (still a 304 off the etag); assets/ is content-hashed and stays immutable. --- src/vouch/http_server.py | 28 ++++++++++++++++++------ src/vouch/web/console.py | 19 +++++++++++++++-- tests/test_console.py | 23 ++++++++++++++++++++ tests/test_http_server.py | 45 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) 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"