perf(cursor)!: move request protobuf framing off the async runtime - #272
Conversation
`build_run_frames` ran synchronously on a Tokio worker inside `open_turn`, taking 424 us for a 4 KiB prompt with 16 tools -- Claude Code's normal request shape, and roughly 4x over Tokio's 100 us blocking budget. With an image attached it reached 1.4-7.5 ms. Extract the bounded-offload pattern from #256 into a shared `cursor::offload` module (`cpu_slots` + `spawn_bounded`) rather than hand-rolling it a third time, and move `connect.rs`'s gzip decode onto it. The permit is moved into the blocking closure so cancellation cannot free a slot while an unabortable task is still running. `AgentRunParams` becomes owned so it can move into `spawn_blocking`, and `open_turn` takes it by value. In `forward` this costs zero clones: prompt, images, tools and model_id are already owned; only `cwd` gains a `to_string()`. Framing offloads unconditionally. It runs once per request, before the body sender is spawned and before `.send().await`, so the round trip is off every streaming path -- and unlike gzip's frame size no cheap input measure bounds the work, since tool-schema encoding grows nonlinearly (25.9 us for one tool against 395.4 us for 16). Base64 image decoding offloads above 64 KiB of estimated decoded bytes, using base64's exact 4:3 expansion as a sound pre-decode upper bound. The threshold keeps headroom deliberately: the new benchmark covers the decode alone, while the inline path also pays `cursor_selected_images`, which walks the message JSON and clones each base64 string before the offload decision is made. New CodSpeed benches cover framing and image decode. Both are pure sync functions, since CodSpeed runs under Valgrind. Refs #259
There was a problem hiding this comment.
Code Review
This pull request refactors and centralizes the offloading of blocking CPU-bound tasks (such as gzip decompression, base64 image decoding, and request protobuf framing) into a new shared offload module that uses a process-wide semaphore to limit concurrency. It also introduces size-based thresholds to determine whether to decode images inline or offload them, and updates AgentRunParams to use owned types. Feedback on these changes suggests improving panic propagation in the new spawn_bounded helper by checking if a JoinError is a panic and resuming it, rather than unconditionally mapping it to a generic std::io::Error.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Performance Changes
Comparing |
Code review of the framing offload found two behavior defects. Framing failures advanced ordered failover. `open_turn` mapped a framing error to `CursorError::internal`, which `map_client_error` stamps as `AdapterFailure::BeforeHeaders`, so `failover.rs` retried a deterministic local fault against every upstream -- each failing identically, since framing precedes any upstream contact -- and the client saw only `all upstreams failed`. This contradicted docs/upstreams-failover.md, which states Cursor adapter-owned errors return immediately. Move the framing offload out of `open_turn` into `forward`, so `open_turn` takes pre-built frames and does I/O only. The framing error now uses `own_error`, which sets `failure: None`, making it local by construction rather than by classification. It also lets the TODO(#170) retry reuse cheap `Bytes` clones instead of reframing. Sharing one semaphore across gzip decode and request preparation introduced cross-class head-of-line blocking that main does not have: response-path gzip is per-frame and latency-critical, request framing is once per request. Split into `gzip_slots` and `request_prep_slots` over one `spawn_bounded` helper, restoring gzip's pre-PR isolation. The doc comment no longer claims framing "cannot monopolize the slots" -- once-per-request bounds per-request multiplicity, not aggregate. Tests now cover the production wiring rather than a test-only replica, the cumulative multi-image threshold, and the load-bearing invariant that the permit lives inside the unabortable closure: moving it out makes the new cancellation test fail. Frame assertions now include the image bytes, MIME, tool description and schema, so dropping a payload field can no longer pass. `build_run_frames` and `decode_selected_images` are `#[doc(hidden)]`, and `AgentImage` no longer derives an unused `Clone` over decoded image bytes. A new bench measures the full inline path (extraction + clone + decode) that the 64 KiB threshold rests on: 54.55 us at 64 KiB against 112.7 us at 128 KiB. Refs #259
Add a behavioral test that the gzip and request-preparation admission classes are independent: saturating either semaphore must not stop the other from admitting work. Every prior test sampled one class at a time under a serializing lock, so pointing `spawn_bounded_gzip` back at `request_prep_slots()` would have left the suite green while restoring the response-path head-of-line blocking the split exists to remove. Verified non-vacuous by that exact mutation, which now fails in 5s. Reset `LAST_REQUEST_PREP_PATH` immediately before each asserted action. The marker is a process-wide test global written by several tests, so a stale value could satisfy an assertion and let a regression that stops recording the path pass depending on test order. Gate the capacity test's closures on per-task channels instead of a shared `Barrier`. A `spawn_blocking` task cannot be aborted, so an assertion panic left the closures parked on the barrier forever and Tokio teardown waited on them, turning a legible failure into a hung test process. Dropping the senders on unwind now wakes every closure. Refs #259
The wrapper-level isolation test proves `spawn_bounded_gzip` and `spawn_bounded_request_prep` draw on distinct semaphores, but it stays green if a production call site is rewired to the wrong class — the exact regression the split exists to prevent. Assert it where it matters instead: saturate every request-preparation permit and require the real `decode_gzip_frame_async` to still complete, then saturate every gzip permit and require the real `build_run_frames_async` and an over-threshold `decode_selected_images_async` to still complete. The gzip payload is incompressible and larger than `INLINE_GZIP_FRAME_BYTES`, so it provably takes the offload path rather than the inline one. Both directions are mutation-proven: pointing `decode_gzip_frame_async` at the request-preparation class, or `build_run_frames_async` at the gzip class, fails each test in 5s via its inner timeout rather than hanging. Refs #259
Greptile SummaryThis PR moves Cursor request framing and larger image decoding into bounded blocking tasks.
Confidence Score: 4/5The PR appears safe to merge, although the previously reported aggregate CPU-admission concern remains. The request-preparation and gzip paths retain separate CPU-sized semaphores with no shared aggregate gate, so concurrent traffic can admit both classes at full capacity. Files Needing Attention: src/adapters/cursor/offload.rs
|
| Filename | Overview |
|---|---|
| src/adapters/cursor/offload.rs | Adds bounded blocking-task helpers with independent admission classes and tests for capacity, isolation, panic propagation, and cancellation. |
| src/adapters/cursor/mod.rs | Offloads image decoding above the configured threshold and request framing before opening the Cursor turn while preserving adapter-local error shaping. |
| src/adapters/cursor/agent.rs | Converts agent-run inputs to owned values and changes open_turn to consume pre-built request frames. |
| src/adapters/cursor/connect.rs | Migrates gzip decompression to the shared bounded-offload helper and verifies admission-class isolation. |
| benches/gateway.rs | Adds representative Cursor framing and image extraction/decoding benchmarks. |
Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
…move-request-protobuf-framing-off-th # Conflicts: # .claude/agent-memory/review-review-pr-test-analyzer/MEMORY.md # .please/docs/review/rejected-findings.jsonl
|
/gemini review |
|
There was a problem hiding this comment.
Code Review
This pull request introduces bounded CPU offloading for Cursor's request framing and large-image decoding to prevent blocking the Tokio worker pool. It isolates response-path gzip decompression from request-path preparation using separate, CPU-sized semaphores to eliminate cross-workload head-of-line blocking. The changes also include comprehensive unit tests, integration tests, and benchmarks to validate the offloading thresholds and concurrency limits. The review feedback suggests a minor optimization to use '.clone()' instead of '.to_string()' on 'cwd' to avoid unnecessary formatting overhead.



Summary
Move Cursor request protobuf framing off Tokio worker threads.
build_run_framespreviously ran synchronously insideopen_turn: it took 424 µs for Claude Code's normal request shape (a 4 KiB prompt with 16 tools), roughly four times Tokio's 100 µs blocking budget, and 1.4–7.5 ms when a request included an image.This change:
src/adapters/cursor/offload.rswith sharedcpu_slots()andspawn_bounded()helpers, extracting the bounded-offload pattern from perf(cursor): decompress gzip response frames off the async runtime #256 instead of duplicating it a third time. The blocking closure acquires the permit so cancellation cannot release a slot while an unabortable task still runs. Cursor gzip decoding now uses the shared helper and deletes its private semaphore.AgentRunParamsowned so it can move intospawn_blocking, and changesopen_turnto take it by value.forwardadds no clones for the prompt, images, tools, ormodel_id; onlycwdgains a.to_string()..send().await, covering every streaming path. No cheap input metric safely bounds framing work because tool-schema encoding grows nonlinearly: 25.9 µs for one tool versus 395.4 µs for 16 tools.Why 64 KiB instead of 128 KiB?
The image benchmark measures base64 decoding alone, so choosing the largest power of two below 100 µs would not provide a sound inline-work bound. Although the 128 KiB median was 93.17 µs, a rerun measured a 100.5 µs mean, already within noise of the budget. Before reaching the offload decision, the inline path also runs
request::cursor_selected_images, which walks the message JSON and clones each base64 string—about 171 KiB of additional copying for a 128 KiB decoded image.A 64 KiB threshold preserves headroom and remains conservatively below the approximately 105 KiB crossover measured in #259 for the pre-split
decode_cursor_images, which included extraction.Benchmarks
New CodSpeed benchmarks cover pure synchronous functions because CodSpeed runs under Valgrind. Apple Silicon medians are below; #259 used an i7-9700K, so the ordering is comparable but absolute values differ.
Known remaining inline cost
JSON extraction and its base64 string clone still run on the Tokio worker.
cursor_selected_imagesborrows the requestValue, so moving extraction into a'staticclosure requires a larger refactor. This change does not regress that cost, and the retained cost is why the threshold keeps headroom.Documentation
No documentation update is needed because this change does not alter observable behavior, config keys, endpoints, CLI, providers, models, or defaults.
README.md,docs/, andsite/contain no inline-framing claim;wiki/is generated. This matches the #256 precedent (c36c619), which changed onlysrc/andbenches/.Closes #259
Milestone / spec
Issue #259 — Cursor request framing performance; no frozen milestone/spec change.
Checklist
cargo buildpassescargo testpasses (new behavior is covered; tests run without network/loopback where possible)cargo clippy --all-targets -- -D warningscleancargo fmt --all --checkcleanoffload.rsis 129 lines; existing larger Cursor modules are not expanded structurally by this focused change)docs/updated if this change deviates from it (not applicable; no deviation)README.md/site/as applicable (wiki/is generated; don't hand-edit) (not applicable; no user-facing change)Notes for reviewers
Please review the 64 KiB image threshold and permit lifetime closely. The permit intentionally moves into the blocking closure so dropping the awaiting future cannot over-admit unabortable blocking work.
Sharing one semaphore across gzip decoding, framing, and image decoding introduced a test-isolation requirement. Slot-limit tests sample
cpu_slots().available_permits()as their bound, so any test that holds a permit without the sharedOFFLOAD_OBSERVERlock can understate capacity; an unguarded#[cfg(test)]global write can also be clobbered by an interleaving test. All 16 shared-state sites now take the lock, and the requirement is documented on the static.Verification completed:
cargo fmt --all --checkcargo clippy --all-targets --all-features -- -D warningscargo test --all-features --workspace(1009 library, 25 binary, and 244 integration tests)Summary by cubic
Moves Cursor request protobuf framing and large base64 image decoding off Tokio workers into a bounded blocking pool to reduce worker blocking and keep streams smooth. Addresses #259 and fixes failover behavior by treating request-prep errors as local, while isolating response-path gzip from request bursts.
Bug Fixes
forwardand errors mapped viaown_error.Refactors
cursor::offloadwithgzip_slots(),request_prep_slots(), andspawn_bounded_*(); reused by request framing, large image decodes, and gzip (removedconnect.rs’s private semaphore).AgentRunParamsis owned andopen_turnnow takes pre-builtVec<Bytes>frames. Framing is offloaded once per request;forwardbuilds frames with no new clones exceptcwd.to_string(). Image decode offloads above a 64 KiB estimated decoded bound (exact 4:3 base64). New CodSpeed benches cover framing and extract+decode.Written for commit e46704e. Summary will update on new commits.
BREAKING CHANGE:
shunt::adapters::cursor::agent::AgentRunParamschanges fromborrowed fields (
AgentRunParams<'a>with&str/&[T]) to ownedString/Vec<T>,and
CursorAgentClient::open_turnchanges from(&self, &str, &AgentRunParams<'_>)to taking pre-built frames by value. Source-breaking for any external consumer of the
published
shunt-gatewaylibrary's Cursor adapter API.Reviewer note: known adjacent finding, tracked separately
A review pass on this PR flagged that Cursor-owned errors can still advance
ordered failover, contradicting
docs/upstreams-failover.md:264-268.That is real but pre-existing and out of scope here — the doc line,
map_client_error, and theopen_turnheader block are byte-identical toorigin/main. This PR moves conformance in the right direction: framing andimage-preparation failures now return
failure: Noneand no longer advance thechain. The residual path (a
reqwestbuilder error from a malformedSHUNT_CURSOR_CLIENT_VERSION) is tracked in #275.