2121
2222use std:: sync:: Arc ;
2323
24+ use tokio:: task:: JoinHandle ;
25+
2426use axum:: extract:: ws:: { Message , WebSocket } ;
2527use axum:: extract:: { State , WebSocketUpgrade } ;
2628use axum:: http:: HeaderMap ;
2729use axum:: response:: IntoResponse ;
2830use futures:: { SinkExt , StreamExt } ;
29- use tracing:: debug;
31+ use tracing:: { debug, warn } ;
3032
3133use crate :: control:: change_stream:: LiveSubscriptionSet ;
3234use crate :: control:: security:: audit:: ArcAuditEmitter ;
3335use crate :: control:: server:: http:: auth:: { AppState , ResolvedIdentity } ;
3436use crate :: control:: server:: shared:: authorization:: authorize_database;
37+ use crate :: control:: server:: shared:: { ConnectionFutureOutcome , isolate_connection_future} ;
3538use crate :: control:: state:: SharedState ;
3639use crate :: types:: DatabaseId ;
3740
@@ -66,12 +69,64 @@ pub async fn ws_handler(
6669 . into_response ( ) ;
6770 }
6871 let trace_id = crate :: control:: trace_context:: extract_from_headers ( & headers) ;
69- ws. on_upgrade ( move |socket| {
70- handle_ws_connection ( socket, state, identity, database_id, trace_id)
72+ ws. on_upgrade ( move |socket| async move {
73+ match isolate_connection_future ( handle_ws_connection (
74+ socket,
75+ state,
76+ identity,
77+ database_id,
78+ trace_id,
79+ ) )
80+ . await
81+ {
82+ ConnectionFutureOutcome :: Completed ( ( ) ) => { }
83+ ConnectionFutureOutcome :: Panicked => {
84+ warn ! ( "ws-rpc connection panicked" ) ;
85+ }
86+ }
7187 } )
7288 . into_response ( )
7389}
7490
91+ /// Owns a sender task until normal completion or connection teardown.
92+ ///
93+ /// Dropping the guard aborts the task synchronously. This ensures a panic or
94+ /// cancellation of the connection future cannot detach the sender task.
95+ struct AbortOnDropJoinHandle {
96+ handle : Option < JoinHandle < ( ) > > ,
97+ }
98+
99+ impl AbortOnDropJoinHandle {
100+ fn new ( handle : JoinHandle < ( ) > ) -> Self {
101+ Self {
102+ handle : Some ( handle) ,
103+ }
104+ }
105+
106+ /// Join the sender after the channel is closed. A sender panic is observed
107+ /// without inspecting its payload or logging its join error.
108+ async fn finish ( mut self ) {
109+ let terminated_unexpectedly = match self . handle . as_mut ( ) {
110+ Some ( handle) => handle. await . is_err ( ) ,
111+ None => false ,
112+ } ;
113+ // Remove only after the await completes. If this future is cancelled
114+ // while pending, `Drop` still owns and aborts the sender task.
115+ self . handle . take ( ) ;
116+ if terminated_unexpectedly {
117+ warn ! ( "ws-rpc sender task terminated unexpectedly" ) ;
118+ }
119+ }
120+ }
121+
122+ impl Drop for AbortOnDropJoinHandle {
123+ fn drop ( & mut self ) {
124+ if let Some ( handle) = self . handle . take ( ) {
125+ handle. abort ( ) ;
126+ }
127+ }
128+ }
129+
75130/// Handle a single WebSocket connection.
76131async fn handle_ws_connection (
77132 socket : WebSocket ,
@@ -87,7 +142,7 @@ async fn handle_ws_connection(
87142 // 256 messages provides ~10s of buffer at 25 events/sec.
88143 let ( live_tx, mut live_rx) = tokio:: sync:: mpsc:: channel :: < String > ( 256 ) ;
89144
90- let send_handle = tokio:: spawn ( async move {
145+ let sender = AbortOnDropJoinHandle :: new ( tokio:: spawn ( async move {
91146 loop {
92147 tokio:: select! {
93148 Some ( msg) = live_rx. recv( ) => {
@@ -99,7 +154,7 @@ async fn handle_ws_connection(
99154 else => break ,
100155 }
101156 }
102- } ) ;
157+ } ) ) ;
103158
104159 // Session ID is set only after successful auth inside process_message.
105160 let mut authenticated_session_id: Option < String > = None ;
@@ -157,7 +212,7 @@ async fn handle_ws_connection(
157212 drop ( live_set) ;
158213
159214 drop ( live_tx) ;
160- let _ = send_handle . await ;
215+ sender . finish ( ) . await ;
161216 debug ! ( "WebSocket RPC connection closed" ) ;
162217}
163218
@@ -189,3 +244,88 @@ pub fn save_ws_session(shared: &SharedState, session_id: &str) {
189244 "WS session saved for reconnect"
190245 ) ;
191246}
247+
248+ #[ cfg( test) ]
249+ mod tests {
250+ use std:: future:: pending;
251+
252+ use tokio:: sync:: oneshot;
253+
254+ use super :: { AbortOnDropJoinHandle , ConnectionFutureOutcome , isolate_connection_future} ;
255+
256+ struct AbortSignal ( Option < oneshot:: Sender < ( ) > > ) ;
257+
258+ impl Drop for AbortSignal {
259+ fn drop ( & mut self ) {
260+ if let Some ( sender) = self . 0 . take ( ) {
261+ let _ = sender. send ( ( ) ) ;
262+ }
263+ }
264+ }
265+
266+ #[ tokio:: test]
267+ async fn sender_is_aborted_when_guard_is_dropped ( ) {
268+ let ( started_tx, started_rx) = oneshot:: channel ( ) ;
269+ let ( dropped_tx, dropped_rx) = oneshot:: channel ( ) ;
270+ let sender = AbortOnDropJoinHandle :: new ( tokio:: spawn ( async move {
271+ let _signal = AbortSignal ( Some ( dropped_tx) ) ;
272+ let _ = started_tx. send ( ( ) ) ;
273+ pending :: < ( ) > ( ) . await ;
274+ } ) ) ;
275+
276+ assert ! ( started_rx. await . is_ok( ) ) ;
277+ drop ( sender) ;
278+
279+ assert ! ( dropped_rx. await . is_ok( ) ) ;
280+ }
281+
282+ #[ tokio:: test]
283+ async fn cancellation_while_finishing_aborts_sender ( ) {
284+ let ( started_tx, started_rx) = oneshot:: channel ( ) ;
285+ let ( dropped_tx, dropped_rx) = oneshot:: channel ( ) ;
286+ let sender = AbortOnDropJoinHandle :: new ( tokio:: spawn ( async move {
287+ let _signal = AbortSignal ( Some ( dropped_tx) ) ;
288+ let _ = started_tx. send ( ( ) ) ;
289+ pending :: < ( ) > ( ) . await ;
290+ } ) ) ;
291+ assert ! ( started_rx. await . is_ok( ) ) ;
292+
293+ let finishing = tokio:: spawn ( sender. finish ( ) ) ;
294+ tokio:: task:: yield_now ( ) . await ;
295+ finishing. abort ( ) ;
296+ let _ = finishing. await ;
297+
298+ assert ! ( dropped_rx. await . is_ok( ) ) ;
299+ }
300+
301+ #[ tokio:: test]
302+ async fn sender_finishes_normally ( ) {
303+ let ( completed_tx, completed_rx) = oneshot:: channel ( ) ;
304+ let sender = AbortOnDropJoinHandle :: new ( tokio:: spawn ( async move {
305+ let _ = completed_tx. send ( ( ) ) ;
306+ } ) ) ;
307+
308+ sender. finish ( ) . await ;
309+
310+ assert ! ( completed_rx. await . is_ok( ) ) ;
311+ }
312+
313+ #[ tokio:: test]
314+ async fn isolation_catches_connection_panic_and_releases_sender ( ) {
315+ let ( started_tx, started_rx) = oneshot:: channel ( ) ;
316+ let ( dropped_tx, dropped_rx) = oneshot:: channel ( ) ;
317+ let outcome = isolate_connection_future ( async move {
318+ let _sender = AbortOnDropJoinHandle :: new ( tokio:: spawn ( async move {
319+ let _signal = AbortSignal ( Some ( dropped_tx) ) ;
320+ let _ = started_tx. send ( ( ) ) ;
321+ pending :: < ( ) > ( ) . await ;
322+ } ) ) ;
323+ let _ = started_rx. await ;
324+ panic ! ( "simulated websocket connection panic" ) ;
325+ } )
326+ . await ;
327+
328+ assert ! ( matches!( outcome, ConnectionFutureOutcome :: Panicked ) ) ;
329+ assert ! ( dropped_rx. await . is_ok( ) ) ;
330+ }
331+ }
0 commit comments