Skip to content

fix(server): offload blocking sync work out of async v2 handlers (#732) - #805

Merged
frankbria merged 3 commits into
mainfrom
fix/732-offload-blocking-handlers
Jul 4, 2026
Merged

fix(server): offload blocking sync work out of async v2 handlers (#732)#805
frankbria merged 3 commits into
mainfrom
fix/732-offload-blocking-handlers

Conversation

@frankbria

@frankbria frankbria commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #732

Problem

Five async def v2 handlers called long-blocking core functions (full test-suite runs, LLM calls, subprocess installs) directly on the event-loop thread, freezing all other requests — SSE heartbeats, terminal/chat WebSockets, and /health — for minutes.

Change

Wrap the heavy call in each handler with await run_in_threadpool(...) (fastapi.concurrency), the pattern already used in settings_v2.py / prd_v2.py:

Handler Blocking call
proof_v2.py POST /proof/run run_proof() (pytest/ruff gate runs)
gates_v2.py POST /gates/run gates.run()
discovery_v2.py POST /{id}/generate-prd generate_prd_from_discovery() (LLM)
diagnose_v2.py POST /{id}/diagnose DiagnosticAgent.analyze() (LLM)
environment_v2.py POST /install ToolInstaller.install_tool() (subprocess)

Tests

  • New tests/ui/test_event_loop_offload.py: TDD regression test (RED before fix) — monkeypatches run_proof with a 1.5s time.sleep blocker and asserts a concurrent /health request completes in <1s over httpx.ASGITransport.
  • tests/ui/test_proof_v2.py + tests/ui/test_v2_routers_integration.py: 125 passed. ruff check clean.

Acceptance criteria

  • Listed handlers offload via run_in_threadpool
  • A proof run no longer blocks a concurrent /health request (regression test)

Known Limitations

Summary by CodeRabbit

  • New Features
    • Long-running v2 operations now run in the background, helping the app stay responsive during report generation, installs, diagnostics, gates, and proof runs.
  • Bug Fixes
    • Reduced the chance of request handling freezing while blocking tasks complete.
  • Tests
    • Added coverage to verify that proof execution does not block the health endpoint and the event loop remains responsive.

run_proof, gates.run, generate_prd_from_discovery, DiagnosticAgent.analyze,
and ToolInstaller.install_tool ran directly on the event-loop thread inside
async handlers, freezing /health, SSE, and WebSockets for minutes. Wrap each
in run_in_threadpool (existing settings_v2/prd_v2 convention). Adds a
concurrency regression test proving /health responds during a proof run.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 56 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 77efdae7-1324-4861-9202-b87381831970

📥 Commits

Reviewing files that changed from the base of the PR and between f2f3096 and d06f48b.

📒 Files selected for processing (2)
  • docs/PHASE_2_DEVELOPER_GUIDE.md
  • tests/ui/test_event_loop_offload.py

Walkthrough

Five FastAPI v2 routers (diagnose, discovery, environment, gates, proof) were updated to offload their previously synchronous, blocking core calls onto worker threads using run_in_threadpool, with results awaited. A new test module verifies the proof endpoint no longer blocks the event loop during execution.

Changes

Threadpool Offload for Blocking Endpoints

Layer / File(s) Summary
Diagnose endpoint offload
codeframe/ui/routers/diagnose_v2.py
Imports run_in_threadpool and wraps agent.analyze(...) in an awaited threadpool call.
Discovery PRD generation offload
codeframe/ui/routers/discovery_v2.py
Imports run_in_threadpool and wraps prd_discovery.generate_prd_from_discovery(...) in an awaited threadpool call within generate_prd.
Environment tool install offload
codeframe/ui/routers/environment_v2.py
Imports run_in_threadpool and wraps installer.install_tool(...) in an awaited threadpool call within install_tool.
Gates run offload
codeframe/ui/routers/gates_v2.py
Imports run_in_threadpool and wraps gates.run(...) in an awaited threadpool call within run_gates.
Proof run offload and event-loop test
codeframe/ui/routers/proof_v2.py, tests/ui/test_event_loop_offload.py
Imports run_in_threadpool and wraps run_proof(...) in an awaited threadpool call within run_proof_endpoint; new test confirms concurrent /health requests stay responsive while a proof run blocks.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProofV2Router
  participant ThreadPool
  participant EventLoop

  Client->>ProofV2Router: POST /api/v2/proof/run
  ProofV2Router->>ThreadPool: run_in_threadpool(run_proof, workspace)
  Client->>ProofV2Router: GET /health
  ProofV2Router->>EventLoop: handle /health immediately
  EventLoop-->>Client: 200 OK (fast)
  ThreadPool-->>ProofV2Router: proof result
  ProofV2Router-->>Client: RunProofResponse
Loading

Poem

A rabbit hops through threads so light,
No more blocking, day or night 🐇
Proof and gates now run apart,
Health stays quick, right from the start,
Threadpools whirl, the event loop free—
Hooray for async harmony!

🚥 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 clearly and concisely summarizes the main change: offloading blocking sync work from async v2 handlers.
Linked Issues check ✅ Passed The five listed handlers are offloaded with run_in_threadpool, and a regression test confirms /health stays responsive during proof runs.
Out of Scope Changes check ✅ Passed The changes stay focused on the blocking-handler offload fix and the required regression test, with no obvious unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/732-offload-blocking-handlers

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

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review — fix/732-offload-blocking-handlers

This is a clean, well-scoped fix that applies the correct pattern consistently. The approach matches what's already done in settings_v2.py / prd_v2.py, and each change is minimal and targeted.

What looks good

  • Consistent pattern — all 5 handlers use fastapi.concurrency.run_in_threadpool uniformly. No new abstractions, no half-implementations.
  • Inline comments# Offload: … (#732). on each change satisfies the CLAUDE.md convention of explaining non-obvious WHY (a bare await with no comment here would be confusing).
  • Regression test concepttest_event_loop_offload.py is a RED-before-fix test that proves the fix works end-to-end over a real ASGI transport. That's exactly the right level of test for a concurrency fix.
  • Argument passingrun_in_threadpool(func, *args, **kwargs) is the correct call signature; the template_id=template_id kwarg in discovery_v2.py and the full=, gate= kwargs in proof_v2.py are passed through correctly.

Issues to address

1. Thread safety assumption is unverified (medium)

run_in_threadpool runs the function on AnyIO's default thread pool. If any of the wrapped functions touch shared mutable state (module-level caches, unsynchronized singletons) or rely on SQLite in a way that isn't thread-safe, concurrent requests could race.

The PR description says "fast SQLite reads stay on the loop" — but run_proof and gates.run almost certainly write run records (gate evidence, proof run records) to SQLite. SQLAlchemy and raw sqlite3 are both thread-safe in serialized mode by default, but a quick grep or comment confirming this would give future reviewers confidence:

grep -r "sqlite3.connect\|check_same_thread" codeframe/core/

If connections were opened on the main thread and passed to the worker, or if WAL mode isn't enabled, there is a latent risk here. Even a brief note in the PR (or a comment in the wrapped call sites) explaining the safety guarantee would help.

2. Regression test only covers proof_v2 (low)

gates_v2 is the other "blocks for minutes" handler (also runs pytest/ruff). The mechanism is identical, so a parametrized variant or a note explaining the omission would make the intent of the test file clearer and prevent silent regression.

3. asyncio.sleep(0.05) yield is a potential flake source (low)

proof_task = asyncio.create_task(client.post("/api/v2/proof/run", json={}))
await asyncio.sleep(0.05)   # yields to let the proof handler start

50 ms is usually sufficient, but on a heavily loaded CI host the thread pool may not have scheduled the blocking work yet when the health check fires. If this test ever flakes, increasing the sleep or restructuring to use an event/synchronization primitive would help. Minor risk since BLOCK_SECONDS=1.5 gives a wide margin.


Minor nit

The test fixture creates a workspace but doesn't initialize any DB schema, so any code path in run_proof that hits the DB would fail before the slow_run_proof monkeypatch takes effect. This is intentional and fine — but a brief comment in the fixture clarifying that the monkeypatch replaces the entire function (no DB needed) would help readers understand why the fixture is minimal.


Summary

The fix is correct and the pattern is right. The two things worth resolving before merge:

  1. Confirm (or document) that SQLite connections used by run_proof / gates.run are thread-safe from a pool worker — or note explicitly that the thread pool serializes writes.
  2. Either add a test for gates_v2 or add a comment in test_event_loop_offload.py explaining why other handlers are intentionally omitted (same mechanism, not duplicated).

Everything else is minor. Good work keeping the change tight and consistent with the existing codebase pattern.

@frankbria

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@frankbria

Copy link
Copy Markdown
Owner Author

Review triage (claude-review findings)

1. Thread safety of SQLite writes from a pool worker (medium) — verified safe, documented here.
Workspace connections are never shared across threads: codeframe/core/workspace.py:_open_db opens a fresh connection per call inside the executing thread, with WAL + busy_timeout=5000 explicitly added for "multiple processes and background agent threads writing the same DB" (#686). run_proof/gates.run are the same core functions the conductor worker pool already calls from non-main threads. No code change needed.

2. Regression test only covers proof_v2 (low) — addressed in e009ca5.
Module docstring now states the rationale: proof-run is the criterion named in the issue; the other four handlers use the identical run_in_threadpool wrapper. (The live demo also exercised gates_v2 end-to-end: /health at 5–7ms during a real 5s gate run.)

3. asyncio.sleep(0.05) flake risk (low) — rebutted, no change.
The test is deterministic in both directions: with unfixed code the blocking handler delays the sleep(0.05) resume itself, so the measured /health elapsed still exceeds the 1.0s budget; with fixed code /health is fast regardless of threadpool scheduling. BLOCK_SECONDS=1.5 vs a 1.0s budget leaves the margin.

Nit (fixture comment) — addressed in e009ca5.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Follow-up Review

The author's triage comment in e009ca5 closes all three issues I raised. Quick confirmations:

Thread safety (medium, resolved) — Confirmed: workspace.py:_open_db opens a fresh connection per call inside the executing thread, with WAL and busy_timeout=5000. This matches the pattern used by the conductor worker pool, so the threadpool promotion here is safe.

Test async wiring — The test is a bare async def with no @pytest.mark.asyncio. Verified that pytest.ini sets asyncio_mode = auto, so this is fine.

asyncio.sleep(0.05) flake risk (low, rebutted) — The author's reasoning is sound: with unfixed code the 0.05s yield itself is delayed by the blocking handler, so the health-check timer still exceeds 1.0s. The margin (BLOCK_SECONDS=1.5 vs MAX_HEALTH_SECONDS=1.0) is wide. No change needed.

Regression test scope (low, resolved) — Module docstring now explains the scope rationale. Acceptable.


The change is minimal, consistent with the existing run_in_threadpool pattern in the codebase, and all raised concerns are addressed. No blocking issues remain. Ready to merge.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Update

No new commits since my last review (latest: d06f48b). My previous assessment stands — ready to merge.

All raised issues were addressed:

  • Thread safety confirmed via workspace.py:_open_db pattern (per-thread connections, WAL mode)
  • Test scope rationale documented in module docstring
  • asyncio.sleep(0.05) flake concern rebutted with sound reasoning

The developer guide addition in d06f48b is also correct and complete — the rule, code example, and WAL threading note all land in the right place in PHASE_2_DEVELOPER_GUIDE.md.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
codeframe/ui/routers/environment_v2.py (1)

219-221: 🧹 Nitpick | 🔵 Trivial

Threadpool offload looks correct; note shared thread-pool capacity.

installer.install_tool doesn't touch workspace, so there's no cross-thread DB concern here (unlike the diagnose/discovery endpoints). The wrapping itself is correct.

One operational note: Starlette/AnyIO's default thread pool is capped at 40 tokens shared across the whole app (sync dependencies + all run_in_threadpool calls). Since installs here can block "for minutes" per the added comment, several concurrent installs (or installs overlapping with other offloaded v2 endpoints) could exhaust that pool and delay unrelated sync work. Not a regression from this PR, but worth keeping in mind if install traffic grows — e.g. bumping anyio.to_thread.current_default_thread_limiter().total_tokens or bounding concurrent installs.

🤖 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 `@codeframe/ui/routers/environment_v2.py` around lines 219 - 221, The
threadpool offload in environment_v2 is correct, but long-running
`installer.install_tool` calls can consume Starlette/AnyIO’s shared default
thread pool and delay other sync work. In `environment_v2` around the
`run_in_threadpool(installer.install_tool, ...)` path, add a mitigation such as
bounding concurrent installs or increasing
`anyio.to_thread.current_default_thread_limiter().total_tokens` so install
traffic cannot starve unrelated offloaded endpoints.
🤖 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 `@codeframe/ui/routers/gates_v2.py`:
- Line 15: The `/api/v2/gates/run` path is currently dispatching `gates.run()`
through `run_in_threadpool(...)` without any workspace-level serialization, so
concurrent requests can race on the same repo. Add a per-`workspace.repo_path`
lock or queued execution in the router handler in `gates_v2.py` so only one gate
run can mutate a given workspace at a time. Use the route entrypoint that calls
`run_in_threadpool` and the underlying `gates.run()` flow as the place to
acquire/release the workspace lock before installing deps and running checks.

---

Nitpick comments:
In `@codeframe/ui/routers/environment_v2.py`:
- Around line 219-221: The threadpool offload in environment_v2 is correct, but
long-running `installer.install_tool` calls can consume Starlette/AnyIO’s shared
default thread pool and delay other sync work. In `environment_v2` around the
`run_in_threadpool(installer.install_tool, ...)` path, add a mitigation such as
bounding concurrent installs or increasing
`anyio.to_thread.current_default_thread_limiter().total_tokens` so install
traffic cannot starve unrelated offloaded endpoints.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4c75270-abb9-4f69-8ea8-908107d2c134

📥 Commits

Reviewing files that changed from the base of the PR and between dd05eee and f2f3096.

📒 Files selected for processing (6)
  • codeframe/ui/routers/diagnose_v2.py
  • codeframe/ui/routers/discovery_v2.py
  • codeframe/ui/routers/environment_v2.py
  • codeframe/ui/routers/gates_v2.py
  • codeframe/ui/routers/proof_v2.py
  • tests/ui/test_event_loop_offload.py

Comment thread codeframe/ui/routers/gates_v2.py
@frankbria

Copy link
Copy Markdown
Owner Author

Final feedback triage (CodeRabbit re-review)

Serialize gate runs per workspace (Minor) — acknowledged, not fixed here.
Concurrent gates.run on one repo was already possible via the CLI and the conductor worker pool; the only thing serializing overlapping HTTP requests before this PR was the frozen event loop — i.e., the bug itself. Workspace DB writes are WAL + busy_timeout (#686). If per-workspace serialization is wanted, it belongs in core (covering all entry points), not in one router — out of scope for #732.

Shared thread-pool capacity (Trivial) — acknowledged, no change.
CodeRabbit notes it is not a regression from this PR; already recorded in the PR's Known Limitations (40-token AnyIO pool; background-job semantics out of scope).

Gate summary: CI green on d06f48b, demo verified both acceptance criteria with outcome evidence, no unresolved Critical/Major findings. Merging.

@frankbria
frankbria merged commit f766bbe into main Jul 4, 2026
16 checks passed
@frankbria
frankbria deleted the fix/732-offload-blocking-handlers branch July 4, 2026 04:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1.5] Offload blocking sync work (pytest/ruff/LLM/installers) out of async handlers

1 participant