#1602·adk-go

llminternal: a dropped live connection is resumable or fatal depending on which goroutine reports it, so live sessions stop resuming on Windows

Author: harshitwandhareCreated Sep 15, 2026Updated Sep 15, 2026

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)   // WSAECONNABORTED

Impact

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 2

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

go
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

  • main at f7e16e0
  • Go 1.26.6, windows/amd64, cross-checked on linux/amd64 (WSL2)