Skip to content

fix(listen): prevent panic on websocket keep-alive after close_stream#144

Merged
lukeocodes merged 5 commits intomainfrom
fix/websocket-keepalive-panic
Mar 25, 2026
Merged

fix(listen): prevent panic on websocket keep-alive after close_stream#144
lukeocodes merged 5 commits intomainfrom
fix/websocket-keepalive-panic

Conversation

@lukeocodes
Copy link
Copy Markdown
Member

@lukeocodes lukeocodes commented Mar 19, 2026

Summary

  • Replace .expect() with let _ = when sending KeepAlive in the worker loop, preventing a panic when close_stream() closes the channel before the keep-alive timer fires
  • Add regression tests (keepalive_then_close_stream_panic_repro and loop variant) that reproduce the race condition
  • Add standalone example 16_keepalive_close_stream demonstrating correct keep-alive + close_stream behavior end-to-end
  • Add .env to .gitignore

Closes #143

Test plan

  • cargo check --example 16_keepalive_close_stream compiles cleanly
  • cargo run --example 16_keepalive_close_stream completes without panic
  • DEEPGRAM_API_KEY=... cargo test keepalive_then_close_stream_panic_repro --features listen -- --ignored --nocapture passes
  • DEEPGRAM_API_KEY=... cargo test keepalive_close_stream_panic_repro_loop --features listen -- --ignored --nocapture passes

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::<()>().await in the sleep branch will permanently block the worker once the timer fires when keep_alive is false or is_open is false, preventing further processing of websocket responses (including final transcripts / close frames). Consider disabling the sleep future before the select_biased! (e.g., by selecting on pending() instead of sleep when 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.

Comment thread src/listen/websocket.rs Outdated
Comment thread src/listen/websocket.rs Outdated
Comment thread examples/transcription/websocket/16_keepalive_close_stream.rs Outdated
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).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 while keep_alive is false or is_open is 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 be pending() when keep-alive is disabled (or continue when the sleep fires) rather than awaiting pending() 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.

Comment thread src/listen/websocket.rs Outdated
Comment thread examples/transcription/websocket/16_keepalive_close_stream.rs Outdated
lukeocodes and others added 2 commits March 20, 2026 18:25
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…eam.rs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::<()>() when keep_alive is false or is_open is 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 the select_biased! branch guard (or constructing sleep as a pending() 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.

Comment thread src/listen/websocket.rs
.expect("handle");

tokio::time::sleep(Duration::from_millis(100)).await;
let _ = handle.close_stream().await;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
let _ = handle.close_stream().await;
handle.close_stream().await.expect("close_stream");

Copilot uses AI. Check for mistakes.
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.
@lukeocodes lukeocodes merged commit 22778f4 into main Mar 25, 2026
18 checks passed
@lukeocodes lukeocodes deleted the fix/websocket-keepalive-panic branch March 25, 2026 21:31
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.

Occasional panic in websocket keep alive

3 participants