Fix ask_user starving the Rust SDK per-session event loop#2034
Fix ask_user starving the Rust SDK per-session event loop#2034pallaviraiturkar0 wants to merge 1 commit into
ask_user starving the Rust SDK per-session event loop#2034Conversation
There was a problem hiding this comment.
Pull request overview
Prevents pending Rust ask_user requests from starving sibling session events.
Changes:
- Dispatches user-input handlers concurrently.
- Adds an E2E regression test and replay snapshot.
Show a summary per file
| File | Description |
|---|---|
rust/src/session.rs |
Spawns user-input request handling. |
rust/tests/e2e/ask_user.rs |
Tests concurrent sibling tool execution. |
test/snapshots/ask_user/ask_user_does_not_block_sibling_tool_call_in_same_turn.yaml |
Adds replay data for the regression test. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Medium
|
Replies to the automated review threads (in-thread on Files changed):
|
|
The prior Rust SDK Tests (ubuntu-latest, default) failure was an unrelated flake, not a regression from this PR:
I re-triggered CI (reopen) since I don't have admin to re-run the job directly. |
|
Thanks for the fix. It looks like Rust is actually the outlier here; the other 5 SDKs do it "correctly". My preference though would be to generalize this further to better bring it in line with the other SDKs. Can we consider moving concurrency to the request-dispatch boundary? The current patch is correct, but it fixes one manifestation rather than the architectural cause. The other SDKs are only “fire-and-forget” from the reader loop: each inbound JSON-RPC request gets its own task/goroutine/future, which awaits the handler and sends that request’s response. Rust could similarly spawn handle_request from the requests.recv() branch rather than spawning only userInput.request internally. That would also prevent starvation from exitPlanMode, autoModeSwitch, hooks, transforms, canvas/session-FS providers, or any future slow handler. JSON-RPC permits concurrent requests and out-of-order responses, and the other SDKs establish that serialization is not part of the SDK contract. |
Each session runs one `tokio::select!` loop in `rust/src/session.rs`. Inbound JSON-RPC requests were dispatched by `handle_request(...).await` inline in the select loop, so a slow handler parked the reader and could not drain the next message. `ask_user` (`userInput.request`) awaits the user's answer (host backstop timeout: 5 min), so while it was pending the loop was frozen — starving a sibling tool call co-emitted in the same turn (e.g. `set_session_title` + `ask_user` — github/copilot-experiences#12540) and hanging the UI. Notifications didn't hit this because their slow interactive callbacks (permission/tool/elicitation) are already spawned. Move concurrency to the request-dispatch boundary, matching the other five SDKs: spawn each `handle_request` from the `requests.recv()` branch as its own task that awaits the handler and sends that request's response, instead of awaiting inline. This fixes starvation for every request handler — not just `userInput.request` but also `exitPlanMode`, `autoModeSwitch`, hooks, transforms, and canvas/session-FS providers. The Arc-backed dispatch context is cloned into the task so the future is `'static`; JSON-RPC permits concurrent requests and out-of-order responses, so nothing is serialized. Notifications remain inline (they only do fast dispatch work). The `userInput.request` arm itself is unchanged from `main`. Add an e2e regression test (`ask_user` category) where the model emits `set_marker` and `ask_user` in one turn; the user-input handler waits for the sibling tool to fire before answering and asserts it observed the tool while its own request was still pending. The test fails on the inline dispatch (~31s, starvation) and passes with the boundary spawn (~10s). The parallel dotnet/go/python/nodejs bindings and the bundled core already spawn per-request, but their `userInput.request` handlers are worth a follow-up audit for the same inline-await asymmetry (not changed here). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86146d70-7b81-497c-9d80-cd8dd8cf8a41
bb78252 to
d2a0bb8
Compare
|
Thanks for the quick review @stephentoub — done. Moved concurrency to the request-dispatch boundary instead of special-casing
|
Problem
Each session runs one
tokio::select!loop inrust/src/session.rs. Interactive callbacks that arrive as notifications (tool-call, permission, elicitation) aretokio::spawned and run concurrently. Butask_userarrives as a JSON-RPC request (userInput.request) and was handled byhandle_request(...).awaitinline in the select loop.While the user's answer is pending (the host applies a ~5-minute backstop timeout), the loop is parked and cannot drain the next notification. A sibling tool call co-emitted in the same turn — e.g.
set_session_title+ask_user(github/copilot-experiences#12540) — is therefore starved, so it never completes and the UI appears frozen.The root cause is a dispatch asymmetry: tool/permission/elicitation are spawned, but only
userInput.requestwas awaited inline. The host already yields correctly, so the fix is SDK-only.Fix
In the
"userInput.request"arm ofhandle_request, parsequestion/choices/allowFreeformup front (keeping the existing inlineINVALID_PARAMSerror for a missingquestion, which is instant), then spawn a child task that runsUserInputHandler::handle(...)and sends the JSON-RPC response — instead of awaiting inline. This mirrors the existing permission-request spawn pattern (clone theClient/session id/handlerArc, wrap in atracing::error_span!+.instrument). The select loop now keeps draining notifications whileask_useris pending.The change is intentionally minimal and surgical — no refactor beyond moving the dispatch to a spawned task.
Test
Adds an e2e regression test in
rust/tests/e2e/ask_user.rs:ask_user_does_not_block_sibling_tool_call_in_same_turn. The model emits both a customset_markertool andask_userin one turn;set_markerfires aNotify, and theUserInputHandlerwaits (bounded) on thatNotifybefore answering, then asserts it observed the sibling tool executing while its ownuserInput.requestwas still pending. Backed by a new replay snapshot undertest/snapshots/ask_user/.Verified the test fails on the pre-fix inline-await code (assertion trips after ~34s) and passes with the fix (~6s).
cargo build,cargo fmt --check,cargo clippy,cargo test --lib(187 passed), and theask_user+ parallel-tools e2e tests all pass.Follow-up (not in this PR)
The parallel non-Rust bindings (dotnet / go / python / nodejs) and the bundled core may share the same inline-await asymmetry for
userInput.requestand are worth a follow-up audit. This PR deliberately changes only the Rust SDK.