fix(network): a silent IPv6 address no longer holds up IPv4, so a PC with broken IPv6 reaches git.magicbean.dev #115

Merged
HumanBean merged 1 commit from madebyjordan/LongshotLauncher:feat/happy-eyeballs into main 2026-11-15 21:32:53 +00:00
Contributor

On a PC whose IPv6 doesn't actually reach the internet, the launcher's update check fails with "git.magicbean.dev did not answer in time", while a browser on the same PC opens the site at once. .NET's own connect tries a name's addresses one after another, each to its end, and Windows prefers IPv6. The IPv6 connect waits about 21 s for nothing, longer than the check's 20 s timeout, so IPv4 is never reached. Browsers avoid this by trying IPv4 alongside a slow IPv6 (Happy Eyeballs). This does the same. It's one of the likely causes behind the "launcher can't connect" reports (Longshot#738, #support), and #106's log would show it as an IPv6 connect that is stopped at the timeout.

What changed

Core/Connections.cs, the ConnectCallback #106 added (the launcher's only way to open a connection), now tries the addresses as browsers do (RFC 8305):

  • Order: the name service's first address, then the two families taking turns (IPv6, IPv4, IPv6…), each in the name service's order (Interleave).
  • Staggered starts: the next address starts at once when the one before fails, or after 250 ms with no answer while that one goes on (Stagger, RFC 8305's Connection Attempt Delay).
  • First to connect wins: it is kept, and the others are stopped. A connect that loses but connects anyway later has its stream closed.
  • Errors are unchanged: the name service's SocketException, the last address's SocketException, or the request's cancellation. So NetFailure's words for a failure stay the same.
  • The log says more: besides #106's lines, a stopped connect reads connect <host> <address>: stopped after N ms, <winner> connected first. At the request's timeout, every connect still going reads no answer, stopped after N ms.

What stays the same: a single address (an IP literal, or a proxy with one address) connects as before; the proxy handling; DOTNET_SYSTEM_NET_DISABLEIPV6=1; and the 20 s / 60 s request timeouts.

Tested

Windows 11, .NET SDK 10.0.404, on main @ f6a4123:

  • python -I tests/test_launcher_core.py: 1292 checks pass. New in ConnectionChecks.cs, all with a fake resolver and connect:

    • an IPv6 address that never answers is overtaken by IPv4 after the stagger, and stopped (the log says so);
    • the interleaved order (2001:db8::1 192.0.2.1 2001:db8::2 192.0.2.2 2001:db8::3, and one family kept as it is);
    • a losing connect that connects late has its stream closed;
    • at the request's timeout, both connects still going stop and are logged;
    • every address refused: the next starts at once, not after the stagger, and the last one's error is thrown.

    #106's checks pass unchanged: in-order failures, the timeout's address, IPv6 left out, DNS failure, and refused/DNS/not-TLS through HttpFetcher.

  • Real sockets, through this branch's Core in a console harness: an address that never answers (10.255.255.1) first, then a local server. It connected in 321 ms, with the first address stopped after 266 ms. Before, that connect would wait for the first address's TCP timeout (~21 s on Windows).

  • The real update check against the live servers: unchanged. It connected to the first Cloudflare address in 22 ms, and the run took 0.7 s.

  • dotnet build App -c Release -p:RestoreLockedMode=true -warnaserror: 0 warnings. dotnet format --verify-no-changes on the changed files: clean.

Not tested: a PC with real broken IPv6 (this one has only a Teredo address, so Windows prefers IPv4 here), and Linux.

🤖 Generated with Claude Code

On a PC whose IPv6 doesn't actually reach the internet, the launcher's update check fails with "git.magicbean.dev did not answer in time", while a browser on the same PC opens the site at once. .NET's own connect tries a name's addresses one after another, each to its end, and Windows prefers IPv6. The IPv6 connect waits about 21 s for nothing, longer than the check's 20 s timeout, so IPv4 is never reached. Browsers avoid this by trying IPv4 alongside a slow IPv6 (Happy Eyeballs). This does the same. It's one of the likely causes behind the "launcher can't connect" reports (Longshot#738, #support), and #106's log would show it as an IPv6 connect that is stopped at the timeout. ## What changed `Core/Connections.cs`, the `ConnectCallback` #106 added (the launcher's only way to open a connection), now tries the addresses as browsers do (RFC 8305): - **Order:** the name service's first address, then the two families taking turns (IPv6, IPv4, IPv6…), each in the name service's order (`Interleave`). - **Staggered starts:** the next address starts at once when the one before fails, or after **250 ms** with no answer while that one goes on (`Stagger`, RFC 8305's Connection Attempt Delay). - **First to connect wins:** it is kept, and the others are stopped. A connect that loses but connects anyway later has its stream closed. - **Errors are unchanged:** the name service's `SocketException`, the last address's `SocketException`, or the request's cancellation. So `NetFailure`'s words for a failure stay the same. - **The log says more:** besides #106's lines, a stopped connect reads `connect <host> <address>: stopped after N ms, <winner> connected first`. At the request's timeout, every connect still going reads `no answer, stopped after N ms`. What stays the same: a single address (an IP literal, or a proxy with one address) connects as before; the proxy handling; `DOTNET_SYSTEM_NET_DISABLEIPV6=1`; and the 20 s / 60 s request timeouts. ## Tested Windows 11, .NET SDK 10.0.404, on main @ f6a4123: - `python -I tests/test_launcher_core.py`: **1292 checks pass**. New in `ConnectionChecks.cs`, all with a fake resolver and connect: - an IPv6 address that never answers is overtaken by IPv4 after the stagger, and stopped (the log says so); - the interleaved order (`2001:db8::1 192.0.2.1 2001:db8::2 192.0.2.2 2001:db8::3`, and one family kept as it is); - a losing connect that connects late has its stream closed; - at the request's timeout, both connects still going stop and are logged; - every address refused: the next starts at once, not after the stagger, and the last one's error is thrown. #106's checks pass unchanged: in-order failures, the timeout's address, IPv6 left out, DNS failure, and refused/DNS/not-TLS through `HttpFetcher`. - **Real sockets, through this branch's Core in a console harness:** an address that never answers (10.255.255.1) first, then a local server. It connected in **321 ms**, with the first address stopped after 266 ms. Before, that connect would wait for the first address's TCP timeout (~21 s on Windows). - **The real update check against the live servers:** unchanged. It connected to the first Cloudflare address in 22 ms, and the run took 0.7 s. - `dotnet build App -c Release -p:RestoreLockedMode=true -warnaserror`: 0 warnings. `dotnet format --verify-no-changes` on the changed files: clean. Not tested: a PC with real broken IPv6 (this one has only a Teredo address, so Windows prefers IPv4 here), and Linux. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(network): a silent IPv6 address no longer holds up IPv4, so a PC with broken IPv6 reaches git.magicbean.dev
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Compile and test (pull_request) Blocked by required conditions
b33bfda2e9
.NET's own connect tries a name's addresses one after another, each to its end. On a PC whose IPv6 doesn't reach the
internet, the IPv6 connect waits ~21 s for nothing, longer than the update check's 20 s, so git.magicbean.dev "did not
answer" while a browser on the same PC opened it at once.

Connections.OpenAsync now tries them as browsers do (Happy Eyeballs, RFC 8305): the name service's first address, then the
families taking turns; the next one starts when the one before fails, or after 250 ms with no answer while it goes on; the
first to connect is kept, and the others are stopped (a late one's stream closed). The launcher log says which connected,
which were stopped and why, and where a timeout was spent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
HumanBean deleted branch feat/happy-eyeballs 2026-11-15 21:32:53 +00:00
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Compile and test (pull_request) Blocked by required conditions
Some workflows are waiting to be reviewed.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/LongshotLauncher!115
No description provided.