Skip to content

Commit 7abd4c8

Browse files
authored
Implement Happy-Eyeballs (#551)
1 parent 6acd44d commit 7abd4c8

File tree

2 files changed

+219
-9
lines changed

2 files changed

+219
-9
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Net.Sockets;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
namespace TMDbLib.Rest;
12+
13+
/// <summary>
14+
/// Implements Happy Eyeballs (RFC 8305) connection algorithm for HttpClient.
15+
/// This attempts IPv6 connections first, then falls back to IPv4 if IPv6 fails or is slow.
16+
/// Based on https://slugcat.systems/post/24-06-16-ipv6-is-hard-happy-eyeballs-dotnet-httpclient/.
17+
/// </summary>
18+
internal static class HappyEyeballsCallback
19+
{
20+
/// <summary>
21+
/// Delay between connection attempts as recommended by RFC 8305.
22+
/// </summary>
23+
private const int ConnectionAttemptDelayMs = 250;
24+
25+
/// <summary>
26+
/// Connect callback that implements Happy Eyeballs algorithm.
27+
/// </summary>
28+
/// <param name="context">The connection context containing DNS endpoint information.</param>
29+
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
30+
/// <returns>A network stream for the established connection.</returns>
31+
public static async ValueTask<Stream> ConnectAsync(
32+
SocketsHttpConnectionContext context,
33+
CancellationToken cancellationToken)
34+
{
35+
var endPoint = context.DnsEndPoint;
36+
37+
// Resolve DNS to get all IP addresses
38+
var resolvedAddresses = await GetAddressesAsync(endPoint.Host, cancellationToken).ConfigureAwait(false);
39+
40+
if (resolvedAddresses.Length == 0)
41+
{
42+
throw new SocketException((int)SocketError.HostNotFound);
43+
}
44+
45+
// Interleave IPv6 and IPv4 addresses (IPv6 first per RFC 8305)
46+
var sortedAddresses = SortInterleaved(resolvedAddresses);
47+
48+
// Attempt connections with Happy Eyeballs algorithm
49+
var socket = await ConnectWithHappyEyeballsAsync(
50+
sortedAddresses,
51+
endPoint.Port,
52+
TimeSpan.FromMilliseconds(ConnectionAttemptDelayMs),
53+
cancellationToken).ConfigureAwait(false);
54+
55+
return new NetworkStream(socket, ownsSocket: true);
56+
}
57+
58+
private static async Task<IPAddress[]> GetAddressesAsync(string host, CancellationToken cancellationToken)
59+
{
60+
// If host is already an IP address, return it directly
61+
if (IPAddress.TryParse(host, out var ip))
62+
{
63+
return [ip];
64+
}
65+
66+
var entry = await Dns.GetHostEntryAsync(host, cancellationToken).ConfigureAwait(false);
67+
return entry.AddressList;
68+
}
69+
70+
/// <summary>
71+
/// Sorts addresses by interleaving IPv6 and IPv4, with IPv6 first.
72+
/// This ensures IPv6 is attempted first but IPv4 follows quickly.
73+
/// </summary>
74+
private static IPAddress[] SortInterleaved(IPAddress[] addresses)
75+
{
76+
var ipv6 = addresses.Where(x => x.AddressFamily == AddressFamily.InterNetworkV6).ToArray();
77+
var ipv4 = addresses.Where(x => x.AddressFamily == AddressFamily.InterNetwork).ToArray();
78+
79+
var commonLength = Math.Min(ipv6.Length, ipv4.Length);
80+
var result = new IPAddress[addresses.Length];
81+
82+
// Interleave: IPv6, IPv4, IPv6, IPv4, ...
83+
for (var i = 0; i < commonLength; i++)
84+
{
85+
result[i * 2] = ipv6[i];
86+
result[(i * 2) + 1] = ipv4[i];
87+
}
88+
89+
// Append remaining addresses
90+
if (ipv4.Length > ipv6.Length)
91+
{
92+
ipv4.AsSpan(commonLength).CopyTo(result.AsSpan(commonLength * 2));
93+
}
94+
else if (ipv6.Length > ipv4.Length)
95+
{
96+
ipv6.AsSpan(commonLength).CopyTo(result.AsSpan(commonLength * 2));
97+
}
98+
99+
return result;
100+
}
101+
102+
/// <summary>
103+
/// Attempts connections to multiple addresses with staggered starts per RFC 8305.
104+
/// </summary>
105+
private static async Task<Socket> ConnectWithHappyEyeballsAsync(
106+
IPAddress[] addresses,
107+
int port,
108+
TimeSpan delay,
109+
CancellationToken cancellationToken)
110+
{
111+
using var successCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
112+
113+
var allTasks = new List<Task<Socket>>();
114+
var pendingTasks = new List<Task<Socket>>();
115+
Socket? successSocket = null;
116+
var successIndex = -1;
117+
118+
try
119+
{
120+
while (successSocket == null && (allTasks.Count < addresses.Length || pendingTasks.Count > 0))
121+
{
122+
// Start a new connection attempt if we haven't tried all addresses
123+
if (allTasks.Count < addresses.Length)
124+
{
125+
var newTask = AttemptConnectionAsync(addresses[allTasks.Count], port, successCts.Token);
126+
pendingTasks.Add(newTask);
127+
allTasks.Add(newTask);
128+
}
129+
130+
var whenAnyDone = Task.WhenAny(pendingTasks);
131+
132+
if (allTasks.Count < addresses.Length)
133+
{
134+
// Wait for either a connection to complete or the delay to expire
135+
var delayTask = Task.Delay(delay, successCts.Token);
136+
var completedFirst = await Task.WhenAny(whenAnyDone, delayTask).ConfigureAwait(false);
137+
138+
if (completedFirst == delayTask)
139+
{
140+
// Delay expired, start next connection attempt
141+
continue;
142+
}
143+
}
144+
145+
// A connection attempt completed
146+
var completedTask = await whenAnyDone.ConfigureAwait(false);
147+
148+
if (completedTask.IsCompletedSuccessfully)
149+
{
150+
successSocket = await completedTask.ConfigureAwait(false);
151+
successIndex = allTasks.IndexOf(completedTask);
152+
break;
153+
}
154+
155+
// Connection failed, remove from pending and try next
156+
pendingTasks.Remove(completedTask);
157+
}
158+
159+
cancellationToken.ThrowIfCancellationRequested();
160+
161+
if (successSocket == null)
162+
{
163+
// All connections failed - aggregate the exceptions
164+
var exceptions = allTasks
165+
.Where(x => x.IsFaulted)
166+
.SelectMany(x => x.Exception!.InnerExceptions)
167+
.ToList();
168+
169+
throw new AggregateException("All connection attempts failed.", exceptions);
170+
}
171+
172+
return successSocket;
173+
}
174+
finally
175+
{
176+
// Cancel remaining attempts
177+
await successCts.CancelAsync().ConfigureAwait(false);
178+
179+
// Dispose any successful sockets that weren't the winner
180+
for (var i = 0; i < allTasks.Count; i++)
181+
{
182+
var task = allTasks[i];
183+
if (task.IsCompletedSuccessfully && i != successIndex)
184+
{
185+
(await task.ConfigureAwait(false)).Dispose();
186+
}
187+
}
188+
}
189+
}
190+
191+
private static async Task<Socket> AttemptConnectionAsync(IPAddress address, int port, CancellationToken cancellationToken)
192+
{
193+
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
194+
{
195+
NoDelay = true
196+
};
197+
198+
try
199+
{
200+
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
201+
return socket;
202+
}
203+
catch
204+
{
205+
socket.Dispose();
206+
throw;
207+
}
208+
}
209+
}

TMDbLib/Rest/RestClient.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,22 @@ internal RestClient(Uri baseUrl, ITMDbSerializer serializer, IWebProxy? proxy, H
2525
MaxRetryCount = 0;
2626
Proxy = proxy;
2727

28-
if (httpMessageHandler is not null)
28+
if (httpMessageHandler is null)
2929
{
30-
HttpClient = new HttpClient(httpMessageHandler);
31-
}
32-
else
33-
{
34-
var handler = new HttpClientHandler();
30+
var handler = new SocketsHttpHandler
31+
{
32+
ConnectCallback = HappyEyeballsCallback.ConnectAsync
33+
};
34+
3535
if (proxy is not null)
3636
{
37-
// Blazor apparently throws on the Proxy setter.
38-
// https://github.com/jellyfin/TMDbLib/issues/354
3937
handler.Proxy = proxy;
4038
}
4139

42-
HttpClient = new HttpClient(handler);
40+
httpMessageHandler = handler;
4341
}
42+
43+
HttpClient = new HttpClient(httpMessageHandler);
4444
}
4545

4646
internal Uri BaseUrl { get; }
@@ -60,6 +60,7 @@ public int MaxRetryCount
6060
set
6161
{
6262
ArgumentOutOfRangeException.ThrowIfNegative(value);
63+
6364
_maxRetryCount = value;
6465
}
6566
}

0 commit comments

Comments
 (0)