Skip to content

Commit dc2ff5d

Browse files
author
Zhangyi Yuan
committed
fix(rust): answer the request id when a handler panics
Requests are dispatched to their own spawned tasks, so a panic inside a handler is isolated by tokio and the request is simply never answered - the caller waits out its own timeout. Catch the unwind at the dispatch boundary and reply with a JSON-RPC internal error (-32603) instead; the panic payload is not exposed. Also corrects the stop_event_loop, Drop and lifecycle-test documentation, which still claimed in-flight handlers complete before the loop exits. That stopped being true when request dispatch moved to spawned tasks. Fixes #2053.
1 parent 3108e8c commit dc2ff5d

2 files changed

Lines changed: 97 additions & 23 deletions

File tree

rust/src/session.rs

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
use std::collections::HashMap;
2+
use std::panic::AssertUnwindSafe;
23
use std::path::{Path, PathBuf};
34
use std::sync::Arc;
45
use std::time::{Duration, Instant};
56

7+
use futures_util::FutureExt;
68
use parking_lot::Mutex as ParkingLotMutex;
79
use serde_json::Value;
810
use tokio::sync::oneshot;
911
use tokio::task::JoinHandle;
1012
use tokio_util::sync::CancellationToken;
11-
use tracing::{Instrument, warn};
13+
use tracing::{Instrument, error, warn};
1214

1315
use crate::canvas::CanvasHandler;
1416
use crate::generated::api_types::{
@@ -321,10 +323,15 @@ impl Session {
321323
/// Stop the internal event loop. Called automatically on [`destroy`](Self::destroy).
322324
///
323325
/// Cooperative: signals shutdown via the session's [`CancellationToken`]
324-
/// and awaits the loop's natural exit rather than aborting the task.
325-
/// Any in-flight handler (permission callback, tool call, elicitation
326-
/// response) completes before the loop exits, so the CLI never sees a
327-
/// half-handled request. See RFD-400 review finding #3.
326+
/// and awaits the loop's natural exit rather than aborting the task, so
327+
/// the loop always stops between iterations instead of at an arbitrary
328+
/// await point. See RFD-400 review finding #3.
329+
///
330+
/// Inbound requests are dispatched to their own spawned tasks, which this
331+
/// call does not await. A handler (permission callback, tool call,
332+
/// elicitation response) still running at teardown may therefore outlive
333+
/// the loop, and its response can be lost if the connection closes first.
334+
/// Await your own handler work before calling this if it must complete.
328335
pub async fn stop_event_loop(&self) {
329336
self.shutdown.cancel();
330337
let handle = self.event_loop.lock().take();
@@ -643,11 +650,11 @@ impl Drop for Session {
643650
fn drop(&mut self) {
644651
// Cooperative shutdown: cancel the event loop's token to signal
645652
// exit between iterations. The loop will see the cancellation on
646-
// its next select poll and break cleanly without interrupting an
647-
// in-flight handler. We do NOT abort the JoinHandle — that would
648-
// land at any await point in the loop body, potentially leaving
649-
// the CLI with an unanswered request id. RFD-400 review finding
650-
// #3.
653+
// its next select poll and break cleanly. We do NOT abort the
654+
// JoinHandle — that would land at any await point in the loop body,
655+
// potentially leaving the CLI with an unanswered request id.
656+
// RFD-400 review finding #3. Requests already dispatched to their
657+
// own tasks are not tracked here and may outlive the session.
651658
//
652659
// The handle itself is left in `event_loop` to be reaped by the
653660
// tokio runtime when it next polls; we intentionally don't await
@@ -1506,6 +1513,8 @@ fn spawn_event_loop(
15061513
let canvas_handler = canvas_handler.clone();
15071514
let session_fs_provider = session_fs_provider.clone();
15081515
let bearer_token_providers = bearer_token_providers.clone();
1516+
let request_id = request.id;
1517+
let method = request.method.clone();
15091518
tokio::spawn(
15101519
async move {
15111520
let ctx = RequestDispatchContext {
@@ -1517,7 +1526,19 @@ fn spawn_event_loop(
15171526
session_fs_provider: session_fs_provider.as_ref(),
15181527
bearer_token_providers: &bearer_token_providers,
15191528
};
1520-
handle_request(&session_id, ctx, request).await;
1529+
let dispatch = handle_request(&session_id, ctx, request);
1530+
if AssertUnwindSafe(dispatch).catch_unwind().await.is_err() {
1531+
// Tokio isolates the panic to this task, so without a
1532+
// reply the CLI waits out its own timeout on this id.
1533+
error!(method = %method, "request handler panicked");
1534+
let _ = send_error_response(
1535+
&client,
1536+
request_id,
1537+
error_codes::INTERNAL_ERROR,
1538+
"request handler panicked",
1539+
)
1540+
.await;
1541+
}
15211542
}
15221543
.instrument(span),
15231544
);

rust/tests/session_test.rs

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2803,10 +2803,10 @@ async fn send_and_wait_drop_clears_waiter() {
28032803
}
28042804

28052805
/// Cancel-safety regression: `Session::stop_event_loop` must NOT abort
2806-
/// the event-loop task mid-handler. An in-flight handler (here a slow
2807-
/// `userInput.request` callback) must run to completion before the loop
2808-
/// exits — the CLI receives the response on the wire before the session
2809-
/// tears down.
2806+
/// the event-loop task at an arbitrary await point. Requests are
2807+
/// dispatched to their own tasks, so a handler that is still running when
2808+
/// shutdown is signalled keeps going and its response still reaches the
2809+
/// wire rather than being lost mid-protocol.
28102810
///
28112811
/// Closes RFD-400 review finding #3.
28122812
#[tokio::test]
@@ -2850,31 +2850,84 @@ async fn stop_event_loop_completes_in_flight_handler() {
28502850
// Give the loop a moment to dispatch into the handler.
28512851
tokio::time::sleep(Duration::from_millis(20)).await;
28522852

2853-
// Now request shutdown. The loop is parked in handle_request awaiting
2854-
// the slow handler. `notify_one()` buffers the signal until the loop
2855-
// re-enters its select, which can only happen after the handler
2856-
// returns and the response is sent on the wire.
2853+
// Now request shutdown while the spawned handler is still sleeping.
28572854
let stop_handle = tokio::spawn({
28582855
let session = session.clone();
28592856
async move { session.stop_event_loop().await }
28602857
});
28612858

2862-
// Verify the handler's response lands on the wire BEFORE the loop
2863-
// exits — i.e. stop_event_loop did not abort mid-handler.
2859+
// The handler task is independent of the loop, so its response still
2860+
// lands on the wire instead of being lost to an aborted task.
28642861
let response = timeout(Duration::from_secs(2), server.read_response())
28652862
.await
28662863
.unwrap();
28672864
assert_eq!(response["id"], 900);
28682865
assert_eq!(response["result"]["answer"], "completed");
28692866

2870-
// stop_event_loop completes after the handler returns and the loop
2871-
// observes the buffered shutdown signal on its next select iteration.
28722867
timeout(Duration::from_secs(2), stop_handle)
28732868
.await
28742869
.unwrap()
28752870
.unwrap();
28762871
}
28772872

2873+
/// A panicking request handler must still answer its request id. Tokio
2874+
/// isolates the panic to the spawned handler task, so without an explicit
2875+
/// reply the caller would wait out its own timeout on a request that can
2876+
/// never complete.
2877+
#[tokio::test]
2878+
async fn panicking_request_handler_responds_with_internal_error() {
2879+
struct PanickingHandler;
2880+
#[async_trait]
2881+
impl UserInputHandler for PanickingHandler {
2882+
async fn handle(
2883+
&self,
2884+
_session_id: SessionId,
2885+
_question: String,
2886+
_choices: Option<Vec<String>>,
2887+
_allow_freeform: Option<bool>,
2888+
) -> Option<UserInputResponse> {
2889+
panic!("handler blew up");
2890+
}
2891+
}
2892+
2893+
let (session, mut server) = create_session_pair_with_config(|cfg| {
2894+
cfg.with_user_input_handler(Arc::new(PanickingHandler))
2895+
})
2896+
.await;
2897+
2898+
server
2899+
.send_request(
2900+
901,
2901+
"userInput.request",
2902+
serde_json::json!({
2903+
"sessionId": server.session_id,
2904+
"question": "boom",
2905+
"choices": null,
2906+
"allowFreeform": true,
2907+
}),
2908+
)
2909+
.await;
2910+
2911+
let response = timeout(TIMEOUT, server.read_response()).await.unwrap();
2912+
assert_eq!(response["id"], 901);
2913+
assert_eq!(response["error"]["code"], -32603);
2914+
assert!(response.get("result").is_none());
2915+
2916+
// The loop survives the panicking handler and keeps serving requests.
2917+
server
2918+
.send_request(
2919+
902,
2920+
"unknown.method",
2921+
serde_json::json!({ "sessionId": server.session_id }),
2922+
)
2923+
.await;
2924+
let response = timeout(TIMEOUT, server.read_response()).await.unwrap();
2925+
assert_eq!(response["id"], 902);
2926+
assert_eq!(response["error"]["code"], -32601);
2927+
2928+
session.stop_event_loop().await;
2929+
}
2930+
28782931
/// Cancel-safety regression: dropping a Session does NOT abort the event
28792932
/// loop mid-handler. The loop sees the buffered shutdown signal on its
28802933
/// next select iteration and exits cleanly. This is the Drop equivalent

0 commit comments

Comments
 (0)