Skip to content

fix(transport): Track connections from a custom DialTLSContext - #36

Merged
korya merged 4 commits into
masterfrom
korya-fix-dialtls-leak
Aug 30, 2026
Merged

fix(transport): Track connections from a custom DialTLSContext#36
korya merged 4 commits into
masterfrom
korya-fix-dialtls-leak

Conversation

@korya

@korya korya commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Problem

A binary embedding the library leaked a connection per load flow, for ever, if its HTTP client used a custom TLS dialer.

net/http dials https through DialTLSContext (or DialTLS) whenever the caller's transport sets one, bypassing the factory's DialContext wrapper entirely. Those connections were never registered with ownedTransport, so teardown fell back to CloseIdleConnections — which by design skips an HTTP/2 connection still winding down a cancelled stream. With the default 90s IdleConnTimeout the connection eventually expires; on a hand-built &http.Transport{} (IdleConnTimeout 0) it never does, and the connection, its readLoop, and the orphaned transport clone survive until the server hangs up. A 40-run soak grew by 111 goroutines and 6 MiB. This breaks INV-4 for the one caller shape most likely to appear in a long-lived agent.

Solution

The custom TLS dialer is wrapped on each per-flow clone, so its connections are tracked and torn down with the run.

They are tracked unwrapped: net/http only upgrades to HTTP/2 when the dialled value is exactly a *tls.Conn, so a wrapper would silently drop every flow to HTTP/1.1 — a test asserts the negotiated protocol survives tracking. The dial address is not rewritten for test_endpoint, because the caller's dialer would then verify the certificate against the rewritten address; that limitation was already the behaviour and is now spelled out as DISC-9 and warned about at runtime, alongside the existing DISC-7 proxy warning.

TestNoLeaksAcrossRuns runs with both dialer kinds. A new TestSoak covers what the instant-of-return check cannot: forty runs per scenario (completed, cancelled mid-load, custom TLS dialer with no idle timeout) asserting the goroutine count and post-GC HeapInuse plateau. It is skipped under -short. Before the fix the custom-dialer scenario failed; after, all three are flat.

Other Changes

The new INV-4 case failed on the minimum-Go job, and reproduces on any loaded machine. net/http dials in a goroutine that outlives the cancelled request that started it, so when a phase ends that goroutine can still be inside the caller's dialer — a socket the library has not been handed and cannot close. With a custom TLS dialer that window spans the whole handshake rather than a loopback TCP connect, which is why only the new case failed. The dial context is cancelled with the phase, so the dialer aborts its handshake and closes the socket immediately after; the check for that case now polls to zero rather than demanding it at the instant of return, and the DialContext case keeps the instant check.

ownedTransport additionally latches closed at teardown so a dial completing after the snapshot is closed on arrival. That was never observed firing in 72 contended runs — it is six lines against a hole, not a fix for anything seen.

Needs a decision before merge: INV-4 reads "when Run returns, all goroutines, connections, and timers it created are gone." A socket the caller's own dialer is still handshaking is a documented exception to that sentence. Should docs/product-specs/invariants.md say so, or is the test comment enough? I have not edited the spec.

Related: INV-4, DISC-6, DISC-7, DISC-9

🤖 Generated with Claude Code

https://claude.ai/code/session_017dM7M83LicKy8psVSvjod2

korya and others added 4 commits August 29, 2026 04:57
net/http dials https through DialTLSContext (or DialTLS) when the caller sets one,
bypassing the factory's DialContext wrapper entirely. Those connections were never
registered with ownedTransport, so closeAll fell back to CloseIdleConnections, which
skips an HTTP/2 connection still winding down a cancelled stream. On a transport with
IdleConnTimeout 0 the connection, its readLoop, and the orphaned clone lived until the
server hung up: a 40-run soak grew by 111 goroutines.

The custom dialer is now wrapped on each clone and its connections tracked unwrapped:
net/http only upgrades to HTTP/2 when the dialled value is exactly a *tls.Conn, so a
wrapper would silently drop to HTTP/1.1. The address is not rewritten for test_endpoint,
because the caller's dialer would verify the certificate against it; the run warns
instead of silently ignoring the override (DISC-9).

TestNoLeaksAcrossRuns now runs with both dialer kinds.

Related: INV-4, DISC-6, DISC-7, DISC-9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dM7M83LicKy8psVSvjod2
TestNoLeaksAcrossRuns checks the instant Run returns; this checks the long run an
embedding agent actually lives in. Three scenarios (completed runs, runs cancelled
mid-load, a custom TLS dialer with no idle timeout) loop Run forty times after a warm-up
and assert that the goroutine count and post-GC HeapInuse plateau. Skipped under -short.

Related: INV-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dM7M83LicKy8psVSvjod2
The DialTLSContext case of TestNoLeaksAcrossRuns failed on the minimum-Go job and
reproduces on any loaded machine: one socket is still open at the instant Run returns.

net/http dials in a goroutine that outlives the cancelled request that started it
(startDialConnForLocked -> dialConnFor), so when a phase ends that goroutine can still be
inside the caller's dialer. With a custom TLS dialer that window spans the whole
handshake rather than a loopback TCP connect, which is why only the new case failed. The
socket belongs to the caller's dialer at that moment: the library has not been handed it
and cannot close it. The dial context is cancelled with the phase, so the dialer aborts
its handshake and closes the socket immediately after. What the library owes is that such
a connection dies promptly, not that it was never opened, so that case now polls to zero
instead of demanding it at the instant of return. The DialContext case keeps the
instant check.

ownedTransport also latches closed at teardown, so a dial that does complete after
closeAll took its snapshot is closed on arrival rather than filed in a map nobody reads
again. This was never observed firing in 72 contended runs; it closes the hole for six
lines.

Related: INV-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dM7M83LicKy8psVSvjod2
TestSoak/default failed on the minimum-Go job at goroutines 4 -> 10, and the printed
trend shows why it is not a leak: 10, 10, 7, 4 over the run, with no ramp.

The count is process-wide, so it includes the in-process test server's per-connection
goroutines. The client aborts its load flows, which is an abortive close, so the server
side unwinds on the kernel's schedule rather than ours — INV-4 is a promise about the
client's own sockets. A single sample 50ms after the last run catches that transient on a
loaded runner.

The final check now polls for the plateau, as TestNoLeaksAcrossRuns already does, and
tightens the bound it settles to: +2 goroutines and +8MiB rather than +5 and +4MiB.
Verified it still catches what it exists for — with the DialTLSContext tracking disabled
the same test reports goroutines 16 -> 184.

Related: INV-4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dM7M83LicKy8psVSvjod2
@korya
korya marked this pull request as ready for review August 30, 2026 17:06
@korya
korya merged commit 968ca45 into master Aug 30, 2026
12 checks passed
@korya
korya deleted the korya-fix-dialtls-leak branch August 30, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant