From 106bf217d7758be1859a12fbf5136cd3fd1a22a5 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 13:36:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8D=87=E7=BA=A7=20StreamFrame=202.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + Directory.Packages.props | 2 +- docs/ARCHITECTURE.md | 7 +- docs/COMPATIBILITY.md | 5 +- docs/ROADMAP.md | 7 +- docs/STREAMFRAME-ADAPTER.md | 18 ++++- .../StreamFrameHsmsTransportTests.cs | 78 +++++++++++++++++++ 7 files changed, 109 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fd601..b49b2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### 修复 +- 升级官方 StreamFrame 2.5.0,采用自适应发送编码缓冲,并获得 Passive + 接受循环代次门控,避免显式关闭与自动重连竞速泄漏监听器。 +- 增加真实 TCP Passive 重连竞态测试,连续验证监听恢复、Session ID 单调 + 递增和原会话精确关闭。 - 升级官方 StreamFrame 2.4.0,采用 Connected 发布点的会话 epoch 收口, 并获得 Passive 接受重试出锁与监听端口立即重绑加固。 - 升级官方 StreamFrame 2.3.1,修复迟到旧会话故障污染活会话状态、旧会话 diff --git a/Directory.Packages.props b/Directory.Packages.props index 98c8004..e857de2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4632fa..afed159 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,7 +53,7 @@ HSMS 会话切换后,旧会话的控制请求、数据事务和等待中的应 IHsmsTransport 使用单一事件流按 Session ID 报告会话打开、 帧到达和会话关闭。StreamFrameHsmsTransport 已在内部实现: -- 直接采用 StreamFrame 2.4.0 的单调 TCP Session ID,并在 Connected +- 直接采用 StreamFrame 2.5.0 的单调 TCP Session ID,并在 Connected 发布点同步分配会话 epoch; - 通过会话感知接收流保留帧的原始 Session ID,迟到消费不会被标成新会话; - 会话绑定发送在 StreamFrame 原生 FIFO 中校验 Session ID,旧会话消息 @@ -67,8 +67,9 @@ HSMS 会话切换后,旧会话的控制请求、数据事务和等待中的应 这些能力仍隔离在 IHsmsTransport 后,不进入公共 API。此前 跟踪的 StreamFrame #38/#39 已在 2.3.0 完成迁移,2.3.1 进一步封闭迟到 旧故障污染活会话和排队消息跨 Socket 错发的竞态,2.4.0 又收口发布窗口 -旧 epoch 故障并加固 Passive 监听恢复;SecsFrame 只保留协议关闭原因和 -异常边界转换。详细失效模式与验证证据见 +旧 epoch 故障并加固 Passive 监听恢复,2.5.0 进一步以接受循环代次门控 +阻止重连竞速泄漏旧监听器,并加入不改变编码契约的自适应发送缓冲; +SecsFrame 只保留协议关闭原因和异常边界转换。详细失效模式与验证证据见 [STREAMFRAME-ADAPTER.md](STREAMFRAME-ADAPTER.md)。 ### 单线程会话状态机 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 217c364..8bbbe2e 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -22,8 +22,9 @@ StreamFrame #38(未完成帧超时)与 #39(会话感知发送确认及消息上下文) 已经随官方 StreamFrame 2.3.0 发布,2.3.1 随后补充迟到旧会话故障隔离、 排队发送 Session ID 复核和稳定失效异常修复。SecsFrame 当前固定官方 -2.4.0 NuGet 包,进一步收口 Connected 发布点的会话 epoch,加固 Passive -接受重试与监听端口重绑。内部 +2.5.0 NuGet 包;2.4.0 收口 Connected 发布点的会话 epoch,并加固 Passive +接受重试与监听端口重绑;2.5.0 再以接受循环代次门控阻止显式关闭/自动重连 +竞速泄漏旧监听器,并采用不改变编码契约的自适应发送缓冲。内部 StreamFrameHsmsTransport 使用原生会话 API;此前基于 原始字节回调、发送信封和自建 Session ID 的临时实现已经删除。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf80d4f..3ce874c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,7 +10,7 @@ - [x] 完整 SECS-II Item 类型、嵌套 List 和严格二进制编解码。 - [x] 动态 SecsMessage、可选单根 Item 与 HSMS Data Message Payload 编解码。 -- [x] 迁移到 StreamFrame 2.4.0 原生会话绑定、写出确认、会话 epoch/监听 +- [x] 迁移到 StreamFrame 2.5.0 原生会话绑定、写出确认、会话 epoch/监听 竞态修复和未完成帧超时。 - [x] Active/Passive 共用状态机的 Select/Select Response、Separate、 T6/T7、System Bytes 关联和会话失效处理。 @@ -27,8 +27,9 @@ - [x] Host、Equipment 双角色端点及两种 Active/Passive 拓扑的双向回环。 阶段 1 的工程能力已经形成完整基础链路,StreamFrame #38/#39 的正式包 -迁移与 2.3.1 会话隔离修复已经完成;当前固定 2.4.0,并继续验证发布窗口 -epoch 收口与 Passive 监听加固。后续切片继续扩展 GEM 通用行为。任何 +迁移与 2.3.1 会话隔离修复已经完成;2.4.0 又收口发布窗口 epoch 并加固 +Passive 监听。当前固定 2.5.0,并增加接受循环代次门控与自适应发送缓冲的 +本地回归证据。后续切片继续扩展 GEM 通用行为。任何 标准默认值 和完整合规结论都依赖团队合法获得的 E37/E37.1 及相关 GEM 标准副本与 一致性测试。 diff --git a/docs/STREAMFRAME-ADAPTER.md b/docs/STREAMFRAME-ADAPTER.md index cd98083..15efdbf 100644 --- a/docs/STREAMFRAME-ADAPTER.md +++ b/docs/STREAMFRAME-ADAPTER.md @@ -2,7 +2,7 @@ ## 上游基线 -SecsFrame 固定依赖官方 [StreamFrame 2.4.0](https://www.nuget.org/packages/StreamFrame/2.4.0)。 +SecsFrame 固定依赖官方 [StreamFrame 2.5.0](https://www.nuget.org/packages/StreamFrame/2.5.0)。 2.3.0 已经解决此前跟踪的两项能力缺口: - [#38](https://github.com/CSJ608/StreamFrame/issues/38) / [PR #41](https://github.com/CSJ608/StreamFrame/pull/41): @@ -25,6 +25,16 @@ ID 一样在 Connected 发布点分配,闭合状态已经对外可见但会话 提升为公共 API。详见 [2.4.0 发布说明](https://github.com/CSJ608/StreamFrame/releases/tag/v2.4.0)。 +2.5.0 继续保留上述公共接口和目标框架。发送 worker 会按连接记忆上一帧 +编码高水位并自适应选择初始缓冲区,减少尺寸相近的大报文在暖机期跨池扩容; +该缓冲策略不进入 SecsFrame 公共 API,也不改变帧或消息编码。Passive +接受循环新增代次门控,被显式 Reconnect 取代的旧循环不能再 +创建、关闭监听器或发布 Connected,修复显式关闭与对端断开触发的自动重连 +竞速可能泄漏无人消费监听 Socket 的问题。详见 +[2.5.0 发布说明](https://github.com/CSJ608/StreamFrame/releases/tag/v2.5.0)、 +[PR #51](https://github.com/CSJ608/StreamFrame/pull/51) 和 +[PR #52](https://github.com/CSJ608/StreamFrame/pull/52)。 + SecsFrame 不再维护基于原始字节回调的 T8 监视器、发送确认计数器、会话 信封 codec 或自建 TCP Session ID。公共 API 和内部 IHsmsTransport 契约保持不变,上层 HSMS 状态机不直接依赖 @@ -80,9 +90,11 @@ StreamFrame 只在已有未完成帧时运行该超时:空闲连接和完整 适配器单元测试覆盖原生 Session ID、迟到消息、发送完成、并发入队、会话 失效、显式关闭原因、关闭/释放竞态和 T8 诊断映射。真实 TCP 回环测试覆盖 分片帧收发、实际 T8 到期,以及会话替换时排队发送的精确失效与新 Socket -内容隔离;完整套件继续覆盖 T3/T6 从发送完成点启动以及旧会话不重放。 +内容隔离;Passive 重连竞态测试反复并发对端关闭与本地显式关闭,验证监听 +恢复、Session ID 单调递增和原会话精确关闭。完整套件继续覆盖 T3/T6 从 +发送完成点启动以及旧会话不重放。 -这些测试证明当前工程契约与 StreamFrame 2.4.0 的互操作行为,不构成 +这些测试证明当前工程契约与 StreamFrame 2.5.0 的互操作行为,不构成 SEMI 合规声明。T5/T8 的标准默认值、精确启停边界和异常恢复仍须依据团队 合法获得的 SEMI E37/E37.1 版本及一致性测试核对。仓库不得提交标准正文、 表格或 Schema。 diff --git a/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs b/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs index cc5acf2..5624802 100644 --- a/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs +++ b/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs @@ -458,6 +458,47 @@ await SendAndReadAsync( await events.DisposeAsync().ConfigureAwait(true); } + [Fact] + public async Task Real_passive_reconnect_race_keeps_listener_available() + { + var port = GetFreePort(); + await using var transport = StreamFrameHsmsTransport.Create( + IPAddress.Loopback, + port, + isActive: false, + new HsmsTransportOptions(T5, T8), + new StreamConnectionOptions + { + AcceptRetryDelayMs = 10, + ConnectRetryDelayMs = 10, + }); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var events = transport.GetEventsAsync(cancellation.Token).GetAsyncEnumerator(); + transport.Start(cancellation.Token); + var previousSessionId = new HsmsTransportSessionId(0); + + for (var attempt = 0; attempt < 8; attempt++) + { + using var client = await ConnectWithRetryAsync( + port, + cancellation.Token).ConfigureAwait(true); + var opened = await NextAsync(events).ConfigureAwait(true); + Assert.Equal(HsmsTransportEventKind.SessionOpened, opened.Kind); + Assert.True(opened.SessionId.Value > previousSessionId.Value); + + var explicitClose = Task.Run( + () => transport.TryCloseSession(opened.SessionId)); + client.Dispose(); + _ = await explicitClose.ConfigureAwait(true); + var closed = await NextAsync(events).ConfigureAwait(true); + Assert.Equal(HsmsTransportEventKind.SessionClosed, closed.Kind); + Assert.Equal(opened.SessionId, closed.SessionId); + previousSessionId = opened.SessionId; + } + + await events.DisposeAsync().ConfigureAwait(true); + } + private static StreamFrameHsmsTransport CreateTransport( FakeStreamConnection connection) => new(connection); @@ -506,6 +547,43 @@ private static int GetFreePort() return port; } + private static async Task ConnectWithRetryAsync( + int port, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var client = new System.Net.Sockets.TcpClient(); + try + { + var connect = client.ConnectAsync(IPAddress.Loopback, port); + var completed = await Task.WhenAny( + connect, + Task.Delay( + TimeSpan.FromSeconds(2), + cancellationToken)).ConfigureAwait(false); + if (!ReferenceEquals(connect, completed)) + { + client.Dispose(); + throw new TimeoutException( + "The passive listener accepted no connection."); + } + + await connect.ConfigureAwait(false); + return client; + } + catch (System.Net.Sockets.SocketException) + { + client.Dispose(); + await Task.Delay( + TimeSpan.FromMilliseconds(10), + cancellationToken).ConfigureAwait(false); + } + } + + throw new TimeoutException("The passive listener did not become available."); + } + private static async Task ReadExactlyAsync( Stream stream, int count,