diff --git a/osu.Framework/IO/Network/WebRequest.cs b/osu.Framework/IO/Network/WebRequest.cs
index 29bde4a5e1..1fc368d651 100644
--- a/osu.Framework/IO/Network/WebRequest.cs
+++ b/osu.Framework/IO/Network/WebRequest.cs
@@ -7,6 +7,7 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
+using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -126,7 +127,17 @@ private set
///
public bool AllowRetryOnTimeout { get; set; } = true;
- private static readonly HttpClient client = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate })
+ private static readonly HttpClient client = new HttpClient(
+#if NET5_0
+ new SocketsHttpHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
+ ConnectCallback = onConnect,
+ }
+#else
+ new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }
+#endif
+ )
{
// Timeout is controlled manually through cancellation tokens because
// HttpClient does not properly timeout while reading chunked data
@@ -692,6 +703,86 @@ public void Dispose()
#endregion
+ #region IPv4 fallback implementation
+
+#if NET5_0
+ ///
+ /// Whether IPv6 should be preferred. Value may change based on runtime failures.
+ ///
+ private static bool useIPv6 = Socket.OSSupportsIPv6;
+
+ ///
+ /// Whether the initial IPv6 check has been performed (to determine whether v6 is available or not).
+ ///
+ private static bool hasResolvedIPv6Availability;
+
+ private const int connection_establish_timeout = 2000;
+
+ private static async ValueTask onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
+ {
+ // Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way.
+ // This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6.
+
+ if (useIPv6)
+ {
+ try
+ {
+ var localToken = cancellationToken;
+
+ if (!hasResolvedIPv6Availability)
+ {
+ // to make things move fast, use a very low timeout for the initial ipv6 attempt.
+ var quickFailCts = new CancellationTokenSource(connection_establish_timeout);
+ var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token);
+
+ localToken = linkedTokenSource.Token;
+ }
+
+ return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken);
+ }
+ catch
+ {
+ // very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt.
+ // note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance)
+ // but in the interest of keeping this implementation simple, this is acceptable.
+ useIPv6 = false;
+ }
+ finally
+ {
+ hasResolvedIPv6Availability = true;
+ }
+ }
+
+ // fallback to IPv4.
+ return await attemptConnection(AddressFamily.InterNetwork, context, cancellationToken);
+ }
+
+ private static async ValueTask attemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken)
+ {
+ // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
+ var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp)
+ {
+ // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
+ NoDelay = true
+ };
+
+ try
+ {
+ await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
+ // The stream should take the ownership of the underlying socket,
+ // closing it when it's disposed.
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+#endif
+
+ #endregion
+
private class LengthTrackingStream : Stream
{
public readonly BindableLong BytesRead = new BindableLong();