From eca02b83f924d9bd5014dc5f5b405bdc355455eb Mon Sep 17 00:00:00 2001 From: Marci Date: Fri, 28 Aug 2026 17:23:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=85=AC=E5=85=B1=20?= =?UTF-8?q?HSMS=20=E8=BF=9E=E6=8E=A5=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 + README.md | 5 +- docs/ARCHITECTURE.md | 12 + docs/COMPATIBILITY.md | 10 + docs/HSMS-CONNECTION.md | 104 +++++ docs/HSMS-DATA-TRANSACTIONS.md | 14 +- docs/HSMS-SESSION-STATE-MACHINE.md | 4 +- docs/ROADMAP.md | 17 +- docs/STANDARDS.md | 6 +- .../Hsms/Connection/HsmsConnection.cs | 393 ++++++++++++++++++ .../Hsms/Connection/HsmsConnectionEvent.cs | 73 ++++ .../Connection/HsmsConnectionEventKind.cs | 17 + .../Hsms/Connection/HsmsConnectionOptions.cs | 137 ++++++ .../Hsms/Session/HsmsConnectionMode.cs | 10 +- .../Hsms/Session/HsmsSessionState.cs | 10 +- .../HsmsDataTransactionManager.cs | 7 + .../Transactions/HsmsIncomingDataMessage.cs | 19 +- .../HsmsConnectionOptionsTests.cs | 140 +++++++ test/SecsFrame.Tests/HsmsConnectionTests.cs | 282 +++++++++++++ 19 files changed, 1245 insertions(+), 20 deletions(-) create mode 100644 docs/HSMS-CONNECTION.md create mode 100644 src/SecsFrame/Hsms/Connection/HsmsConnection.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsConnectionEventKind.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsConnectionOptions.cs create mode 100644 test/SecsFrame.Tests/HsmsConnectionOptionsTests.cs create mode 100644 test/SecsFrame.Tests/HsmsConnectionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b91c5f..e396ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,5 +42,10 @@ StreamFrame 重试间隔并关闭指数退避,Passive 监听重试保持独立。 - 增加按接收进展与会话代次隔离的 T8 监视、专用超时异常,以及部分长度 头、部分 Payload、陈旧回调、替换会话和真实 TCP 超时测试。 +- 增加公共 HsmsConnection 与不可变 + HsmsConnectionOptions,组合动态消息收发、Selected 等待、 + 单消费者事件流、入站一次性回复和控制命令。 +- 增加公共 API 选项边界、生命周期、等待/事务取消和 Active/Passive + 双端真实 TCP 往返测试;入站回复令牌绑定原连接与原运输会话。 [Unreleased]: https://github.com/CSJ608/SecsFrame/compare/main...HEAD diff --git a/README.md b/README.md index 5d26cc5..c5dbfc4 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,16 @@ 共用的内部 HSMS-SS 状态机已经覆盖 TCP 会话、Select、Linktest、Reject、 Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor 进一步提供实际写出后启动的 T3、复合事务键关联、入站回复和会话失效 -隔离。Item 用法与边界见 +隔离。公共 HsmsConnection 将这些内部层组合成显式计时 +配置、动态消息收发、状态等待和单消费者事件流。Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 [docs/STREAMFRAME-ADAPTER.md](docs/STREAMFRAME-ADAPTER.md),状态机边界见 [docs/HSMS-SESSION-STATE-MACHINE.md](docs/HSMS-SESSION-STATE-MACHINE.md), 数据事务边界见 [docs/HSMS-DATA-TRANSACTIONS.md](docs/HSMS-DATA-TRANSACTIONS.md), +公共连接用法见 +[docs/HSMS-CONNECTION.md](docs/HSMS-CONNECTION.md), 整体路线图见 [docs/ROADMAP.md](docs/ROADMAP.md)。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31a65d0..93b541d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,6 +120,18 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后 和测试边界见 [HSMS-DATA-TRANSACTIONS.md](HSMS-DATA-TRANSACTIONS.md)。 +### 公共连接外观 + +HsmsConnection 是运输、会话和数据事务 actor 之上的薄公共 +组合层。它要求显式网络、协议 Session ID 和 T3/T5/T6/T7/T8 配置,公开 +动态消息发送、一次性入站回复、控制命令、Selected 等待和单消费者事件 +流。公共层不暴露 transport Session generation、StreamFrame +StreamConnectionOptions 或 #38/#39 的回调适配。 + +连接内部独占消费事务事件,并分别更新状态等待信号与公共事件 channel, +因此 readiness 等待不会与业务消息消费者竞争。详细生命周期和取消语义 +见 [HSMS-CONNECTION.md](HSMS-CONNECTION.md)。 + ### 严格与兼容分离 默认按标准拒绝畸形帧、非法 Item 和无效状态转换。对现场设备的已知偏差通过命名明确的兼容选项启用,并记录来源、风险和测试向量。 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 6c5c09b..6c7fc49 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -32,3 +32,13 @@ StreamFrame #38(未完成帧超时)和 #39(会话感知发送确认与消 具体机制、测试证据和已知代价见 [STREAMFRAME-ADAPTER.md](STREAMFRAME-ADAPTER.md)。 + +## 跨实现测试依赖 + +计划中的 secs4net 互操作夹具只能使用 nuget.org 发布的官方 NuGet 包, +并在 Directory.Packages.props 中固定明确版本。不得使用 +本地源码 checkout、ProjectReference、本地 feed 或自行修改的程序集作为 +互操作对端。源码仓库只读参考不进入 SecsFrame 的还原、构建或测试图。 + +夹具和文档必须记录官方包 ID、版本、运行框架、测试方向与通过/失败结果, +避免把本地差异误判为上游互操作能力。 diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md new file mode 100644 index 0000000..bfe8192 --- /dev/null +++ b/docs/HSMS-CONNECTION.md @@ -0,0 +1,104 @@ +# 公共 HSMS 连接 API + +## 范围 + +HsmsConnection 是当前基础层的最小公共入口。它在内部组合 +StreamFrame 运输适配、HSMS-SS 会话 actor 和数据事务 actor,调用方不需要 +接触 transport Session generation、发送确认回调或内部 channel。 + +当前公共能力包括: + +- Active 或 Passive TCP 连接以及 Select 会话建立; +- 等待 Selected 状态和读取当前状态; +- 发送任意动态 SecsMessage,无需消息目录或预生成类型; +- W-Bit Primary 的 T3 关联与 Secondary 返回; +- 接收入站 Primary 或未匹配消息,并使用不可伪造令牌回复一次; +- Linktest、Deselect 和 Separate 控制命令; +- 状态、数据、未消费控制消息和解码失败的单消费者异步事件流。 + +Host/Equipment 是后续业务能力角色,不由 Active/Passive 推断。 + +## 显式配置 + +HsmsConnectionOptions 构造时必须给出: + +| 配置 | 当前用途 | +|---|---| +| IP 地址、端口 | Active 的远端地址或 Passive 的本地监听地址 | +| ConnectionMode | 只表示哪一端建立 TCP 连接 | +| SessionId | 出站 Data Message 使用的协议 Session ID | +| T3 | W-Bit Primary 写完后等待 Secondary | +| T5 | Active TCP 连接失败后的固定重试间隔 | +| T6 | Select、Linktest、Deselect 控制响应等待 | +| T7 | TCP 建立后等待进入 Selected | +| T8 | 未完成消息接收进展等待 | + +仓库没有内置或暗示标准默认值。T5 必须能无损转换成 StreamFrame 使用的 +整毫秒范围;Passive 监听重试不被解释成 T5。每个取值应依据团队合法获得 +的 E37/E37.1 版本、设备通信手册和互操作证据确定。 + +## 生命周期 + +~~~csharp +var options = new HsmsConnectionOptions( + IPAddress.Parse("192.0.2.10"), + port: 5000, + HsmsConnectionMode.Active, + sessionId: 0, + t3: TimeSpan.FromSeconds(45), + t5: TimeSpan.FromSeconds(10), + t6: TimeSpan.FromSeconds(5), + t7: TimeSpan.FromSeconds(10), + t8: TimeSpan.FromSeconds(5)); + +await using var connection = new HsmsConnection(options); +connection.Start(); +await connection.WaitUntilSelectedAsync(cancellationToken); + +var secondary = await connection.SendAsync( + new SecsMessage( + stream: 1, + function: 1, + replyExpected: true), + cancellationToken); +~~~ + +以上计时值只演示 API 形状,不是标准推荐值。 + +Start 只能调用一次,连接活动期由 +DisposeAsync 结束。WaitUntilSelectedAsync +不消费业务事件;取消它只取消本次等待,不停止连接。 + +GetEventsAsync 同一时刻只允许一个消费者;枚举器结束或释放 +后可以重新获取。连接内部先独占消费事务 actor 的事件,再写入公共事件 +流,因此 Selected 等待不会和业务接收争抢消息。调用方通常应使用一个 +长期事件循环处理: + +- StateChanged:状态转换和可选错误; +- DataMessageReceived:携带 + HsmsIncomingDataMessage; +- ControlMessageReceived:未被当前控制或数据事务认领的帧; +- DataMessageDecodeFailed:原始帧和严格解码错误。 + +入站消息只有 ReplyExpected 为真时可以回复。回复自动复制原 +协议 Session ID 与 System Bytes,并绑定原 transport session;开始回复 +后令牌不可再次使用,也不会在替换连接上重放。 + +## 取消与失败 + +- 出站无 W-Bit 消息在整帧写完后完成; +- 出站 W-Bit Primary 在整帧写完后启动 T3; +- 取消发送会结束本地等待,不会自动关闭 Selected 会话,也不能撤回已经 + 上线的字节;迟到 Secondary 作为未匹配入站消息上报; +- Deselect、Separate、断线、替换会话或释放会结束绑定旧会话的等待; +- 事件枚举的取消只结束该消费者;连接仍须显式释放。 + +当前具体异常类型仍以 TimeoutException、 +IOException、OperationCanceledException 和参数/ +状态异常为稳定分类;更细的公共诊断模型将在互操作证据后再收敛。 + +## 验证边界 + +真实 TCP 测试使用两个公共连接完成 Select、Linktest、嵌套 Item +Primary/Secondary、重复回复拒绝和取消后保持 Selected。它证明当前实现 +各层能够组合工作,不替代 SEMI 一致性认证或第三方设备互操作。 diff --git a/docs/HSMS-DATA-TRANSACTIONS.md b/docs/HSMS-DATA-TRANSACTIONS.md index 8676a25..4b7a21e 100644 --- a/docs/HSMS-DATA-TRANSACTIONS.md +++ b/docs/HSMS-DATA-TRANSACTIONS.md @@ -86,8 +86,12 @@ Active/Passive 双端真实 TCP 回环另行覆盖 Select 后的 Primary/Seconda ## 标准核对边界 -实现追踪 SEMI E5-0725、E37-0222 与 E37.1-0819。发布公共事务 API 前, -仍须使用团队合法获得的版本核对 Primary/Secondary 定义、W-Bit 与 -Function 约束、T3 的精确启停、SxF0/S9Fx 对事务的影响、System Bytes -分配规则及 Reject 后处理。仓库不得提交标准正文、状态表或由付费材料 -机械转换的测试资料,也不据当前工程测试声明完整合规。 +实现追踪 SEMI E5-0725、E37-0222 与 E37.1-0819。扩大公共事务 API 或 +声明合规前,仍须使用团队合法获得的版本核对 Primary/Secondary 定义、 +W-Bit 与 Function 约束、T3 的精确启停、SxF0/S9Fx 对事务的影响、 +System Bytes 分配规则及 Reject 后处理。仓库不得提交标准正文、状态表 +或由付费材料机械转换的测试资料,也不据当前工程测试声明完整合规。 + +HsmsDataTransactionManager 仍是内部 actor;公共调用方通过 +HsmsConnection 使用其动态发送、入站回复和事件能力。公共 +生命周期与取消语义见 [HSMS-CONNECTION.md](HSMS-CONNECTION.md)。 diff --git a/docs/HSMS-SESSION-STATE-MACHINE.md b/docs/HSMS-SESSION-STATE-MACHINE.md index faa16e0..c7a0984 100644 --- a/docs/HSMS-SESSION-STATE-MACHINE.md +++ b/docs/HSMS-SESSION-STATE-MACHINE.md @@ -77,13 +77,15 @@ System Bytes。旧会话的帧、发送完成和计时器回调不会推进替 StreamFrameHsmsTransport 完成 Select、Linktest 和 Deselect 流程;其上的事务回环还完成带嵌套 Item 的 Primary/Secondary 往返。数据 事务细节见 [HSMS-DATA-TRANSACTIONS.md](HSMS-DATA-TRANSACTIONS.md)。 +公共 HsmsConnection 另行验证同一路径的外部组合契约,见 +[HSMS-CONNECTION.md](HSMS-CONNECTION.md)。 测试证明当前实现路径可互操作,不替代认证工具或第三方设备互操作证据。 ## 尚未实现 - 授权标准核对后的 T5/T8 默认值和完整标准语义; - 自动周期 Linktest 调度; -- 公共会话 API、Host/Equipment 能力 API; +- Host/Equipment 能力 API; - 与 secs4net 或真实设备的跨实现互操作矩阵。 实现这些行为前,需要使用团队合法获得的 SEMI E37-0222 与 E37.1-0819 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ebcd755..3c6b2c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,14 +16,17 @@ - [x] Linktest、Reject、Deselect 控制消息与对应 T6 分支。 - [x] Selected 数据发送、Primary/Secondary 关联、T3 与入站同事务回复。 - [x] 显式 T5 固定重连节流、T8 接收进展监视和跨会话旧回调隔离。 +- [x] 最小公共连接外观、显式 T3/T5/T6/T7/T8 配置、动态消息事件与 + 确定的取消/释放语义。 - [ ] 使用授权 E37/E37.1 核对 T5/T8 默认值与精确启停边界。 -- Host、Equipment 双角色回环集成测试,以及与 secs4net 的互操作测试。 - -阶段 1 的下一最小切片是稳定最小公共连接外观:公开动态消息收发、会话 -状态、显式 T3/T5/T6/T7/T8 配置和确定的取消/关闭语义,同时继续把 -StreamFrame #38/#39 细节隔离在内部。随后建立与 secs4net 的跨实现 -Select、Linktest 和 Primary/Secondary 互操作夹具。任何标准默认值和 -完整合规结论都依赖团队合法获得的 E37/E37.1 副本与一致性测试。 +- Host、Equipment 双角色回环集成测试,以及与 nuget.org 官方 secs4net + 包的互操作测试。 + +阶段 1 的下一最小切片是跨实现互操作夹具:通过中央包管理固定 +nuget.org 官方 secs4net 包版本,覆盖 Select、Linktest 和 +Primary/Secondary。不得引用、构建或链接本地 secs4net 源码 checkout。 +随后根据互操作结果收敛公共诊断模型。任何标准默认值和完整合规结论都 +依赖团队合法获得的 E37/E37.1 副本与一致性测试。 ## 阶段 2:GEM 通用能力 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 22c4562..eac65ce 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -56,5 +56,7 @@ - 未经验证的“完整合规”“通过认证”等声明。 本项目只读参考 StreamFrame 和 MIT 许可的 secs4net 以交叉理解公开协议 -实现,没有复制参考仓库代码。未来如引入实质性开源代码,必须保留对应 -版权与许可证文本,并在变更说明中记录来源。 +实现,没有复制参考仓库代码。该 secs4net 源码 checkout 不参与互操作 +构建;互操作夹具只允许使用 nuget.org 发布并固定版本的官方 NuGet 包。 +未来如引入实质性开源代码,必须保留对应版权与许可证文本,并在变更说明 +中记录来源。 diff --git a/src/SecsFrame/Hsms/Connection/HsmsConnection.cs b/src/SecsFrame/Hsms/Connection/HsmsConnection.cs new file mode 100644 index 0000000..ac4509d --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsConnection.cs @@ -0,0 +1,393 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; + +namespace SecsFrame; + +/// +/// Provides dynamic SECS-II messaging over one HSMS-SS connection. +/// +/// +/// This type owns its transport, session state machine, and data transaction +/// manager. Call once and dispose the connection to stop it. +/// +public sealed class HsmsConnection : IAsyncDisposable +{ + private readonly HsmsDataTransactionManager _transactions; + private readonly Channel _events; +#if NET9_0_OR_GREATER + private readonly Lock _stateGate = new(); +#else + private readonly object _stateGate = new(); +#endif + private TaskCompletionSource _selectedSignal = + CreateSelectionSignal(); + private Task? _eventPump; + private int _state = (int)HsmsSessionState.Disconnected; + private int _eventReaderClaimed; + private int _started; + private int _disposed; + + /// Creates an HSMS connection without starting network activity. + /// The explicit connection and timer settings. + public HsmsConnection(HsmsConnectionOptions options) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + _transactions = CreateTransactions(options); + _events = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + }); + } + + /// Gets the immutable connection settings. + public HsmsConnectionOptions Options { get; } + + /// Gets the latest observed HSMS session state. + public HsmsSessionState State + => (HsmsSessionState)Volatile.Read(ref _state); + + /// Starts network, session, transaction, and event processing. + public void Start() + { + ThrowIfDisposed(); + if (Interlocked.Exchange(ref _started, 1) != 0) + throw new InvalidOperationException("The HSMS connection has already been started."); + + try + { + _transactions.Start(CancellationToken.None); + _eventPump = PumpEventsAsync(); + } + catch (Exception ex) + { + FailSelectionWaiters(ex); + _events.Writer.TryComplete(ex); + throw; + } + } + + /// + /// Gets the single-consumer stream of state, incoming-message, control, and + /// decoding events. + /// + /// Cancels only this event-stream read. + /// + /// Only one event stream can be active at a time. A new stream can be + /// acquired after the previous enumerator ends or is disposed. Use + /// for readiness checks without + /// consuming state events. + /// + public IAsyncEnumerable GetEventsAsync( + CancellationToken cancellationToken = default) + { + ThrowIfNotRunning(); + return ReadEventsAsync(cancellationToken); + } + + /// Waits until this connection next reaches Selected state. + /// Cancels only this wait operation. + public Task WaitUntilSelectedAsync( + CancellationToken cancellationToken = default) + { + ThrowIfNotRunning(); + cancellationToken.ThrowIfCancellationRequested(); + + Task selected; + lock (_stateGate) + { + if ((HsmsSessionState)_state == HsmsSessionState.Selected) + return Task.CompletedTask; + + selected = _selectedSignal.Task; + } + + return AwaitSelectionAsync(selected, cancellationToken); + } + + /// + /// Sends a dynamic Primary message with the configured protocol Session ID. + /// + /// The message to send. + /// Cancels this send or open transaction. + /// + /// The matching Secondary when the W-Bit is set; otherwise + /// after the entire frame is written. + /// + public Task SendAsync( + SecsMessage primary, + CancellationToken cancellationToken = default) + { + if (primary is null) + throw new ArgumentNullException(nameof(primary)); + + ThrowIfNotRunning(); + return _transactions.SendAsync( + Options.SessionId, + primary, + cancellationToken); + } + + /// Replies once to an incoming Primary on its original session. + /// The incoming message token from the event stream. + /// The Secondary message, with W-Bit clear. + /// Cancels this reply operation. + public Task ReplyAsync( + HsmsIncomingDataMessage incoming, + SecsMessage secondary, + CancellationToken cancellationToken = default) + { + if (incoming is null) + throw new ArgumentNullException(nameof(incoming)); + if (secondary is null) + throw new ArgumentNullException(nameof(secondary)); + + ThrowIfNotRunning(); + return _transactions.ReplyAsync( + incoming, + secondary, + cancellationToken); + } + + /// Sends Linktest Request and waits for its matching response. + /// Cancels this control command. + public Task LinktestAsync(CancellationToken cancellationToken = default) + { + ThrowIfNotRunning(); + return _transactions.LinktestAsync(cancellationToken); + } + + /// Sends Deselect Request and waits for its matching response. + /// Cancels this control command. + public Task DeselectAsync(CancellationToken cancellationToken = default) + { + ThrowIfNotRunning(); + return _transactions.DeselectAsync(cancellationToken); + } + + /// Sends Separate Request and closes the selected TCP session. + /// Cancels this control command. + public Task SeparateAsync(CancellationToken cancellationToken = default) + { + ThrowIfNotRunning(); + return _transactions.SeparateAsync(cancellationToken); + } + + /// Stops the connection and completes pending operations and events. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + FailSelectionWaiters(new ObjectDisposedException(nameof(HsmsConnection))); + await _transactions.DisposeAsync().ConfigureAwait(false); + if (_eventPump is not null) + await _eventPump.ConfigureAwait(false); + else + _events.Writer.TryComplete(); + } + + private static HsmsDataTransactionManager CreateTransactions( + HsmsConnectionOptions options) + { + var isActive = options.ConnectionMode == HsmsConnectionMode.Active; + var transport = StreamFrameHsmsTransport.Create( + options.IpAddress, + options.Port, + isActive, + new HsmsTransportOptions(options.T5, options.T8)); + var session = new HsmsSessionStateMachine( + transport, + new HsmsSessionOptions( + options.ConnectionMode, + options.T6, + options.T7)); + return new HsmsDataTransactionManager( + session, + new HsmsDataTransactionOptions(options.T3)); + } + + private async Task PumpEventsAsync() + { + Exception? completionError = null; + var events = _transactions.GetEventsAsync(CancellationToken.None) + .GetAsyncEnumerator(); + try + { + while (await events.MoveNextAsync().ConfigureAwait(false)) + { + var transactionEvent = events.Current; + if (transactionEvent.Kind == + HsmsDataTransactionEventKind.StateChanged) + { + ApplyState(transactionEvent.State); + } + + if (!_events.Writer.TryWrite(MapEvent(transactionEvent))) + break; + } + + if (Volatile.Read(ref _disposed) == 0) + { + completionError = new IOException( + "The internal HSMS event stream ended before the connection was disposed."); + FailSelectionWaiters(completionError); + } + } + catch (Exception ex) + { + completionError = ex; + FailSelectionWaiters(ex); + } + finally + { + await events.DisposeAsync().ConfigureAwait(false); + _events.Writer.TryComplete(completionError); + } + } + + private void ApplyState(HsmsSessionState state) + { + lock (_stateGate) + { + Volatile.Write(ref _state, (int)state); + if (Volatile.Read(ref _disposed) != 0) + return; + + if (state == HsmsSessionState.Selected) + { + _selectedSignal.TrySetResult(null); + } + else if (_selectedSignal.Task.IsCompleted) + { + _selectedSignal = CreateSelectionSignal(); + } + } + } + + private void FailSelectionWaiters(Exception error) + { + lock (_stateGate) + { + Volatile.Write( + ref _state, + (int)HsmsSessionState.Disconnected); + if (_selectedSignal.Task.IsCompleted) + _selectedSignal = CreateSelectionSignal(); + + _selectedSignal.TrySetResult(error); + } + } + + private async IAsyncEnumerable ReadEventsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _eventReaderClaimed, 1) != 0) + { + throw new InvalidOperationException( + "The HSMS connection event stream already has a consumer."); + } + + try + { + var reader = _events.Reader; + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (reader.TryRead(out var connectionEvent)) + yield return connectionEvent; + } + } + finally + { + Interlocked.Exchange(ref _eventReaderClaimed, 0); + } + } + + private static HsmsConnectionEvent MapEvent( + HsmsDataTransactionEvent transactionEvent) + => transactionEvent.Kind switch + { + HsmsDataTransactionEventKind.StateChanged => + HsmsConnectionEvent.StateChanged( + transactionEvent.State, + transactionEvent.Error), + HsmsDataTransactionEventKind.DataMessageReceived => + HsmsConnectionEvent.DataMessageReceived( + transactionEvent.DataMessage!), + HsmsDataTransactionEventKind.ControlMessageReceived => + HsmsConnectionEvent.ControlMessageReceived( + transactionEvent.State, + transactionEvent.Frame!), + HsmsDataTransactionEventKind.DataMessageDecodeFailed => + HsmsConnectionEvent.DataMessageDecodeFailed( + transactionEvent.Frame!, + transactionEvent.Error!), + _ => throw new ArgumentOutOfRangeException( + nameof(transactionEvent), + transactionEvent.Kind, + "Unknown HSMS data transaction event."), + }; + + private static async Task AwaitSelectionAsync( + Task operation, + CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + var error = await operation.ConfigureAwait(false); + if (error is not null) + throw error; + + return; + } + + var cancellation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static state => + { + var pair = (CancellationState)state!; + pair.Signal.TrySetCanceled(pair.Token); + }, + new CancellationState(cancellation, cancellationToken)); + var completed = await Task.WhenAny(operation, cancellation.Task) + .ConfigureAwait(false); + await completed.ConfigureAwait(false); + var selectionError = await operation.ConfigureAwait(false); + if (selectionError is not null) + throw selectionError; + } + + private void ThrowIfNotRunning() + { + ThrowIfDisposed(); + if (Volatile.Read(ref _started) == 0) + throw new InvalidOperationException("The HSMS connection has not been started."); + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(HsmsConnection)); + } + + private static TaskCompletionSource CreateSelectionSignal() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class CancellationState + { + public CancellationState( + TaskCompletionSource signal, + CancellationToken token) + { + Signal = signal; + Token = token; + } + + public TaskCompletionSource Signal { get; } + + public CancellationToken Token { get; } + } +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs b/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs new file mode 100644 index 0000000..9a3d9ec --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs @@ -0,0 +1,73 @@ +namespace SecsFrame; + +/// An immutable event emitted by . +public sealed class HsmsConnectionEvent +{ + private HsmsConnectionEvent( + HsmsConnectionEventKind kind, + HsmsSessionState state, + HsmsIncomingDataMessage? incomingMessage, + HsmsFrame? frame, + Exception? error) + { + Kind = kind; + State = state; + IncomingMessage = incomingMessage; + Frame = frame; + Error = error; + } + + /// Gets the event kind. + public HsmsConnectionEventKind Kind { get; } + + /// Gets the session state when the event was produced. + public HsmsSessionState State { get; } + + /// Gets the incoming data message, when applicable. + public HsmsIncomingDataMessage? IncomingMessage { get; } + + /// Gets the control or undecodable data frame, when applicable. + public HsmsFrame? Frame { get; } + + /// Gets the state or decoding error, when applicable. + public Exception? Error { get; } + + internal static HsmsConnectionEvent StateChanged( + HsmsSessionState state, + Exception? error) + => new( + HsmsConnectionEventKind.StateChanged, + state, + null, + null, + error); + + internal static HsmsConnectionEvent DataMessageReceived( + HsmsIncomingDataMessage incomingMessage) + => new( + HsmsConnectionEventKind.DataMessageReceived, + HsmsSessionState.Selected, + incomingMessage, + null, + null); + + internal static HsmsConnectionEvent ControlMessageReceived( + HsmsSessionState state, + HsmsFrame frame) + => new( + HsmsConnectionEventKind.ControlMessageReceived, + state, + null, + frame, + null); + + internal static HsmsConnectionEvent DataMessageDecodeFailed( + HsmsFrame frame, + Exception error) + => new( + HsmsConnectionEventKind.DataMessageDecodeFailed, + HsmsSessionState.Selected, + null, + frame, + error); +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsConnectionEventKind.cs b/src/SecsFrame/Hsms/Connection/HsmsConnectionEventKind.cs new file mode 100644 index 0000000..9184d4e --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsConnectionEventKind.cs @@ -0,0 +1,17 @@ +namespace SecsFrame; + +/// Identifies an event emitted by . +public enum HsmsConnectionEventKind +{ + /// The HSMS session state changed. + StateChanged, + + /// An unmatched or primary data message was received. + DataMessageReceived, + + /// An unconsumed HSMS control message was received. + ControlMessageReceived, + + /// An HSMS data message could not be decoded. + DataMessageDecodeFailed, +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsConnectionOptions.cs b/src/SecsFrame/Hsms/Connection/HsmsConnectionOptions.cs new file mode 100644 index 0000000..497851b --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsConnectionOptions.cs @@ -0,0 +1,137 @@ +using System.Net; + +namespace SecsFrame; + +/// +/// Immutable network, protocol-session, and timer settings for an +/// . +/// +public sealed class HsmsConnectionOptions +{ + /// Creates explicit HSMS connection settings. + /// The remote address in Active mode or local bind address in Passive mode. + /// The TCP port, from 1 through 65535. + /// Which peer initiates the TCP connection. + /// The protocol Session ID used by outgoing data messages. + /// The data transaction reply timeout. + /// The Active connection retry interval. + /// The control transaction reply timeout. + /// The selection timeout after TCP connection. + /// The incomplete-message receive timeout. + public HsmsConnectionOptions( + IPAddress ipAddress, + int port, + HsmsConnectionMode connectionMode, + ushort sessionId, + TimeSpan t3, + TimeSpan t5, + TimeSpan t6, + TimeSpan t7, + TimeSpan t8) + { + IpAddress = ipAddress ?? throw new ArgumentNullException(nameof(ipAddress)); + if (port is < 1 or > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(port), + port, + "The TCP port must be between 1 and 65535."); + } + + if (connectionMode != HsmsConnectionMode.Active && + connectionMode != HsmsConnectionMode.Passive) + { + throw new ArgumentOutOfRangeException( + nameof(connectionMode), + connectionMode, + "The HSMS connection mode must be Active or Passive."); + } + + if (sessionId == ushort.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(sessionId), + sessionId, + "The protocol Session ID cannot use the control-message value 65535."); + } + + ValidatePositive(t3, nameof(t3), "T3"); + ValidateT5(t5, nameof(t5)); + ValidatePositive(t6, nameof(t6), "T6"); + ValidatePositive(t7, nameof(t7), "T7"); + ValidatePositive(t8, nameof(t8), "T8"); + + Port = port; + ConnectionMode = connectionMode; + SessionId = sessionId; + T3 = t3; + T5 = t5; + T6 = t6; + T7 = t7; + T8 = t8; + } + + /// Gets the remote or local bind IP address. + public IPAddress IpAddress { get; } + + /// Gets the TCP port. + public int Port { get; } + + /// Gets which peer initiates the TCP connection. + public HsmsConnectionMode ConnectionMode { get; } + + /// Gets the protocol Session ID for outgoing data messages. + public ushort SessionId { get; } + + /// Gets the data transaction reply timeout. + public TimeSpan T3 { get; } + + /// Gets the Active connection retry interval. + public TimeSpan T5 { get; } + + /// Gets the control transaction reply timeout. + public TimeSpan T6 { get; } + + /// Gets the selection timeout after TCP connection. + public TimeSpan T7 { get; } + + /// Gets the incomplete-message receive timeout. + public TimeSpan T8 { get; } + + private static void ValidatePositive( + TimeSpan value, + string parameterName, + string timerName) + { + if (value <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"{timerName} must be positive."); + } + } + + private static void ValidateT5( + TimeSpan value, + string parameterName) + { + ValidatePositive(value, parameterName, "T5"); + if (value.Ticks % TimeSpan.TicksPerMillisecond != 0) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + "T5 must be representable as a whole number of milliseconds."); + } + + var milliseconds = value.Ticks / TimeSpan.TicksPerMillisecond; + if (milliseconds > int.MaxValue) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + "T5 exceeds the supported connection retry-delay range."); + } + } +} diff --git a/src/SecsFrame/Hsms/Session/HsmsConnectionMode.cs b/src/SecsFrame/Hsms/Session/HsmsConnectionMode.cs index de07644..ae6d9ba 100644 --- a/src/SecsFrame/Hsms/Session/HsmsConnectionMode.cs +++ b/src/SecsFrame/Hsms/Session/HsmsConnectionMode.cs @@ -1,7 +1,15 @@ namespace SecsFrame; -internal enum HsmsConnectionMode +/// Specifies which peer initiates the TCP connection. +/// +/// The connection mode is independent of the Host or Equipment application +/// role. +/// +public enum HsmsConnectionMode { + /// The local peer initiates the TCP connection. Active, + + /// The local peer listens for the TCP connection. Passive, } diff --git a/src/SecsFrame/Hsms/Session/HsmsSessionState.cs b/src/SecsFrame/Hsms/Session/HsmsSessionState.cs index 69e7ffe..a4a2650 100644 --- a/src/SecsFrame/Hsms/Session/HsmsSessionState.cs +++ b/src/SecsFrame/Hsms/Session/HsmsSessionState.cs @@ -1,9 +1,17 @@ namespace SecsFrame; -internal enum HsmsSessionState +/// Describes the current HSMS transport and selection state. +public enum HsmsSessionState { + /// No TCP session is currently open. Disconnected, + + /// A TCP session is open but is not selected. Connected, + + /// An Active connection is waiting for Select Response. Selecting, + + /// The HSMS session is selected and can exchange data messages. Selected, } diff --git a/src/SecsFrame/Hsms/Transactions/HsmsDataTransactionManager.cs b/src/SecsFrame/Hsms/Transactions/HsmsDataTransactionManager.cs index b222c4f..10a3412 100644 --- a/src/SecsFrame/Hsms/Transactions/HsmsDataTransactionManager.cs +++ b/src/SecsFrame/Hsms/Transactions/HsmsDataTransactionManager.cs @@ -117,6 +117,12 @@ public Task ReplyAsync( throw new ArgumentNullException(nameof(incoming)); if (secondary is null) throw new ArgumentNullException(nameof(secondary)); + if (!incoming.IsOwnedBy(this)) + { + throw new InvalidOperationException( + "The incoming data message belongs to another HSMS connection."); + } + if (!incoming.DataMessage.Message.ReplyExpected) { throw new InvalidOperationException( @@ -350,6 +356,7 @@ ex is SecsProtocolException || } var incoming = new HsmsIncomingDataMessage( + this, sessionEvent.SessionId, dataMessage); _events.Writer.TryWrite( diff --git a/src/SecsFrame/Hsms/Transactions/HsmsIncomingDataMessage.cs b/src/SecsFrame/Hsms/Transactions/HsmsIncomingDataMessage.cs index 5aae46d..908d2de 100644 --- a/src/SecsFrame/Hsms/Transactions/HsmsIncomingDataMessage.cs +++ b/src/SecsFrame/Hsms/Transactions/HsmsIncomingDataMessage.cs @@ -1,13 +1,19 @@ namespace SecsFrame; -internal sealed class HsmsIncomingDataMessage +/// +/// An incoming HSMS data message bound to the transport session on which it +/// was received. +/// +public sealed class HsmsIncomingDataMessage { private int _replyStarted; - public HsmsIncomingDataMessage( + internal HsmsIncomingDataMessage( + object replyOwner, HsmsTransportSessionId transportSessionId, HsmsDataMessage dataMessage) { + ReplyOwner = replyOwner ?? throw new ArgumentNullException(nameof(replyOwner)); if (!transportSessionId.IsValid) { throw new ArgumentOutOfRangeException( @@ -20,10 +26,19 @@ public HsmsIncomingDataMessage( DataMessage = dataMessage ?? throw new ArgumentNullException(nameof(dataMessage)); } + /// Gets the decoded HSMS data message. public HsmsDataMessage DataMessage { get; } + /// Gets whether the message requests a secondary reply. + public bool ReplyExpected => DataMessage.Message.ReplyExpected; + + private object ReplyOwner { get; } + internal HsmsTransportSessionId TransportSessionId { get; } + internal bool IsOwnedBy(object owner) + => ReferenceEquals(ReplyOwner, owner); + internal bool TryBeginReply() => Interlocked.CompareExchange(ref _replyStarted, 1, 0) == 0; } diff --git a/test/SecsFrame.Tests/HsmsConnectionOptionsTests.cs b/test/SecsFrame.Tests/HsmsConnectionOptionsTests.cs new file mode 100644 index 0000000..5504926 --- /dev/null +++ b/test/SecsFrame.Tests/HsmsConnectionOptionsTests.cs @@ -0,0 +1,140 @@ +using System.Net; + +namespace SecsFrame.Tests; + +public sealed class HsmsConnectionOptionsTests +{ + [Fact] + public void Explicit_values_are_preserved() + { + var address = IPAddress.Loopback; + var options = new HsmsConnectionOptions( + address, + 5000, + HsmsConnectionMode.Active, + 10, + TimeSpan.FromSeconds(45), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(5)); + + Assert.Same(address, options.IpAddress); + Assert.Equal(5000, options.Port); + Assert.Equal(HsmsConnectionMode.Active, options.ConnectionMode); + Assert.Equal(10, options.SessionId); + Assert.Equal(TimeSpan.FromSeconds(45), options.T3); + Assert.Equal(TimeSpan.FromSeconds(10), options.T5); + Assert.Equal(TimeSpan.FromSeconds(5), options.T6); + Assert.Equal(TimeSpan.FromSeconds(10), options.T7); + Assert.Equal(TimeSpan.FromSeconds(5), options.T8); + } + + [Fact] + public void Null_address_is_rejected() + { + Assert.Throws( + () => new HsmsConnectionOptions( + null!, + 5000, + HsmsConnectionMode.Active, + 10, + TimeSpan.FromSeconds(45), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(5))); + } + + [Theory] + [InlineData(0)] + [InlineData(65536)] + public void Invalid_port_is_rejected(int port) + { + Assert.Throws( + () => Create(port: port)); + } + + [Fact] + public void Undefined_connection_mode_is_rejected() + { + Assert.Throws( + () => Create(connectionMode: (HsmsConnectionMode)99)); + } + + [Fact] + public void Control_message_session_identifier_is_rejected() + { + Assert.Throws( + () => Create(sessionId: ushort.MaxValue)); + } + + [Theory] + [InlineData("T3")] + [InlineData("T5")] + [InlineData("T6")] + [InlineData("T7")] + [InlineData("T8")] + public void Nonpositive_timer_is_rejected(string timerName) + { + Assert.Throws( + () => CreateWithTimer(timerName, TimeSpan.Zero)); + Assert.Throws( + () => CreateWithTimer(timerName, TimeSpan.FromTicks(-1))); + } + + [Fact] + public void Submillisecond_T5_is_rejected_instead_of_rounded() + { + Assert.Throws( + () => Create( + t5: TimeSpan.FromTicks( + TimeSpan.TicksPerMillisecond + 1))); + } + + [Fact] + public void T5_beyond_connection_retry_range_is_rejected() + { + Assert.Throws( + () => Create( + t5: TimeSpan.FromMilliseconds( + (long)int.MaxValue + 1))); + } + + private static HsmsConnectionOptions Create( + IPAddress? ipAddress = null, + int port = 5000, + HsmsConnectionMode connectionMode = HsmsConnectionMode.Active, + ushort sessionId = 10, + TimeSpan? t3 = null, + TimeSpan? t5 = null, + TimeSpan? t6 = null, + TimeSpan? t7 = null, + TimeSpan? t8 = null) + => new( + ipAddress ?? IPAddress.Loopback, + port, + connectionMode, + sessionId, + t3 ?? TimeSpan.FromSeconds(45), + t5 ?? TimeSpan.FromSeconds(10), + t6 ?? TimeSpan.FromSeconds(5), + t7 ?? TimeSpan.FromSeconds(10), + t8 ?? TimeSpan.FromSeconds(5)); + + private static HsmsConnectionOptions CreateWithTimer( + string timerName, + TimeSpan value) + => timerName switch + { + "T3" => Create(t3: value), + "T5" => Create(t5: value), + "T6" => Create(t6: value), + "T7" => Create(t7: value), + "T8" => Create(t8: value), + _ => throw new ArgumentOutOfRangeException( + nameof(timerName), + timerName, + "Unknown HSMS timer."), + }; +} diff --git a/test/SecsFrame.Tests/HsmsConnectionTests.cs b/test/SecsFrame.Tests/HsmsConnectionTests.cs new file mode 100644 index 0000000..655392c --- /dev/null +++ b/test/SecsFrame.Tests/HsmsConnectionTests.cs @@ -0,0 +1,282 @@ +using System.Net; + +namespace SecsFrame.Tests; + +public sealed class HsmsConnectionTests +{ + [Fact] + public async Task Public_connections_exchange_dynamic_messages_over_real_tcp() + { + var port = GetFreePort(); + await using var passive = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Passive)); + await using var active = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Active)); + using var cancellation = new CancellationTokenSource( + TimeSpan.FromSeconds(15)); + + passive.Start(); + active.Start(); + await using var passiveEvents = passive + .GetEventsAsync(cancellation.Token) + .GetAsyncEnumerator(); + await Task.WhenAll( + passive.WaitUntilSelectedAsync(cancellation.Token), + active.WaitUntilSelectedAsync(cancellation.Token)) + .ConfigureAwait(true); + + await AssertSelectedEventAsync( + passiveEvents, + passive, + active).ConfigureAwait(true); + await active.LinktestAsync(cancellation.Token).ConfigureAwait(true); + await AssertRoundTripAsync( + passive, + active, + passiveEvents, + cancellation.Token).ConfigureAwait(true); + } + + [Fact] + public async Task Canceling_open_transaction_does_not_close_public_connection() + { + var port = GetFreePort(); + await using var passive = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Passive)); + await using var active = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Active)); + using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var sendCancellation = new CancellationTokenSource(); + + passive.Start(); + active.Start(); + await using var passiveEvents = passive + .GetEventsAsync(lifetime.Token) + .GetAsyncEnumerator(); + await Task.WhenAll( + passive.WaitUntilSelectedAsync(lifetime.Token), + active.WaitUntilSelectedAsync(lifetime.Token)) + .ConfigureAwait(true); + var send = active.SendAsync( + new SecsMessage(1, 1, true), + sendCancellation.Token); + _ = await NextMatchingAsync( + passiveEvents, + item => item.Kind == + HsmsConnectionEventKind.DataMessageReceived) + .ConfigureAwait(true); + sendCancellation.Cancel(); + await Assert.ThrowsAnyAsync( + () => send).ConfigureAwait(true); + Assert.Equal(HsmsSessionState.Selected, passive.State); + Assert.Equal(HsmsSessionState.Selected, active.State); + } + + [Fact] + public async Task Readiness_wait_can_be_canceled_without_stopping_connection() + { + var port = GetFreePort(); + await using var connection = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Active)); + using var cancellation = new CancellationTokenSource( + TimeSpan.FromMilliseconds(100)); + connection.Start(); + + await Assert.ThrowsAnyAsync( + () => connection.WaitUntilSelectedAsync(cancellation.Token)) + .ConfigureAwait(true); + + Assert.Equal(HsmsSessionState.Disconnected, connection.State); + } + + [Fact] + public async Task Disposing_connection_ends_uncanceled_readiness_wait() + { + var port = GetFreePort(); + var connection = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Active)); + connection.Start(); + var selected = connection.WaitUntilSelectedAsync(); + + await connection.DisposeAsync().ConfigureAwait(true); + + await Assert.ThrowsAsync( + () => selected).ConfigureAwait(true); + } + + [Fact] + public async Task Canceled_event_read_allows_replacement_consumer() + { + var connection = new HsmsConnection( + CreateOptions(GetFreePort(), HsmsConnectionMode.Active)); + await using var owned = connection; + using var cancellation = new CancellationTokenSource( + TimeSpan.FromMilliseconds(100)); + connection.Start(); + await using var first = connection + .GetEventsAsync(cancellation.Token) + .GetAsyncEnumerator(); + + await Assert.ThrowsAnyAsync( + () => first.MoveNextAsync().AsTask()).ConfigureAwait(true); + + var replacement = connection.GetEventsAsync(); + await using var second = replacement + .GetAsyncEnumerator() + .ConfigureAwait(true); + } + + [Fact] + public async Task Concurrent_event_readers_are_rejected() + { + var connection = new HsmsConnection( + CreateOptions(GetFreePort(), HsmsConnectionMode.Active)); + await using var owned = connection; + using var cancellation = new CancellationTokenSource(); + connection.Start(); + await using var first = connection + .GetEventsAsync(cancellation.Token) + .GetAsyncEnumerator(); + var firstRead = first.MoveNextAsync().AsTask(); + await using var second = connection + .GetEventsAsync() + .GetAsyncEnumerator(); + + await Assert.ThrowsAsync( + () => second.MoveNextAsync().AsTask()).ConfigureAwait(true); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + () => firstRead).ConfigureAwait(true); + } + + [Fact] + public async Task Connection_rejects_commands_before_start_and_duplicate_start() + { + var connection = new HsmsConnection( + CreateOptions(GetFreePort(), HsmsConnectionMode.Active)); + await using var owned = connection; + + Assert.Throws( + () => connection.GetEventsAsync()); + Assert.Throws( + () => + { + _ = connection.WaitUntilSelectedAsync(); + }); + Assert.Throws( + () => + { + _ = connection.SendAsync(new SecsMessage(1, 1)); + }); + + connection.Start(); + Assert.Throws(() => connection.Start()); + } + + private static HsmsConnectionOptions CreateOptions( + int port, + HsmsConnectionMode connectionMode) + => new( + IPAddress.Loopback, + port, + connectionMode, + sessionId: 10, + t3: TimeSpan.FromSeconds(5), + t5: TimeSpan.FromMilliseconds(10), + t6: TimeSpan.FromSeconds(5), + t7: TimeSpan.FromSeconds(10), + t8: TimeSpan.FromSeconds(5)); + + private static async Task AssertSelectedEventAsync( + IAsyncEnumerator passiveEvents, + HsmsConnection passive, + HsmsConnection active) + { + var selected = await NextMatchingAsync( + passiveEvents, + item => item.Kind == HsmsConnectionEventKind.StateChanged && + item.State == HsmsSessionState.Selected).ConfigureAwait(true); + + Assert.Null(selected.IncomingMessage); + Assert.Null(selected.Frame); + Assert.Null(selected.Error); + Assert.Equal(HsmsSessionState.Selected, passive.State); + Assert.Equal(HsmsSessionState.Selected, active.State); + } + + private static async Task AssertRoundTripAsync( + HsmsConnection passive, + HsmsConnection active, + IAsyncEnumerator passiveEvents, + CancellationToken cancellationToken) + { + var send = active.SendAsync( + new SecsMessage( + 6, + 11, + true, + SecsItem.List( + SecsItem.Ascii("LOT-42"), + SecsItem.List(SecsItem.U1(1), SecsItem.U1(2)))), + cancellationToken); + var received = await NextMatchingAsync( + passiveEvents, + item => item.Kind == + HsmsConnectionEventKind.DataMessageReceived).ConfigureAwait(true); + var incoming = received.IncomingMessage!; + + Assert.True(incoming.ReplyExpected); + Assert.Equal(10, incoming.DataMessage.SessionId); + Assert.Equal(6, incoming.DataMessage.Message.Stream); + Assert.Equal(11, incoming.DataMessage.Message.Function); + await Assert.ThrowsAsync( + () => active.ReplyAsync( + incoming, + new SecsMessage(6, 12), + cancellationToken)).ConfigureAwait(true); + await passive.ReplyAsync( + incoming, + new SecsMessage( + 6, + 12, + rootItem: SecsItem.Boolean(true)), + cancellationToken).ConfigureAwait(true); + var secondary = await send.ConfigureAwait(true); + + Assert.NotNull(secondary); + Assert.Equal(10, secondary.SessionId); + Assert.Equal(6, secondary.Message.Stream); + Assert.Equal(12, secondary.Message.Function); + Assert.Equal(SecsItem.Boolean(true), secondary.Message.RootItem); + await Assert.ThrowsAsync( + () => passive.ReplyAsync( + incoming, + new SecsMessage(6, 12), + cancellationToken)).ConfigureAwait(true); + } + + private static int GetFreePort() + { + var listener = new System.Net.Sockets.TcpListener( + IPAddress.Loopback, + 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static async Task NextMatchingAsync( + IAsyncEnumerator events, + Func predicate) + { + while (await events.MoveNextAsync().ConfigureAwait(true)) + { + if (predicate(events.Current)) + return events.Current; + } + + Assert.Fail("The expected public HSMS connection event was not received."); + return null!; + } +}