Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 竞态回归。
Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ var conn = new StreamConnection<XDocument>(..., 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
Expand Down
9 changes: 7 additions & 2 deletions src/StreamFrame/Connection/StreamConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

/// <summary>立即进入重连流程。</summary>
/// <exception cref="InvalidOperationException">连接尚未 <see cref="Start"/>(无可重连的会话)。</exception>
public void Reconnect()
Expand Down
6 changes: 4 additions & 2 deletions src/StreamFrame/StreamConnectionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ public sealed class StreamConnectionOptions
/// </summary>
public bool TcpKeepAlive { get; set; }

/// <summary>TCP KeepAlive 首次探测前的静默时长(毫秒),仅 <see cref="TcpKeepAlive"/> 开启时生效。</summary>
/// <summary>TCP KeepAlive 首次探测前的静默时长(正毫秒,默认 30000),仅 <see cref="TcpKeepAlive"/> 开启时生效。
/// 现代 .NET 向上取整为秒(1ms → 1s,1500ms → 2s);netstandard2.0 的 IOControl 保留毫秒粒度。</summary>
public int KeepAliveTimeMs { get; set; } = 30_000;

/// <summary>TCP KeepAlive 探测间隔(毫秒),仅 <see cref="TcpKeepAlive"/> 开启时生效。</summary>
/// <summary>TCP KeepAlive 探测间隔(正毫秒,默认 5000),仅 <see cref="TcpKeepAlive"/> 开启时生效。
/// 现代 .NET 向上取整为秒(1ms → 1s,1500ms → 2s);netstandard2.0 的 IOControl 保留毫秒粒度。</summary>
public int KeepAliveIntervalMs { get; set; } = 5_000;

/// <summary>
Expand Down
77 changes: 77 additions & 0 deletions test/StreamFrame.Tests/KeepAliveTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>.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<Socket> assert)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
await using var connection = new StreamConnection<string>(
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<string>)
.GetField("_socket", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(connection)!;
assert(socket);
}
finally
{
listener.Stop();
}
}
}
Loading