Conversation
AddConn is the second way into getClient, and only roundTripOpt ran init. A transport whose first use is AddConn dialed with a nil QUICConfig, a nil quic.Transport and a nil newClientConn, and the dial runs in its own goroutine, so the nil transport took the process down with it. Fixes imroc#457
imroc
reviewed
Sep 14, 2026
imroc
left a comment
Owner
There was a problem hiding this comment.
Thanks for the clean reproduction and write-up. I verified the fix locally (the fork CI hasn't run, as it needs manual approval):
Bug confirmation
AddConnis req's own addition for the alt-svc path (handlePendingAltSvccallst.t3.AddConnfrom its own goroutine) — quic-go's http3 transport has noAddConn, so there is indeed nothing to port from upstream; the second door intogetClientis ours.- On master,
roundTripOptwas the onlygetClientcaller that raninitOnce(getClientis otherwise reached only fromdoRoundTripOptandAddConn). A first-use-AddConntransport reaches the dial goroutine witht.transport,t.QUICConfigandt.newClientConnall nil, andt.transport.DialEarlyon a nil*quic.Transporttakes the process down. The panic is real and matches the stack in #457. - The intermittency explanation also checks out: every https request goes through
RoundTripOnlyCachedConn→roundTripOptfirst, which usually runsinitbefore the alt-svc goroutine gets toAddConn.
Fix assessment
initOnce.Doat the top ofgetClientis the right chokepoint: bothroundTripOptandAddConnfunnel through it, so any future entry point inherits the guarantee too.- Running it outside
t.mutexmatchesroundTripOpt's existing pattern, andinit()doesn't toucht.mutex, so there is no deadlock or lock-ordering concern. - Reading
t.initErrafterinitOnce.Do(...)is race-free (thesync.Oncehappens-before edge covers it);initErris written exactly once inside the Once. - The now-redundant
DoinroundTripOptis harmless.
Verification (Linux, Go 1.27.0, commit a1b6d12)
go build ./... && go vet ./... && go test ./...— all green. The two failures mentioned in the description (TestTraceInfo,TestSetFile) don't reproduce on Linux; they look environment-specific and unrelated to this change.TestAddConnInitializesTheTransportpasses with the fix; withinternal/http3/transport.goreverted to master it fails withdialed with a nil quic.Config— independently confirming the regression test catches the bug.- The test itself is well constructed: no network (the Dial stub records the config and returns an error), a 10s guard so it can't pass by the stub never being called, and assertions on init-mutated fields (
MaxIncomingStreams == -1, exactly oneVersion).
Leaving out the ErrNilNewClientConn belt-and-braces guard looks right to me: once init runs on this path newClientConn can no longer be nil, and an exported error for an unreachable state would just be extra API surface.
Since this touches internal/http3/ (our modified quic-go copy), per repo policy I'm leaving the merge decision to the maintainer — but the analysis and verification above all check out from my side.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #457.
AddConnis the second way intogetClient, and onlyroundTripOptraninit. So a transport whose first use isAddConnreachesdialwithQUICConfig,transportandnewClientConnall still nil, anddialruns in its own goroutine, so the nilquic.Transporttakes the process with it.Reproduced on master, and it is the stack from the issue:
That is
tr := &Transport{}followed by oneAddConn.Why there is nothing to port from quic-go
You mentioned in the thread that the upstream fix would need porting by hand. There is no upstream fix: quic-go's http3 transport has no
AddConnat all, soroundTripOptis its only door intogetClientand init always runs first.AddConnis this repo's, for the alt-svc path, and it opened a second door.Why it is intermittent
The normal request path calls
t.t3.RoundTripOnlyCachedConn(req)on every https request once HTTP/3 is enabled, and that goes throughroundTripOpt, so init has usually already run by the timehandlePendingAltSvccallsAddConnin its goroutine. An end to end test here, a server answeringAlt-Svc: h3=":443"to a normal client, survives for that reason. It only bites when nothing has run init yet, which is why it shows up on short lived clients under load.The fix
initOnce.Doat the top ofgetClient, which is the one point both paths pass through. Outside the mutex, the wayroundTripOptdoes it, becauseinitopens a UDP socket.Test
TestAddConnInitializesTheTransporthands the transport aDialfunc that records the*quic.Configit is given and returns an error, so nothing touches the network. On master the config is nil, since init never ran:It passes with the change, and the bare reproduction above now returns instead of panicking. The test fails first if the dial never happens, so it cannot pass by the stub going uncalled.
go test ./...gives the same two failures with and without the change,TestTraceInfoandTestSetFile. Both are on master here as well, andTestSetFileis a Windows error string difference.Credit, and one thing left out
@danielboros reported this and put the same
initOnce.Docall in their fork. They also added anErrNilNewClientConnguard indial. I have left that out: once init runs on this pathnewClientConncannot be nil, and it would add an exported error for a state that can no longer happen. Happy to add it if you would rather have the belt and braces.