Repository navigation
Source download: no address-family fallback — a black-holed IPv6 path fails the download even though IPv4 works #325
Description
Description
SocketsHttpHandler attempts the addresses returned for a host in OS-provided order and does not race
address families. On a machine where IPv6 is routed but unreachable — a default route exists, so SYNs are
sent and silently dropped rather than failing fast — every IPv6 address is tried in turn until the connect
budget expires. The working IPv4 addresses are never reached, and the source is reported unreachable
despite being perfectly reachable.
This is not the same defect as #323. #323 bounded how long a stalled attempt may run; this is about the
attempt targeting an address family that cannot work while a working one goes untried. #323's
ConnectTimeout remains a prerequisite — without it the parallel attempt introduced here would itself be
unbounded.
Home Assistant and homelab deployments routinely have partial or flapping IPv6, so this is not specific to
one developer machine.
Reproduction steps
Against raw.githubusercontent.com during a window when IPv6 is black-holed, using SocketsHttpHandler
with ConnectTimeout = 10s:
DNS order as .NET sees it:
InterNetworkV6 2606:50c0:8002::154 <- tried first
... 3 more AAAA ...
InterNetwork 185.199.111.133 <- never reached
... 3 more A ...
app default (dual-stack) FAIL in 10.03s -> ConnectTimeout
forced IPv4 OK 200 in 0.49s
forced IPv6 FAIL in 10.00s -> ConnectTimeout
Same host, same moment: IPv4 works in half a second while the default path times out.
Note the condition is intermittent on the machine where it was found — IPv6 reachability comes and goes,
and a later run had all three variants succeeding. The defect is not that IPv6 is down; it is that the
download fails when it is, instead of using the family that works.
Expected behaviour
- A connect attempt races both address families rather than exhausting one before trying the other
(RFC 8305 Happy Eyeballs: start the preferred family, start the other after a short delay, take the
first to succeed, cancel the loser). - A host reachable over either family connects promptly regardless of which family is degraded.
- The behaviour stays inside the
ConnectTimeoutbudget Source download: a stalled connection attempt outlives its request and fails every other source on the same host #323 introduced. - A single-family host (only A, or only AAAA) still connects normally.
Actual behaviour
The connect budget is spent entirely on the first-listed family. With ConnectTimeout = 10s the request
fails with:
System.TimeoutException: A connection could not be established within the configured ConnectTimeout.
at System.Net.Http.HttpConnectionPool.CreateConnectTimeoutException(OperationCanceledException oce)
at System.Net.Http.HttpConnectionPool.InjectNewHttp11ConnectionAsync(QueueItem queueItem)
...
at System.Net.Sockets.SocketAsyncEventArgs.<DnsConnectAsync>g__Core|113_0(MultiConnectSocketAsyncEventArgs ...)
MultiConnectSocketAsyncEventArgs in that stack is the sequential multi-address walk: one family at a
time, in order, with no concurrent attempt on the other.
Failing tests
| Test class | Test method | Status before fix |
|---|---|---|
| SourceCacheHttpClientTests | SourceCacheClient_PrimaryHandler_HasHappyEyeballsConnectCallback | ❌ red |
| HappyEyeballsConnectTests | Connect_PreferredFamilyBlackHoled_FallsBackToWorkingFamilyPromptly | ❌ red |
| HappyEyeballsConnectTests | Connect_BothFamiliesReachable_UsesFirstToConnect | guard (passed pre-fix) |
| HappyEyeballsConnectTests | Connect_OnlyIPv4Resolved_ConnectsWithoutDelay | guard (passed pre-fix) |
| HappyEyeballsConnectTests | Connect_OnlyIPv6Resolved_ConnectsWithoutDelay | guard (passed pre-fix) |
| HappyEyeballsConnectTests | Connect_AllAddressesBlackHoled_FailsWithinConnectTimeout | guard (passed pre-fix) |
Only the first two demonstrate the defect. The algorithm tests cannot compile without the new type, so
they were first run against a deliberately naive implementation (a sequential walk in resolved order,
reproducing the SocketsHttpHandler behaviour being replaced) to make failure an assertion failure rather
than a compile error. Against that, the four marked "guard" passed — a sequential walk genuinely handles
single-family and both-reachable hosts correctly. They are regression guards, recorded as such rather than
described as red. See the plan doc's "Finding" section, including the two test defects this process
surfaced.
Definition of done
- Failing test(s) listed above are red before the fix is written
- Fix implemented
- All listed tests pass (green)
- No regression in related tests
- Findings summarised in a closing comment
Activity
fix [#325]: race IPv6 and IPv4 so a dead address family cannot fail t…
docs [#325]: add the plan doc and wire it into the milestone
docs [#325]: add the unreleased changelog entries
DutchJaFO commentedon Aug 20, 2026
This bug was the result of an overengineered solution to a problem.
refactor [#325]: drop the ConnectCallback; the default handler downlo…
fix [#323][#325]: give the download a realistic budget - connect 60s,…
fix [#325]: take the address-family preference from the resolver, not…
docs [#323][#325][#326]: sync the plans with the code that actually s…
docs [#326][#325][#323]: changelog for #326, and correct what #325 no…
docs: review the milestone order against this session's findings
docs [#325][#309]: drop a stale next-pointer, and record that #325 is…