Skip to content

fix(ai): close a LiveSession when it's released - #17045

Draft
andrewheard wants to merge 1 commit into
mainfrom
ah/ai-gemini-live-close-sessions
Draft

andrewheard wants to merge 1 commit into
mainfrom
ah/ai-gemini-live-close-sessions

Conversation

@andrewheard

@andrewheard andrewheard commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes a leak where a LiveSession released without calling close() kept its WebSocket connection to the server open.

Root cause

LiveSession.close() is documented as being called automatically when the session is deinitialized, but LiveSession had no deinit. It relied on LiveSessionService.deinit to cancel its tasks and disconnect the WebSocket, and that deinit never ran while the session was connected:

  • responsesTask and messageQueueTask are unstructured Tasks whose closures use the actor's state and methods, so they capture the service strongly.
  • They only end when close() cancels them, or when their streams finish. The message queue stream is only finished in the service's deinit.

So releasing a connected LiveSession left the service, its two tasks, and the WebSocket alive until the server closed the connection (for example, at the session's time limit). I confirmed this against the real backend: three seconds after releasing a session, the service was still allocated, with its WebSocket open and both tasks running.

Because open sessions count toward the backend's concurrent session limits, apps that reconnect without calling close() could accumulate open connections.

Changes

  • Added a LiveSession deinit that closes its service in a Task, since deinit can't await. close() cancels both tasks and disconnects the WebSocket. Once the tasks end, nothing else holds the service, so it deinitializes and finishes responses. (close() is idempotent.)
  • Corrected the close() docs. They said that receiving from a closed session throws LiveSessionUnexpectedClosureError. In practice, responses receives no new messages unless the session is resumed, and finishes shortly after the session is released. I also dropped "Permanently" from the summary, since resumeSession can reopen a closed session.
  • Documented on responses that the stream finishes, at the latest, shortly after the session is deinitialized, so apps need to keep a reference to the session while consuming it.

Testing

Added releasedSession_isClosed to LiveSessionTests. It connects, keeps only responses, releases the session without calling close(), and expects the stream to finish within 5 seconds. It only uses public API.

This branch: the new test uses the suite's shared arguments (Gemini 2.5 Live on Agent Platform and Google AI), so it has 2 cases. Both pass.

On top of #17044 (adds Gemini 3.8 Live), 4 cases:

Agent Platform 2.5 Agent Platform 3.8 Google AI 2.5 Google AI 3.8
Without the fix ❌ ❌ ❌ ❌
With the fix ✅ ✅ ✅ ✅

With the fix, it also passed 3 more iterations there (12/12).

The Live unit test suites (LiveSessionServiceTests, LiveServerVoiceActivityTests, LiveGenerationConfigTests) pass. A deterministic unit test isn't practical here, because MockURLProtocol only handles HTTP and the service only spawns its tasks after a real setup handshake.

Known issues not addressed in this PR

Possible race when the client closes the WebSocket

In AsyncWebSocket, close(code:reason:underlyingError:) records the close error, cancels the URLSessionWebSocketTask, and then finishes the message stream. Meanwhile, the startReceiving() loop's pending receive() throws as soon as the task is cancelled, and its catch calls close again with webSocketTask.closeCode. If that second call finishes the stream first, and closeCode isn't .goingAway at that point, the stream ends with a different error. LiveSessionService would then surface it as a LiveSessionUnexpectedClosureError, so responses would throw instead of finishing normally on release (or staying open after an explicit close()). The second call also overwrites the recorded close error, so later sends would throw the wrong error.

This was found by reading the code. I haven't seen it happen in any of the passing runs above; the new test would fail with a thrown error if it did. I'm not sure what closeCode reports right after a client-side cancel. A possible fix is to skip the catch path when the client has already started closing, since the close error is recorded before the cancel.

A server-sent .goingAway close is treated as a user close

LiveSessionService.mapWebsocketError returns nil for .goingAway (1001), on the assumption that the user called close(). If the server closes the connection with .goingAway, responsesTask exits without calling close() or finishing responses. messageQueueTask keeps the service alive, so responses never finishes while the app still holds the session (it now does finish when the session is released), and sends fail with only a log message.

Alternatives considered

Breaking the cycle inside LiveSessionService, by having its tasks capture the service weakly. This would fix the cycle where it originates and let the service tear down synchronously in its own deinit. But both tasks rely on running on the actor (to read messageQueue, decode and encode messages, and call close()), so they'd need to be restructured, and a guard let self held across the for try await loops would recreate the strong reference. Fixing it in LiveSession, the only owner of the service, is much smaller and makes the existing documentation true. If anything other than LiveSession ever holds a service, it would also need to close it.

@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

LiveSession.close() is documented as being called automatically when
the session is deinitialized, but LiveSession had no deinit. It relied
on LiveSessionService.deinit, which never ran while the session was
connected: the service's response and message queue tasks hold strong
references to it until they're cancelled. A LiveSession released
without calling close() therefore kept its WebSocket connection open.

Add a LiveSession deinit that closes the service. Closing cancels the
tasks, so the service deinitializes, disconnects its WebSocket, and
finishes the responses stream.

Also correct the close() and responses docs: after close(), responses
delivers no more messages unless the session is resumed, rather than
throwing LiveSessionUnexpectedClosureError, and it finishes when the
session is released.

Add an integration test that releases a session without calling
close() and expects its responses stream to finish.
@andrewheard
andrewheard force-pushed the ah/ai-gemini-live-close-sessions branch from 6fb6626 to 7e052b1 Compare November 11, 2026 01:19

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant