fix(link): bound the server connect and alternate address families on retry [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-18 00:23:48 +08:00
1 parent 0d532caf08
commit 34190f5a04
1 file changed
+61
+61
View File
@@ -1,3 +1,6 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
@@ -93,6 +96,9 @@ public sealed class FleetLink
private readonly INodeLog _log;
private volatile RpcSession<LinkContext>? _session;
private volatile bool _stopped;
private static readonly TimeSpan s_connectTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan s_tcpTimeout = TimeSpan.FromSeconds(4);
private int _failedConnects;
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
{
@@ -163,6 +169,61 @@ public sealed class FleetLink
}
}
/// <summary>
/// 连上面板:整个握手(TCP、TLS、升级)限时,卡住就放弃重试;每个地址的 TCP 连接也单独限时。
/// 失败之后下一次换着先试另一个地址族(IPv6 / IPv4):有的网络上到某个地址族的 TCP 能连上、之后的 TLS 却被卡住,换一个往往就通。
/// </summary>
private async Task ConnectAsync(ClientWebSocket socket, Uri uri, CancellationToken ct)
{
int attempt = Volatile.Read(ref _failedConnects);
using var handler = new SocketsHttpHandler { ConnectCallback = (context, token) => ConnectTcpAsync(context, attempt, token) };
using var invoker = new HttpMessageInvoker(handler, disposeHandler: false);
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(s_connectTimeout);
try
{
await socket.ConnectAsync(uri, invoker, timeout.Token);
Volatile.Write(ref _failedConnects, 0);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Interlocked.Increment(ref _failedConnects);
throw new TimeoutException("connecting to the server timed out");
}
catch
{
Interlocked.Increment(ref _failedConnects);
throw;
}
}
private static async ValueTask<Stream> ConnectTcpAsync(SocketsHttpConnectionContext context, int attempt, CancellationToken ct)
{
IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, ct);
// 第一次按 DNS 的顺序;之后奇数次先试 IPv6,偶数次先试 IPv4
IEnumerable<IPAddress> ordered = attempt == 0 ? addresses
: addresses.OrderBy(a => (a.AddressFamily == AddressFamily.InterNetworkV6) == (attempt % 2 == 1) ? 0 : 1);
Exception? last = null;
foreach (IPAddress address in ordered)
{
var tcp = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
using CancellationTokenSource limit = CancellationTokenSource.CreateLinkedTokenSource(ct);
limit.CancelAfter(s_tcpTimeout);
try
{
await tcp.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), limit.Token);
return new NetworkStream(tcp, ownsSocket: true);
}
catch (Exception ex) when (ex is SocketException or OperationCanceledException && !ct.IsCancellationRequested)
{
tcp.Dispose();
last = ex;
}
}
throw last ?? new SocketException((int)SocketError.HostNotFound);
}
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;