diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb6b98..0632dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### 修复 +- **TCP KeepAlive 单位([#62](https://github.com/CSJ608/StreamFrame/issues/62))**:现代 .NET 将正毫秒值安全向上取整为秒,默认 30000/5000ms 正确设置为 30/5s;保留 netstandard2.0 IOControl 毫秒语义及默认关闭。增加真实 Socket 配置读回与边界回归,双语文档明确粒度差异。 - **终态连接等待([#65](https://github.com/CSJ608/StreamFrame/issues/65))**:取得连接等待器后复查停机状态,关闭首次注册与 Shutdown 的竞态;Dispose 或生命周期取消后的新等待也以任务取消结束,无需调用方令牌。补充终态调用、1000 次首次注册竞速及调用方取消隔离回归。 - **坏头后完整帧交付([#64](https://github.com/CSJ608/StreamFrame/issues/64))**:定界器返回 false 但已消费无效前缀时继续解析现有缓冲,无进展才等待输入;保留非法长度头四字节丢弃策略,避免完整帧挂起及误触发半帧超时。补充推进契约及连续坏头、粘包、半帧、EOF、自定义定界器和错误元数据回归。 - **监听绑定失败恢复([#63](https://github.com/CSJ608/StreamFrame/issues/63))**:监听 Socket 完成配置、Bind/Listen 后才发布,失败释放并保持字段为空,端口释放后可自动重试接入;发布与停机释放互斥,保留接受循环代次及单客户端监听关闭语义。新增同步信号门控的端口占用恢复、双向消息及取消/Dispose 竞态回归。 diff --git a/README.en.md b/README.en.md index 1eddacb..db031dd 100644 --- a/README.en.md +++ b/README.en.md @@ -230,6 +230,8 @@ Without one, no logging happens (zero-dependency usage). ### Liveness & heartbeat pattern +KeepAlive is disabled by default. When enabled, `KeepAliveTimeMs` / `KeepAliveIntervalMs` must be positive milliseconds (defaults: 30000 / 5000). Modern .NET Socket options use whole seconds, rounded up (1ms → 1s, 1500ms → 2s, defaults → 30s / 5s); the `netstandard2.0` `SIO_KEEPALIVE_VALS` path retains millisecond granularity. + For production, enable `TcpKeepAlive = true`; an application-level heartbeat combined with `ReceiveIdleTimeoutMs` (3× the heartbeat period, tolerating 1-2 lost beats) is an even stronger combo. The framework does not ship a heartbeat (the message shape belongs to your protocol) — the pattern: ```csharp diff --git a/README.md b/README.md index dd4588c..0ee1a36 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,8 @@ var conn = new StreamConnection(..., logger: loggerFactory.CreateLogg ### 活性探测与心跳范式 +KeepAlive 默认关闭。开启后,`KeepAliveTimeMs` / `KeepAliveIntervalMs` 必须为正毫秒值,默认 30000 / 5000。现代 .NET 的 Socket 选项使用整秒,向上取整(1ms → 1s,1500ms → 2s,默认 → 30s / 5s);`netstandard2.0` 的 `SIO_KEEPALIVE_VALS` 分支保留毫秒粒度。 + 生产环境建议开启 `TcpKeepAlive = true`;应用层心跳配合 `ReceiveIdleTimeoutMs`(取心跳周期的 3 倍,容忍偶尔丢 1-2 次)是更强的组合——框架不内置心跳(消息形态由协议决定),范式如下,完整可运行示例见 demo 场景 4: ```csharp diff --git a/src/StreamFrame/Connection/StreamConnection.cs b/src/StreamFrame/Connection/StreamConnection.cs index 16141ee..53aeede 100644 --- a/src/StreamFrame/Connection/StreamConnection.cs +++ b/src/StreamFrame/Connection/StreamConnection.cs @@ -438,11 +438,16 @@ private void ConfigureSocket(Socket socket) _logger.LogWarning(ex, "SIO_KEEPALIVE_VALS 设置失败(非 Windows 平台?),回退系统默认 KeepAlive 参数。"); } #else - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, _options.KeepAliveTimeMs); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, _options.KeepAliveIntervalMs); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, KeepAliveSeconds(_options.KeepAliveTimeMs)); + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, KeepAliveSeconds(_options.KeepAliveIntervalMs)); #endif } +#if !NETSTANDARD2_0 + // 选项已校验为正数;先减后除避免 int.MaxValue 向上取整时溢出。 + internal static int KeepAliveSeconds(int milliseconds) => (milliseconds - 1) / 1000 + 1; +#endif + /// 立即进入重连流程。 /// 连接尚未 (无可重连的会话)。 public void Reconnect() diff --git a/src/StreamFrame/StreamConnectionOptions.cs b/src/StreamFrame/StreamConnectionOptions.cs index 2baf03e..7084eb8 100644 --- a/src/StreamFrame/StreamConnectionOptions.cs +++ b/src/StreamFrame/StreamConnectionOptions.cs @@ -63,10 +63,12 @@ public sealed class StreamConnectionOptions /// public bool TcpKeepAlive { get; set; } - /// TCP KeepAlive 首次探测前的静默时长(毫秒),仅 开启时生效。 + /// TCP KeepAlive 首次探测前的静默时长(正毫秒,默认 30000),仅 开启时生效。 + /// 现代 .NET 向上取整为秒(1ms → 1s,1500ms → 2s);netstandard2.0 的 IOControl 保留毫秒粒度。 public int KeepAliveTimeMs { get; set; } = 30_000; - /// TCP KeepAlive 探测间隔(毫秒),仅 开启时生效。 + /// TCP KeepAlive 探测间隔(正毫秒,默认 5000),仅 开启时生效。 + /// 现代 .NET 向上取整为秒(1ms → 1s,1500ms → 2s);netstandard2.0 的 IOControl 保留毫秒粒度。 public int KeepAliveIntervalMs { get; set; } = 5_000; /// diff --git a/test/StreamFrame.Tests/KeepAliveTests.cs b/test/StreamFrame.Tests/KeepAliveTests.cs new file mode 100644 index 0000000..17d1230 --- /dev/null +++ b/test/StreamFrame.Tests/KeepAliveTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Sockets; +using System.Reflection; + +namespace StreamFrame.Tests; + +public class KeepAliveTests +{ +#if NET8_0_OR_GREATER + [Theory] + [InlineData(1, 1)] + [InlineData(999, 1)] + [InlineData(1000, 1)] + [InlineData(1500, 2)] + [InlineData(int.MaxValue, 2147484)] + public void Conversion_RoundsUpWithoutOverflow(int milliseconds, int seconds) + => Assert.Equal(seconds, StreamConnection.KeepAliveSeconds(milliseconds)); +#endif + + [Theory] + [InlineData(null, null, 30, 5)] +#if NET8_0_OR_GREATER + [InlineData(1500, 2500, 2, 3)] + [InlineData(1, 999, 1, 1)] + [InlineData(1000, 2000, 1, 2)] +#endif + public async Task Enabled_ReadsExpectedSecondsFromConnectedSocket( + int? timeMs, int? intervalMs, int expectedTime, int expectedInterval) + { + var options = new StreamConnectionOptions { TcpKeepAlive = true }; + if (timeMs.HasValue) options.KeepAliveTimeMs = timeMs.Value; + if (intervalMs.HasValue) options.KeepAliveIntervalMs = intervalMs.Value; + await WithConnectedSocket(options, socket => + { + Assert.Equal(1, (int)socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive)!); +#if NET8_0_OR_GREATER + var timeOption = SocketOptionName.TcpKeepAliveTime; + var intervalOption = SocketOptionName.TcpKeepAliveInterval; +#else + // net48 没有枚举名称;Windows TCP_KEEPIDLE/TCP_KEEPINTVL 原生选项读回秒。 + var timeOption = (SocketOptionName)3; + var intervalOption = (SocketOptionName)17; +#endif + Assert.Equal(expectedTime, (int)socket.GetSocketOption(SocketOptionLevel.Tcp, timeOption)!); + Assert.Equal(expectedInterval, (int)socket.GetSocketOption(SocketOptionLevel.Tcp, intervalOption)!); + }); + } + + [Fact] + public async Task Defaults_KeepAliveRemainsDisabled() + => await WithConnectedSocket(new StreamConnectionOptions(), socket => + Assert.Equal(0, (int)socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive)!)); + + private static async Task WithConnectedSocket(StreamConnectionOptions options, Action assert) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + await using var connection = new StreamConnection( + new LengthPrefixFramer(), StringCodec.Instance, IPAddress.Loopback, + ((IPEndPoint)listener.LocalEndpoint).Port, true, options); + connection.Start(CancellationToken.None); + var accepting = listener.AcceptSocketAsync(); + await accepting.WaitAsync(TimeSpan.FromSeconds(5)); + using var peer = await accepting; + await connection.WaitForConnectedAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var socket = (Socket)typeof(StreamConnection) + .GetField("_socket", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(connection)!; + assert(socket); + } + finally + { + listener.Stop(); + } + } +}