Skip to content

LiveSession.isClosed() returns false after WebSocket is closed due to residual frames in session.incoming buffer #8251

Description

@SDLY1

[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:

  1. Call startAudioConversation() to begin an audio session. The SDK launches a receive() flow consumer on networkScope.
  2. While the conversation is active, the server streams response frames. Some frames may be buffered in session.incoming awaiting consumption.
  3. Call stopAudioConversation(). This cancels networkScope, stopping the consumer. The WebSocket connection itself remains open, but frames already buffered in session.incoming are not drained.
  4. The session goes idle. After a period of inactivity, the Gemini server sends a goAway message and closes the WebSocket connection.
  5. 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.
  6. 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
    }
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions