Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Happy Eyeballs Support #1187

Merged
merged 6 commits into from
Apr 23, 2023
Merged

Add Happy Eyeballs Support #1187

merged 6 commits into from
Apr 23, 2023

Conversation

KazWolfe
Copy link
Member

@KazWolfe KazWolfe commented Apr 21, 2023

This pull request aims to solve a number of issues related to bad IPv6 compatibility, especially an inability to load Kamori when one's ISP hiccups. This is done by implementing a variant of the Happy Eyeballs algorithm, as described in RFC 8305. The specific implementation we're using here was derived from a pull request to the jellyfin project, with some tweaks that hopefully would help our use case.

This pull request provides a new class meant for public consumption: HappyEyeballsCallback. This class provides a ConnectCallback method that can be assigned to a SocketsHttpHandler when creating an HttpClient. HappyEyeballsCallback implements the following behavior:

  1. If set, the forcedAddressFamily will be used and all evaluation will be skipped.
  2. The callback will check for a cached AddressFamily for the DNS name being used. If it exists, the cached value is used and future steps will be skipped.
  3. The callback will race an IPv4 and IPv6 connection attempt (with the IPv4 connection delayed by a configurable time period, defaulting to 100ms).
  4. The first successful connection attempt to complete will return and be cached for that hostname for the rest of the session. If no connection succeeds, the error propagates up the chain.

When a connection is successfully established, the resulting AddressFamily is cached for that DNS hostname and all future connections for the life of the application will use that AddressFamily.

The HappyEyeballsCallback class provides state in the form of the DNS/AddressFamily cache, which can (but does not have to be) shared between multiple HttpClients if desired. Plugin developers should normally create and maintain their own callback with their own state.

This pull request also adds a new service, the HappyHttpClient, which is intended to be shared between all Dalamud subsystems that can use a common HttpClient and obsoletes Util.HttpClient. Further, all uses of Util.HttpClient and any new non-customized HttpClients are replaced with references to the HttpClient provided by this service.

This pull request, if merged, fixes #1186.

- Create HappyEyeballsCallback to provide and track state around a callback for SocketsHttpHandler.
- Create HappyHttpClient to provide easy access to a shared HTTP client service
I'm almost certain this will break *a lot*, especially as I'm coalescing all the `new HttpClient()`s down into the service. Eh, that's why it's WIP.
@KazWolfe
Copy link
Member Author

KazWolfe commented Apr 21, 2023

Potential future work:

  • Fetch all DNS requests and race them, more in line with how Happy Eyeballs actually works. This will also help handle annoying cases where DNS returns a bad/invalid host.
  • Figure out a way to "expire" records from the address cache. Unsure if WeakReference works on enums?
  • The Jellyfin-provided solution creates two CancellationTokens and does a lot more work flip-flopping between things. I tried to simplify this, but this simplification may introduce bugs. In other words: is there a reason Jellyfin made things a lot more complex that I missed?

- Don't expose SharedSocketsHandler, as it can cause problems if not treated with a lot of care.
- Expose a SharedHappyEyeballsCallback for systems that want to synchronize state.
- Make HappyEyeballsCallback Disposable.
  - Just clear the array, not much stateful stuff in there.
- Switch PluginRepository to use its own Handler, with the shared callback.
- New AsyncUtils for helper methods related to async/Task operation.
- Add FirstSuccessfulTask to help race a list of Tasks.
- Swap HappyEyeballsCallback over to using FirstSuccessfulTask.
- Fix a concurrency bug on the Address Family cache.
@KazWolfe
Copy link
Member Author

Testing Strategy

Testing this particular change is a bit annoying as HttpClient will keep connections alive. Therefore, if a (successful) IPv6 connection is made and IPv6 connectivity is subsequently dropped, all future connections (at least until HttpClient decides to close the connection) will experience issues.

With that out of the way, testing this seems easiest with clumsy and setting a high lag time (e.g. 5 seconds) for IPv6 connections. Without this PR, opening the plugin installer should take approximately the delay time (or double) while everything is fetched. With this PR, the connection should still go through reasonably quickly. My testing strategy based on this has been as follows:

  1. Launch the game with IPv6 compatibility and no clumsy rules.
  2. Open the Plugin Installer and time the request. Close the installer.
  3. Enable clumsy and either re-enable the game or wait ??? for the keepalive to expire.
  4. Open the Plugin Installer and time the requests. This should roughly match the same timing as from Step 2.

Some more advanced simulation is possible as well. For example, traffic to Cloudflare's IPv6 network can be completely blocked (see their IP list) using clumsy, which would cause Kamori to be unable to load without this PR.

@KazWolfe KazWolfe marked this pull request as ready for review April 22, 2023 23:34
If IPv6 support isn't detected, skip the entire race process and simply use the IPv4 connection.
- Rename "ipv4WaitMillis" to "ipv6GracePeriod"
- Allow a fast IPv6 failure to immediately start an IPv4 attempt
  - If IPv6 fails to connect in the grace period, continue with the race.
- Change AsyncUtils#FirstSuccessfulTask to take an ICollection instead
Comment on lines +69 to +86
var tryConnectIPv6 = this.AttemptConnection(AddressFamily.InterNetworkV6, context, linkedToken.Token);
var timedV6Attempt = Task.WhenAny(tryConnectIPv6, Task.Delay(this.ipv6GracePeriod, linkedToken.Token));

if (await timedV6Attempt == tryConnectIPv6 && tryConnectIPv6.IsCompletedSuccessfully)
{
stream = tryConnectIPv6.GetAwaiter().GetResult();
}
else
{
var race = AsyncUtils.FirstSuccessfulTask(new List<Task<NetworkStream>>
{
tryConnectIPv6,
this.AttemptConnection(AddressFamily.InterNetwork, context, linkedToken.Token),
});

// If our connections all fail, this will explode with an exception.
stream = race.GetAwaiter().GetResult();
}
Copy link
Member Author

@KazWolfe KazWolfe Apr 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feeling messy again, but unsure if I want to start doing some really fun things to FirstSuccessfulTask to make it FirstSuccessfulTaskWithDelayBetweenTasksAndFastFailureToTextTask.

:/

@goaaats goaaats merged commit 6a0b4e5 into goatcorp:master Apr 23, 2023
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.

Add Happy Eyeballs to our HttpClient
2 participants