You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Flow.RunLive leaks one goroutine per reconnect: unbuffered errChan abandoned on resumable errors #1152
In internal/llminternal/base_flow.go, Flow.RunLive creates a fresh unbuffered errChan per connection attempt (main @ 0c88126, line 373; present since v1.3.0 and unchanged at v1.5.0). The reader and sender goroutines send to it without a cancellation guard:
reader: errChan <- err (line 389)
sender: errChan <- err (lines 430, 439)
The consumer reads a single error in the main select (line 521). When that error isResumable, the loop sets reconnect = true, breaks out, and starts a new attempt with a newerrChan — abandoning the previous one. If the other goroutine of the old attempt subsequently fails (e.g. the sender hits the closed connection after the reader already reported), it blocks forever on a send to the abandoned unbuffered channel: one leaked goroutine (plus its stack and captured connection state) per reconnect cycle. Long-lived live sessions on flaky networks accumulate these.
Suggested fix
Wrap each errChan send in a select with case <-connCtx.Done(): (mirroring the existing eventsChan pattern in the same function), and ensure the per-attempt cancelConn is invoked on the reconnect path before starting the next attempt — today cleanup() runs on terminal paths but not when reconnect = true breaks the select.
Alternatively, errChan := make(chan error, 2) (one slot per producer goroutine) also unblocks the producers, at the cost of masking rather than modeling the lifecycle.
Environment
Observed by code inspection at v1.5.0 and confirmed unchanged on main (0c88126). Found during an integration review of v1.5.0 for a fork we run in a pre-production live voice deployment.
Using a select with <-connCtx.Done() seems like the cleaner fix as it aligns with the existing eventsChan logic, whereas a buffered channel is fragile if we add more background workers in the future. WDYT?
I'm picking this up with the select { case errChan <- err: case <-connCtx.Done(): } approach suggested above — it extends the existing eventsChan guard convention already in RunLive.
One small correction to the issue text: cleanup() does run on the reconnect path (it's called unconditionally after the consumer loop, before the reconnect check), so the fix is smaller than proposed — only the three bare errChan <- err sends need guarding. Since cleanup always cancels connCtx before the next attempt, a connCtx-guarded send is guaranteed to unblock the abandoned producer.
I'll include a reconnect-provoking regression test that fails deterministically on the current code (leaked RunLive producer goroutine blocked on the abandoned channel). PR shortly.
PR up: #1167 — the three errChan sends are now guarded with select { case errChan <- err: case <-connCtx.Done(): }, matching the existing eventsChan convention. The regression test fakes the Gemini Live wire protocol over a local websocket and fails deterministically on the unfixed code (goroutines parked in chan send at base_flow.go:389); details and before/after evidence in the PR body.
Activity
docs(liveflow): add upstream engine goroutine leak to known-gaps list
chore: upgrade to upstream google/adk-go v1.5.0 (true merge) (#50)
indurireddy-TF commentedon Jul 15, 2026
Hi @dmora Thanks for pointing this out.
Using a select with <-connCtx.Done() seems like the cleaner fix as it aligns with the existing eventsChan logic, whereas a buffered channel is fragile if we add more background workers in the future. WDYT?
chethanuk commentedon Jul 17, 2026
I'm picking this up with the
select { case errChan <- err: case <-connCtx.Done(): }approach suggested above — it extends the existingeventsChanguard convention already inRunLive.One small correction to the issue text:
cleanup()does run on the reconnect path (it's called unconditionally after the consumer loop, before the reconnect check), so the fix is smaller than proposed — only the three bareerrChan <- errsends need guarding. Sincecleanupalways cancelsconnCtxbefore the next attempt, aconnCtx-guarded send is guaranteed to unblock the abandoned producer.I'll include a reconnect-provoking regression test that fails deterministically on the current code (leaked
RunLiveproducer goroutine blocked on the abandoned channel). PR shortly.chethanuk commentedon Jul 17, 2026
PR up: #1167 — the three
errChansends are now guarded withselect { case errChan <- err: case <-connCtx.Done(): }, matching the existingeventsChanconvention. The regression test fakes the Gemini Live wire protocol over a local websocket and fails deterministically on the unfixed code (goroutines parked inchan sendatbase_flow.go:389); details and before/after evidence in the PR body.fix(llminternal): guard RunLive errChan sends so abandoned producers …
fix: backport data-race and live-connection lifecycle fixes (#1139, #…
fix(backport): read only the places that record a backport
ci: automate v1 backports from the v1-needed label (#1327)
ci: automate v1 backports from the v1-needed label (google#1327)
fix(llminternal): guard RunLive errChan sends so abandoned producers …
ci: automate v1 backports from the v1-needed label (google#1327)