fix(transport): stop caching a soft HTTP/1.1 fallback as the host's protocol - #106
Open
burruplambert wants to merge 1 commit into
Open
fix(transport): stop caching a soft HTTP/1.1 fallback as the host's protocol#106burruplambert wants to merge 1 commit into
burruplambert wants to merge 1 commit into
Conversation
…rotocol In auto mode doAuto learns the protocol a host speaks and caches it per session in protocolSupport, so later requests skip the negotiation (sardanioss#68). Two different facts were being written into that cache under the same key: - "this host negotiated http/1.1 via ALPN": a property of the host, and correct to cache; - "the H2 attempt failed this time and the HTTP/1.1 fallback got the request through": a property of one attempt. A reset or timed-out handshake, a first dial that did not survive, anything transient. The second was written exactly like the first, at two sites: the bottom-of-doAuto fallback and, for presets with H3 support, the fallback inside raceH3H2 (H2 failed for a non-ALPN reason -> doHTTP1 -> reported as ProtocolHTTP1 with a nil error, which doAuto then cached). Only the first request to a host (or the first after Refresh) can hit this: once a host is known as HTTP/2, a later transient failure serves one request over HTTP/1.1 without touching the cache. That makes it silent and permanent rather than rare: one bad handshake on the opening request and every following request to that host goes out over HTTP/1.1, and with it a different fingerprint, with nothing that would ever re-probe. Long-lived sessions and anything that creates sessions often (each starts with an empty cache) are the most exposed. Note that on a direct connection the initial dial in getOrCreateConn is not retried (retryDial's extra attempts apply only behind a proxy), so a single transient handshake failure is enough. Fix: the fallback runs exactly as before and the request still succeeds, but it no longer writes the cache. raceH3H2 reports ProtocolAuto for a response it served through that fallback, meaning "nothing learned about the host", and doAuto skips the cache write for it; the bottom-of-doAuto fallback simply no longer writes. The next request attempts H2 again and, when that works, the host is cached as H2 like any other. ALPN downgrades and the DisableHTTP2 preset branch are cached as before, so an H1-only host still pays the failed H2 attempt only once. Forced protocols never consult the cache and are unaffected. What each request does on the wire is unchanged: every path returns exactly what it returned before, this only removes two map writes. The stream path (doStreamAuto) was already correct: doStreamHTTP2OrHTTP1 falls back to H1 only on an ALPN mismatch, so it never had a soft fallback to cache. Precedent: the library already fixed the structurally identical "cached thing goes stale and pins a long-lived session" problem for ECH configs by making the cache self-heal; this is the protocol cache's equivalent, done by not caching what was never a fact about the host. Tests (transport/protocol_cache_test.go), all against real in-process servers: - a listener that drops the first N accepts stands in for a transient handshake failure; the H1 fallback dials fresh, lands on connection N+1 and succeeds. TestAutoDoesNotCacheSoftH1Fallback drives both doAuto branches (a preset without H3, plain H2 attempt, N=1; a preset with H3, race branch, N=2 because the probe connects before the dial) and asserts nothing is cached after the fallback and the second request goes out over H2. Fails on the previous code with "host cached as h1" in both branches. - TestAutoStillCachesALPNDowngrade: an http/1.1-only server is still cached as HTTP/1.1 and served from the cache on the next request, on both presets. - TestAutoCachesH2: a healthy H2 host is cached as H2 on the first request, as before. Docs: the auto-negotiation page (step 6) now notes that the fallback is the one case that is not cached, and why.
|
@burruplambert is attempting to deploy a commit to the sardanioss' projects Team on Vercel. A member of the Team first needs to authorize it. |
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.
Problem
In auto mode
doAutolearns the protocol a host speaks and caches it per session inprotocolSupport, so later requests skip the negotiation (added for #68). Two different facts were being written into that cache under the same key:The second was written exactly like the first, at two sites: the bottom-of-
doAutofallback, and, for presets with H3 support, the fallback insideraceH3H2(H2 failed for a non-ALPN reason,doHTTP1succeeded, reported back asProtocolHTTP1with a nil error, whichdoAutothen cached).Only the first request to a host (or the first after
Refresh) can hit this: once a host is known as HTTP/2, a later transient failure serves one request over HTTP/1.1 without touching the cache. That makes the failure silent and permanent rather than rare: one bad handshake on the opening request and every following request to that host goes out over HTTP/1.1, and with it a different fingerprint, with nothing that would ever re-probe. Long-lived sessions and anything that creates sessions often (each starts with an empty cache) are the most exposed. On a direct connection the initial dial ingetOrCreateConnis not retried (retryDial's extra attempts apply only behind a proxy), so a single transient handshake failure is enough.Fix
The fallback runs exactly as before and the request still succeeds; it just no longer writes the cache.
raceH3H2reportsProtocolAutofor a response it served through its H1 fallback, documented as "nothing learned about the host", anddoAutoskips the cache write for it.doAutofallback no longer writes.The next request attempts H2 again and, when that works, the host is cached as H2 like any other. ALPN downgrades and the
DisableHTTP2preset branch are cached as before, so an H1-only host still pays the failed H2 attempt only once. Forced protocols never consult the cache and are unaffected.What each request does on the wire is unchanged. Every path returns exactly what it returned before; the diff removes two map writes and adds comments. The stream path (
doStreamAuto) was already correct:doStreamHTTP2OrHTTP1falls back to H1 only on an ALPN mismatch, so it never had a soft fallback to cache.Precedent: the library already fixed the structurally identical "cached thing goes stale and pins a long-lived session" problem for ECH configs by making that cache self-heal. This is the protocol cache's equivalent, done by not caching what was never a fact about the host.
Tests
transport/protocol_cache_test.go, all against real in-process servers:TestAutoDoesNotCacheSoftH1Fallbackdrives bothdoAutobranches: a preset without H3 support (plain H2 attempt, N=1) and one with it (race branch, N=2, because the probe connects before the dial). It asserts nothing is cached after the fallback and that the second request goes out over H2 and is then cached as H2. On the previous code it fails in both branches withhost cached as h1.TestAutoStillCachesALPNDowngrade: an http/1.1-only server is still cached as HTTP/1.1 and served from the cache next time, on both presets.TestAutoCachesH2: a healthy H2 host is cached as H2 on the first request, as before.transport,session, root,poolandclient(minus the two tests that hittls.peet.ws, whose certificate has expired) pass under-race. The race-branch case takes about 6s because it is the both-probes-fail path, which waits out the H3 probe budget; that is the scenario under test, not the fix.Docs
CHANGELOG entry under Unreleased. The auto-negotiation page (step 6) now notes that the fallback is the one case that is not cached, and why.
Two observations, deliberately not changed here
raceH3H2tries H1 itself, anddoAuto's bottom fallback then tries again on the non-ALPN error. Presets without H3 support get one attempt. That is inconsistent and undocumented, but removing it changes what happens on the wire in a failure case, so it is not part of this fix. Happy to send it separately if you agree it should be one attempt in one place.ProtocolHTTP3branch of buffereddoAutohas no fallback if the H3 request fails, whiledoStreamAutofalls back to H2/H1. Left as is.Unrelated:
TestSessionDo_NonReplayableBodyOn307in the root package is a pre-existing timing flake for me, roughly one run in ten on untouchedmain(forced H1 against a raw listener,write: broken pipe). Mentioning it only so a red run here is not mistaken for this change.