[READ] Step 1: Are you in the right place?
Yes — this is a bug in the firebase-ai (Firebase AI) component of this repository.
[REQUIRED] Step 2: Describe your environment
- Android Studio version: \Panda 2 | 2025.3.2
- Firebase Component: \Firebase AI (Database, Firestore, Storage, Functions, etc)
- Component version: \17.12.0 (BOM 34.13.0)
[REQUIRED] Step 3: Describe the problem
Summary
LiveSession.isClosed() returns false (session open) after stopAudioConversation() is called, even when the underlying WebSocket connection is already closed.
stopAudioConversation() cancels networkScope, which stops the receive() flow consumer, but does not drain or close the session.incoming channel. Residual frames remain buffered in the channel. Because isClosed() checks the channel via tryReceive(), it picks up these residual frames and keeps returning false, incorrectly reporting the session as still open.
The false-negative window is non-deterministic — its duration depends on how many frames were buffered at the time stopAudioConversation() was called — making isClosed() unreliable for any reconnection or health-check logic.
Steps to reproduce:
- Call
startAudioConversation() to begin an audio session. The SDK launches a receive() flow consumer on networkScope.
- While the conversation is active, the server streams response frames. Some frames may be buffered in
session.incoming awaiting consumption.
- Call
stopAudioConversation(). This cancels networkScope, stopping the consumer. The WebSocket connection itself remains open, but frames already buffered in session.incoming are not drained.
- The session goes idle. After a period of inactivity, the Gemini server sends a
goAway message and closes the WebSocket connection.
- Call
isClosed(). It returns false — even though the WebSocket is now closed — because tryReceive() picks up a residual frame from the buffer and reports ChannelResult.isClosed == false.
- Each subsequent call to
isClosed() pops one more residual frame. It only returns true after all residual frames have been exhausted.
Relevant Code:
// isClosed() — uses tryReceive(), which pops a frame and misreports closed state
// when residual frames are buffered:
public fun isClosed(): Boolean = !(session.isActive && !session.incoming.tryReceive().isClosed)
// stopAudioConversation() — cancels the consumer but leaves residual frames
// in session.incoming:
public fun stopAudioConversation() {
FirebaseAIException.catch {
if (!startedReceiving.getAndSet(false)) return@catch
networkScope.cancel() // ← consumer cancelled, residual frames stay in buffer
audioScope.cancel()
playBackQueue.clear()
audioHelper?.release()
audioHelper = null
}
}
[READ] Step 1: Are you in the right place?
Yes — this is a bug in the
firebase-ai(Firebase AI) component of this repository.[REQUIRED] Step 2: Describe your environment
[REQUIRED] Step 3: Describe the problem
Summary
LiveSession.isClosed()returns false (session open) afterstopAudioConversation()is called, even when the underlying WebSocket connection is already closed.stopAudioConversation()cancelsnetworkScope, which stops thereceive()flow consumer, but does not drain or close thesession.incomingchannel. Residual frames remain buffered in the channel. BecauseisClosed()checks the channel viatryReceive(), it picks up these residual frames and keeps returningfalse, incorrectly reporting the session as still open.The false-negative window is non-deterministic — its duration depends on how many frames were buffered at the time
stopAudioConversation()was called — makingisClosed()unreliable for any reconnection or health-check logic.Steps to reproduce:
startAudioConversation()to begin an audio session. The SDK launches areceive()flow consumer onnetworkScope.session.incomingawaiting consumption.stopAudioConversation(). This cancelsnetworkScope, stopping the consumer. The WebSocket connection itself remains open, but frames already buffered insession.incomingare not drained.goAwaymessage and closes the WebSocket connection.isClosed(). It returnsfalse— even though the WebSocket is now closed — becausetryReceive()picks up a residual frame from the buffer and reportsChannelResult.isClosed == false.isClosed()pops one more residual frame. It only returnstrueafter all residual frames have been exhausted.Relevant Code: