diff --git a/backend/fleet/src/Fleet.Core/Bounded.cs b/backend/fleet/src/Fleet.Core/Bounded.cs
new file mode 100644
index 0000000..277e98a
--- /dev/null
+++ b/backend/fleet/src/Fleet.Core/Bounded.cs
@@ -0,0 +1,32 @@
+using System.Net.Sockets;
+using System.Net.WebSockets;
+
+namespace Fleet.Core;
+
+///
+/// 收尾类的等待(关闭、释放、等任务结束)一律要有上限:对端没了、原生层不回调、某个处理器卡住时,
+/// 没有上限的等待会让状态机永远停在「已连接」,既不重连也不报错。超时就放弃等待并交给调用方记日志;会话结束时的常见异常按正常噪声吞掉。
+///
+public static class Bounded
+{
+ /// 收尾的一步最多等这么久。
+ public static readonly TimeSpan Teardown = TimeSpan.FromSeconds(5);
+
+ /// 等 最多 ;超时返回 false(任务继续在后台收尾,不再等它),否则返回 true。
+ public static async Task WaitAsync(Task work, TimeSpan limit, TimeProvider time)
+ {
+ try
+ {
+ await work.WaitAsync(limit, time);
+ return true;
+ }
+ catch (TimeoutException)
+ {
+ return false;
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException or SocketException or WebSocketException)
+ {
+ return true; // 会话结束时的正常噪声:这一步已经结束了
+ }
+ }
+}
diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs
index e920a57..8b61b19 100644
--- a/backend/fleet/src/Fleet.Link/FleetLink.cs
+++ b/backend/fleet/src/Fleet.Link/FleetLink.cs
@@ -279,6 +279,7 @@ public sealed class FleetLink
Task cancellation = Task.Delay(Timeout.Infinite, ct);
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
string finishedReason = DescribeFinishedTask(finished, run, pump, heartbeat, session.TransportFailure);
+ _session = null; // 链路已经结束:上报方不能再往这条会话上写,收尾卡住时也一样
if (ct.IsCancellationRequested)
{
try
@@ -295,15 +296,15 @@ public sealed class FleetLink
}
await sessionCts.CancelAsync();
- await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
+ await Bounded.WaitAsync(Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)), Bounded.Teardown, _time);
return finishedReason;
}
finally
{
_session = null;
await sessionCts.CancelAsync();
- await Quiet(run);
- await session.DisposeAsync();
+ await Bounded.WaitAsync(Quiet(run), Bounded.Teardown, _time);
+ await Bounded.WaitAsync(session.DisposeAsync().AsTask(), Bounded.Teardown, _time);
}
}
diff --git a/backend/fleet/src/Fleet.Link/Transports.cs b/backend/fleet/src/Fleet.Link/Transports.cs
index 14d864f..3426fb0 100644
--- a/backend/fleet/src/Fleet.Link/Transports.cs
+++ b/backend/fleet/src/Fleet.Link/Transports.cs
@@ -118,6 +118,7 @@ public sealed class WsTransport : IRpcTransport
public sealed class TlsFrameTransport : IRpcTransport
{
private const int HeaderBytes = 5;
+ private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
private readonly Stream _stream;
private readonly int _maxFrameBytes;
@@ -139,16 +140,29 @@ public sealed class TlsFrameTransport : IRpcTransport
byte[] header = new byte[HeaderBytes];
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
header[4] = (byte)frame.Kind;
- await _sendLock.WaitAsync(ct);
+ // 对端黑洞时写会一直挂着并占着发送锁,心跳、时钟同步和重连都跟着卡住:超时就掐掉这条连接
+ using var timeout = new CancellationTokenSource(s_sendTimeout);
+ using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
+ bool lockTaken = false;
try
{
- await _stream.WriteAsync(header, ct);
- await _stream.WriteAsync(frame.Data, ct);
- await _stream.FlushAsync(ct);
+ await _sendLock.WaitAsync(linked.Token);
+ lockTaken = true;
+ await _stream.WriteAsync(header, linked.Token);
+ await _stream.WriteAsync(frame.Data, linked.Token);
+ await _stream.FlushAsync(linked.Token);
+ }
+ catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
+ {
+ await _stream.DisposeAsync();
+ throw new TimeoutException($"peer send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
}
finally
{
- _sendLock.Release();
+ if (lockTaken)
+ {
+ _sendLock.Release();
+ }
}
}
diff --git a/backend/fleet/src/Fleet.Rpc/RpcSession.cs b/backend/fleet/src/Fleet.Rpc/RpcSession.cs
index 918807a..c9c0e78 100644
--- a/backend/fleet/src/Fleet.Rpc/RpcSession.cs
+++ b/backend/fleet/src/Fleet.Rpc/RpcSession.cs
@@ -50,6 +50,7 @@ public sealed class RpcSession : IAsyncDisposable
private readonly IRpcTransport _transport;
private readonly RpcRouter _router;
private readonly TContext _context;
+ private static readonly TimeSpan s_dispatcherGrace = TimeSpan.FromSeconds(5);
private readonly TimeProvider _time;
private readonly RpcSessionOptions _options;
private readonly ConcurrentDictionary> _pending = new();
@@ -135,14 +136,8 @@ public sealed class RpcSession : IAsyncDisposable
}
await linked.CancelAsync();
- try
- {
- await dispatcher;
- }
- catch (OperationCanceledException)
- {
- // 正常结束
- }
+ // 某个入站处理器卡住了也不能拖住整个会话的结束(链路据此重连):它随取消令牌自己收尾
+ await Bounded.WaitAsync(dispatcher, s_dispatcherGrace, _time);
}
}
diff --git a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs
index 4239a9e..949566a 100644
--- a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs
+++ b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs
@@ -16,6 +16,9 @@ public sealed class FleetServerOptions
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
+ /// 处理节点的一条消息(心跳、hello、收件箱)最多用这么久:钩子卡在数据库或外部查询里时,不能让这条连接停在「在线」而收不到任何心跳超时。超过就断开,节点按退避重连。
+ public TimeSpan HandlerTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
/// 节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。
public int MaxViolations { get; set; } = 20;
}
diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs
index 9be6688..333d9a8 100644
--- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs
+++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs
@@ -34,12 +34,20 @@ internal sealed partial class NodeConnection
{
if (TryParse(frame.Data, out RpcMessage? message))
{
- await HandleMessageAsync(message, ct);
+ if (!await Bounded.WaitAsync(HandleMessageAsync(message, ct), _host.Options.HandlerTimeout, _host.Time))
+ {
+ _closeReason ??= "handler timeout";
+ return;
+ }
}
}
else
{
- await HandleBinaryAsync(frame.Data, ct);
+ if (!await Bounded.WaitAsync(HandleBinaryAsync(frame.Data, ct), _host.Options.HandlerTimeout, _host.Time))
+ {
+ _closeReason ??= "handler timeout";
+ return;
+ }
}
}
}
diff --git a/backend/fleet/tests/Fleet.Tests/BoundedTests.cs b/backend/fleet/tests/Fleet.Tests/BoundedTests.cs
new file mode 100644
index 0000000..ced8933
--- /dev/null
+++ b/backend/fleet/tests/Fleet.Tests/BoundedTests.cs
@@ -0,0 +1,28 @@
+using Fleet.Core;
+
+namespace Fleet.Tests;
+
+public sealed class BoundedTests
+{
+ [Fact]
+ public async Task WaitAsync_ReturnsFalse_WhenTheWorkNeverFinishes()
+ {
+ var never = new TaskCompletionSource();
+ Assert.False(await Bounded.WaitAsync(never.Task, TimeSpan.FromMilliseconds(100), TimeProvider.System));
+ }
+
+ [Fact]
+ public async Task WaitAsync_ReturnsTrue_WhenTheWorkFinishes_OrFailsWithSessionNoise()
+ {
+ Assert.True(await Bounded.WaitAsync(Task.CompletedTask, TimeSpan.FromSeconds(5), TimeProvider.System));
+ Assert.True(await Bounded.WaitAsync(Task.FromException(new IOException("closed")), TimeSpan.FromSeconds(5), TimeProvider.System));
+ Assert.True(await Bounded.WaitAsync(Task.FromException(new ObjectDisposedException("x")), TimeSpan.FromSeconds(5), TimeProvider.System));
+ Assert.True(await Bounded.WaitAsync(Task.FromCanceled(new CancellationToken(true)), TimeSpan.FromSeconds(5), TimeProvider.System));
+ }
+
+ [Fact]
+ public async Task WaitAsync_DoesNotHideRealFailures()
+ {
+ await Assert.ThrowsAsync(() => Bounded.WaitAsync(Task.FromException(new FormatException()), TimeSpan.FromSeconds(5), TimeProvider.System));
+ }
+}
diff --git a/backend/fleet/tests/Fleet.Tests/LinkTests.cs b/backend/fleet/tests/Fleet.Tests/LinkTests.cs
index 284ea3b..e8d45f9 100644
--- a/backend/fleet/tests/Fleet.Tests/LinkTests.cs
+++ b/backend/fleet/tests/Fleet.Tests/LinkTests.cs
@@ -275,6 +275,27 @@ public sealed class LinkTests
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
+ [Fact]
+ public async Task Session_RunEnds_EvenWhenAnInboundHandlerIsStuck()
+ {
+ (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
+ MethodTable table = TestTables.Of(TestTables.Req("right.stuck"));
+ var stuck = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); // 不理会取消令牌:模拟卡在原生层的处理器
+ var rightRouter = new RpcRouter(RpcSide.Listener, table).On("right.stuck", LinkJson.Default.Number, LinkJson.Default.Number, (_, _, _) => new ValueTask>(stuck.Task));
+ await using var leftSession = new RpcSession(left, new RpcRouter(RpcSide.Dialer, table), "left", TimeProvider.System);
+ await using var rightSession = new RpcSession(right, rightRouter, "right", TimeProvider.System);
+ Task leftRun = leftSession.RunAsync(default);
+ Task rightRun = rightSession.RunAsync(default);
+
+ _ = leftSession.RequestAsync("right.stuck", new Number { Value = 1 }, LinkJson.Default.Number, LinkJson.Default.Number, default, TimeSpan.FromSeconds(30)).AsTask();
+ await Task.Delay(300); // 请求到达并进入处理器
+ await leftSession.CloseAsync("bye", default);
+
+ await rightRun.WaitAsync(TimeSpan.FromSeconds(10)); // 以前这里会一直等到处理器返回
+ stuck.TrySetResult(Result.Ok(new Number { Value = 0 }));
+ await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
// ───────────────────────── 点对点握手 ─────────────────────────
private sealed class PeerFixture : IAsyncDisposable
diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs
index a7c4b28..1ae62e1 100644
--- a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs
+++ b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs
@@ -27,9 +27,6 @@ public sealed class AgentConnection : IDisposable
/// Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。
private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120);
- /// 收尾的每一步最多等这么久:QUIC 连接或控制连接的关闭卡在原生层时,不能让整个重拨循环跟着卡住。
- private static readonly TimeSpan s_teardownLimit = TimeSpan.FromSeconds(5);
-
/// 一直在发测量报文、却这么久没有收到 Agent 的任何一个:数据面已经不通,重建会话。要比一次线路中断长(中断恢复后接着测),又不能拖到几个小时。
private static readonly TimeSpan s_dataIdleLimit = TimeSpan.FromSeconds(90);
private static readonly RpcRouter s_router = BuildRouter();
@@ -347,18 +344,10 @@ public sealed class AgentConnection : IDisposable
private async Task BoundedAsync(string step, Func work)
{
- try
- {
- await work().WaitAsync(s_teardownLimit, _env.Time);
- }
- catch (TimeoutException)
+ if (!await Bounded.WaitAsync(work(), Bounded.Teardown, _env.Time))
{
_env.Log.Warn("agent session teardown step timed out, moving on", ("agent", Endpoint.AgentId), ("step", step));
}
- catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
- {
- // 会话结束时的正常噪声
- }
}
private static async Task Quiet(Task task)
diff --git a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
index 9e03202..e4df037 100644
--- a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
+++ b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs
@@ -271,7 +271,15 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
if (Volatile.Read(ref _handle) != 0)
{
- await _shutdown.Task.ConfigureAwait(false);
+ // 原生层偶尔不回报关闭完成:等一会儿就放手,调用方(重拨循环、有序退出)不能跟着卡住
+ try
+ {
+ await _shutdown.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ _log.Warn("QUIC connection did not report shutdown completion in time; moving on");
+ }
}
}