llminternal: a dropped live connection is resumable or fatal depending on which goroutine reports it, so live sessions stop resuming on Windows
What happens
Flow.RunLive runs a reader goroutine and a sender goroutine against the same live connection. Both report failures into the same unbuffered errChan, and the flow's select consumes exactly one of them, so whichever arrives first decides whether the session resumes or dies. That verdict comes from isResumable, which matches substrings of the error text.
The two goroutines do not produce the same text for the same event:
- the reader gets the websocket layer's close error,
failed to receive message: websocket: close 1006 (abnormal closure): unexpected EOF, which is the same on every platform - the sender gets the raw socket write failure, whose wording is platform-specific
The substring list (broken pipe, connection reset, EOF, 1008, GoAway) covers the reader's text and the POSIX sender text. It does not cover the Windows sender text. Measured with a probe that kills the peer and then calls each path directly:
| platform | sender error on a dropped connection | isResumable |
|---|---|---|
| Linux | write tcp ...: write: broken pipe |
true |
| Windows | write tcp ...: wsasend: An established connection was aborted by the software in your host machine. |
false |
So on Windows, if the sender observes the loss first, the flow falls through to sess.pushError(err); cleanup(); return and never reconnects. If the reader observes it first, the identical connection loss resumes normally.
Go does not map WSA error numbers onto the POSIX constants, so errors.Is(err, syscall.ECONNABORTED) does not help either. The unwrap chain is:
*net.OpError -> *os.SyscallError -> syscall.Errno(10053) // WSAECONNABORTEDImpact
On Windows a live or bidi streaming session drops instead of auto-resuming after a transient connection loss, non-deterministically. Linux is unaffected, which is why CI never sees it.
How it surfaced
TestRunLiveNoGoroutineLeak/realtime_sender_error_after_connection_loss_does_not_leak fails intermittently on Windows:
base_flow_live_test.go:318: never received a model turn for the retried realtime send
base_flow_live_test.go:392: connection count = 1, want 2connection count = 1, want 2 is the tell: the flow never opened the second connection. Despite the test's name this is not a leak.
Being straight about the rate: I saw it once in roughly 550 runs of the test binary, and only under heavy CPU load, which is what makes the sender win the race often enough to observe. The mechanism above is what I can demonstrate on demand; the end-to-end flake itself is rare.
Proposed fix
Classify the transport failure by type rather than by text. Any *net.OpError arriving on this path means the socket of an already-established live connection failed, since the dial is handled separately above, so it is resumable on every platform:
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}The existing substring checks stay for the websocket-level cases (1006, 1008, GoAway), which are not *net.OpError.
I am happy to send that. It needs isResumable lifted out of RunLive to a package-level function so it can be tested directly, plus a table test that synthesizes both platforms' error shapes so a Linux runner covers the Windows ones. I have that working locally if you want it.
Environment
mainat f7e16e0- Go 1.26.6, windows/amd64, cross-checked on linux/amd64 (WSL2)
Source: google/adk-go