Skip to content

fix(serve): dispatch /rpc off the event loop - #590

Merged
plind-junior merged 5 commits into
testfrom
fix/rpc-event-loop-blocking
Jul 29, 2026
Merged

fix(serve): dispatch /rpc off the event loop#590
plind-junior merged 5 commits into
testfrom
fix/rpc-event-loop-blocking

Conversation

@plind-junior

@plind-junior plind-junior commented Jul 29, 2026

Copy link
Copy Markdown
Member

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.

What changed

Why

What might break

VEP

Tests

  • make check passes locally (lint + mypy + pytest)
  • New / changed behaviour has a test
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

  • Performance

    • Improved RPC request handling so slow operations no longer block health checks and other requests.
  • Bug Fixes

    • Updated console caching behavior so application updates are detected promptly.
    • Retained long-term caching for versioned static assets to support faster load times.
  • Tests

    • Added coverage for responsive health checks during slow RPC requests.
    • Added verification for SPA shell revalidation and static asset caching.

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.
@github-actions github-actions Bot added review-ui browser review ui mcp mcp, jsonl, and http surfaces tests tests and fixtures size: S 50-199 changed non-doc lines labels Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The /rpc handler now runs synchronous request processing in a worker thread, while the console distinguishes cache behavior for the SPA shell and hashed assets. Tests cover concurrent health checks during slow RPC handling and the new cache-control headers.

Changes

HTTP serving behavior

Layer / File(s) Summary
Threadpooled RPC dispatch
src/vouch/http_server.py, tests/test_http_server.py
The /rpc handler moves synchronous request processing into run_in_threadpool with scoped actor and trust contexts; a concurrency test verifies /health remains responsive during slow RPC work.
SPA cache headers
src/vouch/web/console.py, tests/test_console.py
The SPA shell receives no-cache, while hashed assets receive long-lived immutable caching; tests cover root, routed shell, and asset responses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: dripsmvcp

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant HTTPServer
  participant Threadpool
  participant JSONLServer
  Client->>HTTPServer: POST /rpc
  HTTPServer->>Threadpool: Dispatch synchronous RPC work
  Threadpool->>JSONLServer: handle_request(envelope)
  JSONLServer-->>Threadpool: Return RPC response
  Threadpool-->>HTTPServer: Return result
  HTTPServer-->>Client: Send HTTP response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: moving /rpc request handling off the event loop.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rpc-event-loop-blocking

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the ci: failing ci is red label Jul 29, 2026
@github-actions github-actions Bot added ci: passing ci is green and removed ci: failing ci is red labels Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/test_console.py (1)

115-137: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

cover the complete cache contract.

These tests only check cache-control substrings. They do not prove that an etag is emitted and that a conditional shell request returns 304. /assets/app.js is also not a content-hashed filename, so the immutable assertion does not exercise the stated hashed-asset boundary. Add a real hashed fixture and conditional-request assertion, or confirm equivalent coverage exists elsewhere in this test file.

example conditional-request assertion
     for path in ("/", "/review"):
         res = client.get(path)
         assert res.status_code == 200
         assert "no-cache" in res.headers.get("cache-control", ""), path
+        if path == "/":
+            etag = res.headers["etag"]
+            assert client.get(
+                path, headers={"if-none-match": etag}
+            ).status_code == 304
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_console.py` around lines 115 - 137, Expand the cache-contract
tests around test_index_is_revalidated_on_every_load and
test_hashed_assets_stay_cacheable to assert that the shell response emits an
ETag and that a subsequent request with If-None-Match returns 304. Replace the
/assets/app.js fixture with a genuinely content-hashed asset filename and verify
that asset retains immutable caching, or reuse equivalent existing coverage in
this test file.
tests/test_http_server.py (1)

240-278: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sleep-based synchronization risks flakiness; also no coverage for actor-identity isolation.

Two observations on this new test:

  1. time.sleep(0.2) at Line 268 to "let the slow call reach the handler" plus the elapsed < 0.5 bound at Line 278 is timing-dependent — under CI contention this can intermittently fail (or pass without actually exercising concurrency, if the RPC thread hasn't reached the handler yet). A threading.Event set inside slow() right before the sleep(1.0) would let the test wait deterministically for the slow call to start before hitting /health, and would be more robust than a fixed delay.
  2. The PR objective states actor/trust context is preserved "across concurrent requests," but this test only asserts non-blocking behavior — it doesn't verify that concurrent /rpc calls with different X-Vouch-Agent/bearer values keep their actor identity isolated. Given _dispatch is the load-bearing mechanism for that guarantee, a dedicated test (e.g., two concurrent calls with different agents, recording jsonl_server._actor.get() inside a patched handler) would directly cover the claim.
♻️ Sketch for synchronized start instead of fixed sleep
     import time

     real = http_server.jsonl_server.handle_request

+    started = threading.Event()
+
     def slow(envelope: dict) -> dict:
+        started.set()
         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
+        assert started.wait(timeout=5), "slow rpc never reached the handler"
         start = time.monotonic()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_http_server.py` around lines 240 - 278, Make
test_slow_rpc_does_not_block_other_requests deterministic by adding a
threading.Event that slow() sets before sleeping, then wait on that event
instead of using time.sleep(0.2) to coordinate startup while preserving the
liveness assertion. Add a separate concurrent RPC test covering _dispatch actor
isolation: issue requests with distinct X-Vouch-Agent or bearer identities,
record jsonl_server._actor.get() inside the patched handler, and assert each
request observes only its own actor context.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/http_server.py`:
- Around line 205-229: Serialize `/rpc` and `/mcp` worker execution for mutating
approval requests so concurrent handlers cannot interleave read-then-write
operations. Extend the existing synchronization around `handle_request` and the
approval flow to cover `store.put_*`/`update_*`, `move_proposal_to_decided()`,
and audit indexing, rather than only `log_event`; preserve concurrent handling
for non-mutating requests where possible.

In `@src/vouch/web/console.py`:
- Around line 69-75: Update the added comment block above the SPA shell caching
logic to use lowercase prose throughout, while preserving literal protocol and
technical names such as SPA, Last-Modified, browser, ETag, 304, and Vite as
required.
- Around line 80-81: Update the cache-header decision in the request path
handling to classify the resolved candidate rather than the raw rel value: after
the traversal check, derive the root-relative POSIX path with
candidate.relative_to(root).as_posix() and pass that value to _cache_headers.
Add a regression test covering an in-root dot-segment path such as
/assets/../index.html, ensuring it receives shell caching.

---

Nitpick comments:
In `@tests/test_console.py`:
- Around line 115-137: Expand the cache-contract tests around
test_index_is_revalidated_on_every_load and test_hashed_assets_stay_cacheable to
assert that the shell response emits an ETag and that a subsequent request with
If-None-Match returns 304. Replace the /assets/app.js fixture with a genuinely
content-hashed asset filename and verify that asset retains immutable caching,
or reuse equivalent existing coverage in this test file.

In `@tests/test_http_server.py`:
- Around line 240-278: Make test_slow_rpc_does_not_block_other_requests
deterministic by adding a threading.Event that slow() sets before sleeping, then
wait on that event instead of using time.sleep(0.2) to coordinate startup while
preserving the liveness assertion. Add a separate concurrent RPC test covering
_dispatch actor isolation: issue requests with distinct X-Vouch-Agent or bearer
identities, record jsonl_server._actor.get() inside the patched handler, and
assert each request observes only its own actor context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 81eb550c-0810-448f-843c-64563f2fe17c

📥 Commits

Reviewing files that changed from the base of the PR and between 763933e and 8b5a377.

📒 Files selected for processing (4)
  • src/vouch/http_server.py
  • src/vouch/web/console.py
  • tests/test_console.py
  • tests/test_http_server.py

Comment thread src/vouch/http_server.py
Comment on lines 205 to +229
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for existing locking/serialization around KB writes now that
# handle_request can run concurrently on real threads.
rg -n 'Lock\(|RLock\(|Semaphore\(|filelock|fcntl|with.*lock' src/vouch --type py -C2

Repository: vouchdev/vouch

Length of output: 10649


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== affected file outline =="
ast-grep outline src/vouch/http_server.py --view compact || true

echo "== http_server relevant lines =="
sed -n '170,245p' src/vouch/http_server.py

echo "== storage lock import/usages =="
sed -n '565,620p' src/vouch/storage.py
sed -n '480,510p' src/vouch/storage.py

echo "== audit lock definition/usages =="
sed -n '40,75p' src/vouch/audit.py
sed -n '140,165p' src/vouch/audit.py

echo "== storage write path candidates =="
rg -n 'def (create|update|delete|load|save|write|read|jsonl|append|replace|json)|open\(|Path\.|# lock|with .*lock|fsync|flush' src/vouch/storage.py src/vouch/audit.py src/vouch/jsonl_server.py src/vouch/proposals.py -g '*.py' -C2

Repository: vouchdev/vouch

Length of output: 20987


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== proposals lock/content =="
sed -n '1,260p' src/vouch/proposals.py
rg -n 'lock|with|open\("x"|Path\.read|Path\.write|mcp|lockfile|fcntl|LOCK_EX' src/vouch/proposals.py -C2

echo "== jsonl_server handle_request outline and file storage calls =="
ast-grep outline src/vouch/jsonl_server.py --view names | sed -n '1,220p' || true
rg -n 'def handle_request|proposals\.approve|storage\.update_|storage\.put_|storage\.delete_|storage\.' src/vouch/jsonl_server.py src/vouch/proposals.py src/vouch/storage.py -C3 | sed -n '1,260p'

echo "== anyio version/deps if present =="
if [ -f pyproject.toml ]; then rg -n 'anyio|uvicorn|starlette' pyproject.toml p -C1 || true; fi

Repository: vouchdev/vouch

Length of output: 20500


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== jsonl_server handle_request implementation =="
sed -n '945,1010p' src/vouch/jsonl_server.py

echo "== http imports for run_in_threadpool and thread pool settings =="
rg -n 'from stdlib|run_in_threadpool|threadpool|workers|limit_workers|_rpc|FastAPI' src/vouch/http_server.py src/vouch --type py -g '*.py' -C2

echo "== storage file operations around durable writes =="
sed -n '700,905p' src/vouch/storage.py
sed -n '1030,1145p' src/vouch/storage.py
sed -n '1,220p' src/vouch/storage.py | sed -n '160,215p'

echo "== deterministic shape from source =="
python3 - <<'PY'
from pathlib import Path
for path in ['src/vouch/storage.py','src/vouch/proposals.py','src/vouch/jsonl_server.py','src/vouch/http_server.py','src/vouch/audit.py']:
    p=Path(path)
    text=p.read_text()
    print(f"\n{path}: runs_on_event_loop_line={any('run in_threadpool' in line for line in text.splitlines())}, has_lock_words={any(w in text for w in ['Lock(', 'RLock(', 'Semaphore(', 'fcntl.flock', 'msvcrt.locking', 'with _audit_lock'])}")
PY

Repository: vouchdev/vouch

Length of output: 31179


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== approve implementation relevant sections =="
sed -n '760,970p' src/vouch/proposals.py
sed -n '995,1050p' src/vouch/proposals.py
sed -n '1220,1235p' src/vouch/proposals.py

echo "== storage delete/update path coverage =="
sed -n '1145,1235p' src/vouch/storage.py
sed -n '938,1038p' src/vouch/storage.py

echo "== candidate tests around MCP sync or /rpc concurrency =="
rg -n 'run_in_threadpool|handle_request|kb\.approve|proposals\.approve|thread|concurrent' tests src/tests tests --type py -C2 | sed -n '1,240p' || true

Repository: vouchdev/vouch

Length of output: 32128


Serialize or lock KB mutations before offloading /rpc to threadpool workers.

handle_request now calls synchronous MCP handlers from real worker threads, and /rpc dispatches the same handlers. The approval flow performs read-then-write checks around store.put_*/update_*, move_proposal_to_decided(), and audit indexing without a cross-thread/global guard, so concurrent /mcp or /rpc writes can interleave and leave artifacts/audit state inconsistent. The existing lock is only around log_event; it should cover the full mutating approval write path or the workers should be serialized at the request boundary before the storage layer changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/http_server.py` around lines 205 - 229, Serialize `/rpc` and `/mcp`
worker execution for mutating approval requests so concurrent handlers cannot
interleave read-then-write operations. Extend the existing synchronization
around `handle_request` and the approval flow to cover `store.put_*`/`update_*`,
`move_proposal_to_decided()`, and audit indexing, rather than only `log_event`;
preserve concurrent handling for non-mutating requests where possible.

Source: Path instructions

Comment thread src/vouch/web/console.py
Comment on lines +69 to +75
# 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

keep the added comment prose lowercase.

The new comment block uses sentence case and uppercase prose. Please lowercase it while preserving literal protocol names where necessary.

As per path instructions, “use lowercase prose in comments and review notes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/console.py` around lines 69 - 75, Update the added comment
block above the SPA shell caching logic to use lowercase prose throughout, while
preserving literal protocol and technical names such as SPA, Last-Modified,
browser, ETag, 304, and Vite as required.

Source: Path instructions

Comment thread src/vouch/web/console.py
Comment on lines +80 to +81
def _cache_headers(rel: str) -> dict[str, str]:
return _ASSET_CACHE if rel.startswith("assets/") else _SHELL_CACHE

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | grep -E '(^|/)console\.py$|AGENTS\.md|CLAUDE\.md|pytest|requirements|pyproject|README' | sed -n '1,200p'

echo "== console outline =="
ast-grep outline src/vouch/web/console.py --view expanded || true

echo "== relevant console sections =="
cat -n src/vouch/web/console.py | sed -n '1,120p'
printf '\n--- 180-215 ---\n'
cat -n src/vouch/web/console.py | sed -n '180,215p'

echo "== tests mentioning console/static/assets/cache =="
rg -n "_cache_headers|assets/|static|FileResponse|index.html|dot|\\./|\\.\\." tests src/vouch -S || true

Repository: vouchdev/vouch

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== test_console relevant sections =="
cat -n tests/test_console.py | sed -n '88,155p'
printf '\n--- tests mentioning console path params or console app ---\n'
rg -n "build_console_app|full_path|assets/|index\\.html|dot|\\./|\\.\\." tests/test_console.py -S

echo "== behavioral probe: path normalization in current logic =="
python3 - <<'PY'
from pathlib import Path
root = Path("/tmp/webapp/dist")
index = root / "index.html"
for rel in ("assets/app.js", "../index.html", "assets/../index.html"):
    candidate = (root / rel).resolve()
    try:
        resolved_rel = candidate.relative_to(root).as_posix()
    except ValueError:
        resolved_rel = "(rejected)"
    print(f"{rel:25} candidate={candidate} file={candidate.is_file()} rel_prefix={rel.startswith('assets/')} resolved_rel={resolved_rel}")
PY

Repository: vouchdev/vouch

Length of output: 5415


classify the resolved path, not the raw request path.

An in-root alias such as /assets/../index.html resolves to the SPA shell, but rel still starts with assets/, so the shell can receive immutable asset caching. Use candidate.relative_to(root).as_posix() for the cache header decision after the traversal check, and add a regression test for dot-segment paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/console.py` around lines 80 - 81, Update the cache-header
decision in the request path handling to classify the resolved candidate rather
than the raw rel value: after the traversal check, derive the root-relative
POSIX path with candidate.relative_to(root).as_posix() and pass that value to
_cache_headers. Add a regression test covering an in-root dot-segment path such
as /assets/../index.html, ensuring it receives shell caching.

@plind-junior
plind-junior merged commit 2722d4f into test Jul 29, 2026
22 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci: passing ci is green mcp mcp, jsonl, and http surfaces review-ui browser review ui size: S 50-199 changed non-doc lines tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant