Skip to content

perf(cursor)!: move request protobuf framing off the async runtime - #272

Merged
amondnet merged 10 commits into
mainfrom
amondnet/perf-cursor-move-request-protobuf-framing-off-th
Jul 29, 2026
Merged

perf(cursor)!: move request protobuf framing off the async runtime#272
amondnet merged 10 commits into
mainfrom
amondnet/perf-cursor-move-request-protobuf-framing-off-th

Conversation

@amondnet

@amondnet amondnet commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Move Cursor request protobuf framing off Tokio worker threads. build_run_frames previously ran synchronously inside open_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:

  • Adds src/adapters/cursor/offload.rs with shared cpu_slots() and spawn_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.
  • Makes AgentRunParams owned so it can move into spawn_blocking, and changes open_turn to take it by value. forward adds no clones for the prompt, images, tools, or model_id; only cwd gains a .to_string().
  • Offloads framing unconditionally once per request, before the body sender starts and before .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.
  • Offloads base64 image decoding above 64 KiB of estimated decoded data, using base64's exact 4:3 expansion as a sound pre-decode upper bound.

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.

Benchmark Median
Framing, 4 KiB / 0 tools 9.9 µs
Framing, 4 KiB / 16 tools 336.5 µs
Framing, 64 KiB / 16 tools 356.8 µs
Image decode, 32 / 64 / 128 / 256 KiB 23.4 / 46.1 / 92.3 / 185.7 µs

Known remaining inline cost

JSON extraction and its base64 string clone still run on the Tokio worker. cursor_selected_images borrows the request Value, so moving extraction into a 'static closure 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/, and site/ contain no inline-framing claim; wiki/ is generated. This matches the #256 precedent (c36c619), which changed only src/ and benches/.

Closes #259

Milestone / spec

Issue #259 — Cursor request framing performance; no frozen milestone/spec change.

Checklist

  • cargo build passes
  • cargo test passes (new behavior is covered; tests run without network/loopback where possible)
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --all --check clean
  • Source files stay under 500 lines (new offload.rs is 129 lines; existing larger Cursor modules are not expanded structurally by this focused change)
  • English only; matches surrounding style
  • Frozen spec in docs/ updated if this change deviates from it (not applicable; no deviation)
  • User-facing docs updated for behavior/config/endpoint/CLI/provider/model changes — README.md / site/ as applicable (wiki/ is generated; don't hand-edit) (not applicable; no user-facing change)
  • Any new GitHub Action is pinned to a full commit SHA (not applicable; no Actions changes)

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 shared OFFLOAD_OBSERVER lock 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 --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --all-features --workspace (1009 library, 25 binary, and 244 integration tests)
  • Five consecutive full-suite runs
  • Twenty targeted stress runs of the shared-state 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

    • Framing/image-prep failures now return local Anthropic-shaped 500s and do not advance failover; framing moved to forward and errors mapped via own_error.
    • Split offload classes so response-path gzip isn’t blocked by request prep; permits stay inside blocking tasks so cancellation can’t over-admit work.
  • Refactors

    • Added cursor::offload with gzip_slots(), request_prep_slots(), and spawn_bounded_*(); reused by request framing, large image decodes, and gzip (removed connect.rs’s private semaphore).
    • AgentRunParams is owned and open_turn now takes pre-built Vec<Bytes> frames. Framing is offloaded once per request; forward builds frames with no new clones except cwd.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::AgentRunParams changes from
borrowed fields (AgentRunParams<'a> with &str/&[T]) to owned String/Vec<T>,
and CursorAgentClient::open_turn changes from (&self, &str, &AgentRunParams<'_>)
to taking pre-built frames by value. Source-breaking for any external consumer of the
published shunt-gateway library'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 the open_turn header block are byte-identical to
origin/main. This PR moves conformance in the right direction: framing and
image-preparation failures now return failure: None and no longer advance the
chain. The residual path (a reqwest builder error from a malformed
SHUNT_CURSOR_CLIENT_VERSION) is tracked in #275.

`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

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/adapters/cursor/offload.rs Outdated
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.09689% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/adapters/cursor/mod.rs 96.41% 8 Missing ⚠️
src/adapters/cursor/offload.rs 99.10% 2 Missing ⚠️
src/adapters/cursor/agent.rs 98.96% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 72 untouched benchmarks
🆕 11 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 cursor_build_run_frames[(4096, 0)] N/A 37.7 µs N/A
🆕 cursor_build_run_frames[(4096, 16)] N/A 513.3 µs N/A
🆕 cursor_build_run_frames[(65536, 16)] N/A 1.1 ms N/A
🆕 cursor_decode_images[131072] N/A 678.8 µs N/A
🆕 cursor_decode_images[262144] N/A 1.4 ms N/A
🆕 cursor_decode_images[32768] N/A 173.7 µs N/A
🆕 cursor_decode_images[65536] N/A 342.1 µs N/A
🆕 cursor_extract_and_decode_images[131072] N/A 1.1 ms N/A
🆕 cursor_extract_and_decode_images[262144] N/A 2.2 ms N/A
🆕 cursor_extract_and_decode_images[32768] N/A 290.7 µs N/A
🆕 cursor_extract_and_decode_images[65536] N/A 555.2 µs N/A

Comparing amondnet/perf-cursor-move-request-protobuf-framing-off-th (e46704e) with main (37079c4)

Open in CodSpeed

@amondnet amondnet changed the title perf(cursor): move request protobuf framing off the async runtime perf(cursor)!: move request protobuf framing off the async runtime Jul 29, 2026
amondnet added 6 commits July 29, 2026 21:43
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
Comment thread src/adapters/cursor/offload.rs
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves Cursor request framing and larger image decoding into bounded blocking tasks.

  • Makes agent-run parameters owned and passes pre-built frames into open_turn.
  • Introduces separate bounded admission classes for request preparation and response gzip decoding.
  • Adds framing, image-decoding, concurrency, cancellation, and benchmark coverage.

Confidence Score: 4/5

The 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

Important Files Changed

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
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@sonarqubecloud

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/adapters/cursor/mod.rs
@amondnet
amondnet merged commit 1a1d8ee into main Jul 29, 2026
16 checks passed
@amondnet
amondnet deleted the amondnet/perf-cursor-move-request-protobuf-framing-off-th branch July 29, 2026 16:58
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.

perf(cursor): move request protobuf framing off the async runtime

1 participant