fix(listen): prevent panic on websocket keep-alive after close_stream#144
fix(listen): prevent panic on websocket keep-alive after close_stream#144lukeocodes merged 5 commits intomainfrom
Conversation
Replace .expect() with `let _ =` when sending KeepAlive in the worker loop. close_stream() calls close_channel() which closes the channel for all senders — if the keep-alive timer fires before the worker processes CloseStream, the send fails and the previous .expect() panicked. Add regression tests and a standalone example (16_keepalive_close_stream) that exercises the keep-alive + close_stream sequence end-to-end. Closes #143
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/listen/websocket.rs:492
pending::<()>().awaitin the sleep branch will permanently block the worker once the timer fires whenkeep_aliveis false oris_openis false, preventing further processing of websocket responses (including final transcripts / close frames). Consider disabling the sleep future before theselect_biased!(e.g., by selecting onpending()instead ofsleepwhen keep-alive is disabled) or simply no-op in this branch rather than awaiting a never-resolving future.
let _ = message_tx.send(WsMessage::ControlMessage(ControlMessage::KeepAlive)).await;
last_sent_message = tokio::time::Instant::now();
} else {
pending::<()>().await;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The keep-alive timer fires every 3s, but tests and example waited only milliseconds — never actually triggering the keep-alive-after-close race. Increase post-close waits to 4s so the keep-alive send path is exercised. Reduce loop iterations from 10 to 3 to keep test runtime reasonable (~12s vs ~40s).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/listen/websocket.rs:492
- In the sleep branch, the
else { pending::<()>().await; }will block the worker forever once the 3s timer fires whilekeep_aliveis false oris_openis false. That prevents processing further websocket responses (including the close/terminal response) and can stall shutdown. Consider disabling the timer by making the future in the select bepending()when keep-alive is disabled (orcontinuewhen the sleep fires) rather than awaitingpending()inside the branch body.
last_sent_message = tokio::time::Instant::now();
} else {
pending::<()>().await;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…eam.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/listen/websocket.rs:493
- The sleep branch awaits
pending::<()>()whenkeep_aliveis false oris_openis false. Because this runs after the sleep has already fired and the select branch is chosen, it will park the worker forever and stop processing both websocket responses and inbound control/audio messages (e.g., after CloseStream). Consider moving the condition into theselect_biased!branch guard (or constructingsleepas apending()future when disabled) so the worker loop can continue to handle responses/shutdown cleanly without deadlocking.
_ = sleep.fuse() => {
// eprintln!("<worker> sleep");
if keep_alive && is_open {
// Ignore send errors: the channel may have been closed by
// close_stream() (via close_channel()) before the worker
// processes the pending CloseStream message. In that case
// the next iteration will handle CloseStream, stop sending new
// messages, and proceed toward shutdown.
let _ = message_tx.send(WsMessage::ControlMessage(ControlMessage::KeepAlive)).await;
last_sent_message = tokio::time::Instant::now();
} else {
pending::<()>().await;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .expect("handle"); | ||
|
|
||
| tokio::time::sleep(Duration::from_millis(100)).await; | ||
| let _ = handle.close_stream().await; |
There was a problem hiding this comment.
In the looped regression test, close_stream() errors are ignored (let _ = ...). That can produce false positives if closing fails and the keep-alive send path is never reached. Consider asserting the close succeeds (or checking state) to keep the test meaningful.
| let _ = handle.close_stream().await; | |
| handle.close_stream().await.expect("close_stream"); |
The tungstenite handshake Response type triggers clippy::result_large_err in the mock server callback. This is a pre-existing issue unrelated to the keep-alive fix.
Summary
.expect()withlet _ =when sending KeepAlive in the worker loop, preventing a panic whenclose_stream()closes the channel before the keep-alive timer fireskeepalive_then_close_stream_panic_reproand loop variant) that reproduce the race condition16_keepalive_close_streamdemonstrating correct keep-alive + close_stream behavior end-to-end.envto.gitignoreCloses #143
Test plan
cargo check --example 16_keepalive_close_streamcompiles cleanlycargo run --example 16_keepalive_close_streamcompletes without panicDEEPGRAM_API_KEY=... cargo test keepalive_then_close_stream_panic_repro --features listen -- --ignored --nocapturepassesDEEPGRAM_API_KEY=... cargo test keepalive_close_stream_panic_repro_loop --features listen -- --ignored --nocapturepasses