From 34190f5a04d8554961eba7ea3e687bca200454e1 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sun, 18 Oct 2026 00:23:48 +0800 Subject: [PATCH] fix(link): bound the server connect and alternate address families on retry [CI] --- backend/fleet/src/Fleet.Link/FleetLink.cs | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index 07755c2..607f93f 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -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? _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 router, IEnumerable handlers, Outbox outbox, TimeProvider time, INodeLog log) { @@ -163,6 +169,61 @@ public sealed class FleetLink } } + /// + /// 连上面板:整个握手(TCP、TLS、升级)限时,卡住就放弃重试;每个地址的 TCP 连接也单独限时。 + /// 失败之后下一次换着先试另一个地址族(IPv6 / IPv4):有的网络上到某个地址族的 TCP 能连上、之后的 TLS 却被卡住,换一个往往就通。 + /// + 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 ConnectTcpAsync(SocketsHttpConnectionContext context, int attempt, CancellationToken ct) + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, ct); + // 第一次按 DNS 的顺序;之后奇数次先试 IPv6,偶数次先试 IPv4 + IEnumerable 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); + } + /// 当前这条链路是不是应用层加密的(服务器证明了身份)。 public bool IsSecure => _secure && IsConnected;