diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0e718..38d9caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,11 @@ 状态接受或拒绝 Host 在线/离线请求,未注册处理器时保持自动接受。 - 拒绝使用 profile 失败应答且双方状态保持不变;接受仍在 Secondary 完整 写出后更新 Equipment 状态。增加注册生命周期和真实 TCP 状态测试。 +- 增加 Host/Equipment 双向可释放通讯建立策略;应用可依据请求方身份接受 + 或拒绝 S1F13,未注册策略时保持自动接受。 +- 拒绝使用 profile 失败应答和空身份 List,双方既有通讯状态与对端身份均 + 保持不变;应用随后可在同一 Selected 会话显式重试恢复。增加双向注册 + 生命周期、首次建立拒绝/恢复和已通讯重建拒绝/恢复的真实 TCP 状态测试。 - 增加定义、链接、S6F11 动态 Item 向量和真实 TCP 垂直测试,覆盖嵌套值、 空报告、配置拒绝、触发前快照与 Host 拒绝。 diff --git a/README.md b/README.md index 5dd413b..ca4d82f 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor 进一步提供实际写出后启动的 T3、复合事务键关联、入站回复和会话失效 隔离。公共 HsmsConnection 将这些内部层组合成显式计时 配置、动态消息收发、状态等待和单消费者事件流。 -独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含通讯建立、 -上下线与应用转换策略、动态变量/常量、应用托管时钟、报告定义、事件链接和 -Collection Event、报警目录查询、单报警发送启停,以及最小报警通知和 -远程命令链路。 +独立 SecsFrame.Gem 已提供可配置 GEM 通用行为,包含双向通讯 +建立及应用接受策略、同会话显式重试恢复、上下线与应用转换策略、动态变量/ +常量、应用托管时钟、报告定义、事件链接和 Collection Event、报警目录查询、 +单报警发送启停,以及最小报警通知和远程命令链路。 Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 diff --git a/docs/GEM-FOUNDATION.md b/docs/GEM-FOUNDATION.md index d01a738..09079f4 100644 --- a/docs/GEM-FOUNDATION.md +++ b/docs/GEM-FOUNDATION.md @@ -3,10 +3,10 @@ ## 模块与范围 SecsFrame.Gem 是只依赖 SecsFrame 的独立程序集。 -当前切片提供 Host/Equipment 两侧的通讯建立、在线/离线及应用转换策略、 -状态变量读取、设备常量读取、应用托管时钟、报告定义、事件链接和 -Collection Event,以及报警目录查询、单报警发送启停、最小报警通知与 -远程命令链路,不把 GEM 状态或消息目录放入 HSMS 状态机。 +当前切片提供 Host/Equipment 两侧的通讯建立及应用接受策略、在线/离线及 +应用转换策略、状态变量读取、设备常量读取、应用托管时钟、报告定义、事件 +链接和 Collection Event,以及报警目录查询、单报警发送启停、最小报警 +通知与远程命令链路,不把 GEM 状态或消息目录放入 HSMS 状态机。 GemHostServices 依赖 SecsHost; GemEquipmentServices 依赖 SecsEquipment。服务不拥有 @@ -65,6 +65,21 @@ await foreach (var connectionEvent in equipment.GetEventsAsync(cancellationToken 非 Selected 的状态事件后,这些状态会重置;因此只分派数据事件会遗漏 断线重置。 +Host 与 Equipment 都可使用 +RegisterCommunicationEstablishmentHandler 注册单一可释放 +策略。每个结构有效的对端通讯建立请求都会把请求方 +GemIdentity 交给处理器。返回 false 时服务发送 +profile 的失败应答和空身份 List,发起方抛出包含操作和原始应答字节的 +GemRequestRejectedException。双方均不更新 +CommunicationState 或 PeerIdentity;如果请求是 +已通讯状态下的重新建立,既有状态和身份也会保留。 + +返回 true 时服务先完整写出成功 Secondary,再记录请求方身份 +并进入 Communicating。没有注册策略时继续自动接受,保持此前 +行为。应用可以改变策略结果并在同一 Selected 会话再次调用 +EstablishCommunicationAsync;库不自动重试、不创建计时器或 +后台恢复循环。 + Equipment 可使用 RegisterOnlineStateTransitionHandler 注册 单一可释放策略。每个结构有效的 Host 在线/离线请求都会把当前状态和目标 状态交给处理器;返回 false 时 Equipment 发送 profile 的失败 @@ -72,10 +87,11 @@ Equipment 可使用 RegisterOnlineStateTransitionHandler 注册 GemRequestRejectedException,不会更新观察状态。返回 true 时仍先完整写出成功 Secondary,再更新 Equipment 状态。 -没有注册处理器时继续自动接受,保持此前行为。处理器在服务锁外执行;释放 +这两类策略没有注册处理器时都继续自动接受。处理器在服务锁外执行;释放 注册只影响后续请求,已经取得的处理器快照会完成。处理器异常和取消继续 -传播给事件循环,不会被折叠为失败应答。同状态请求也会交给处理器。应用应 -遵守角色端点的单消费者事件循环约束,使状态转换按请求顺序执行。 +传播给事件循环,不会被折叠为失败应答。同状态在线请求也会交给处理器。 +应用应遵守角色端点的单消费者事件循环约束,使决策和状态转换按请求顺序 +执行。 当前切片仍没有把变量、常量、时钟、报告、报警或命令硬性门控在某个 GEM 状态上,也不自动重新建立通讯。这样可在尚未获得授权 E30 状态表前避免把 @@ -182,7 +198,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 - 基础 Primary 设置 W-Bit,请求体符合 profile 对应的空 Body、List、 ASCII 或 Identity 结构; - Secondary 的 Stream、Function 和 W-Bit 与配置完全匹配; -- 应答是单个 Binary 字节,接受的通讯建立应答包含两项 ASCII Identity; +- 通讯建立回复是确认字节与身份组成的二元素 List;接受时必须包含两项 + ASCII Identity,策略拒绝时使用 profile 失败确认和空身份 List; - 在线/离线请求必须设置 W-Bit 且 Body 为空;应用拒绝使用 profile 失败 应答,接受应答写出完成前不得改变 Equipment 在线状态; - 状态变量和设备常量请求只引用已注册标识,提供器不得返回 null; @@ -205,9 +222,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 畸形输入和提供器失败会作为异常返回应用事件循环;语义有效但引用未知变量 或报告的配置使用失败应答拒绝。本切片尚未实现自动 S9Fx、错误 Secondary、 -通讯建立策略拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、 -命令目录/权限/调度或完整 GEM 错误恢复,这些行为不能从当前 API 推断为 -标准合规。 +自动重新建立、业务消息状态门控、报警历史/批量控制、命令目录/权限/调度 +或完整 GEM 错误恢复,这些行为不能从当前 API 推断为标准合规。 ## 标准与验证边界 @@ -221,7 +237,8 @@ Host 返回完整结果,不把非零代码折叠成异常,也不解释代码 [STANDARDS.md](STANDARDS.md)。 当前证据包括三目标框架的严格输入测试,以及 Host Active / Equipment -Passive 真实 TCP 下的双向通讯建立、在线转换接受/拒绝与状态保持、异构 +Passive 真实 TCP 下的双向通讯建立、首次建立拒绝后同会话恢复、已通讯 +重新建立拒绝时的状态/身份保持与恢复、在线转换接受/拒绝与状态保持、异构 动态标识、报告定义、 事件链接、Collection Event 嵌套值与空报告、报警通知接受/拒绝、配置/事件 拒绝、报警目录全量/选择查询与释放快照、单报警发送启停与未知标识拒绝、 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e841873..fd2b3db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,6 +43,7 @@ - [x] Equipment 运行期报警目录注册与 Host 全量/按标识查询。 - [x] 单个已注册报警的发送启停、未知标识拒绝和通知发送门控。 - [x] Equipment 在线/离线转换的应用决策、显式拒绝与状态保持。 +- [x] Host/Equipment 双向通讯建立应用决策、显式拒绝与同会话重试恢复。 - [ ] 报警历史/批量控制、命令目录/权限/调度和更完整的状态/错误恢复。 - [ ] 使用授权 E30-0526 核对基础消息条件、数据项、应答值和状态转换。 @@ -50,8 +51,10 @@ SecsItem 标识,并保留报警与命令结果原始代码,不宣称已经 核对 E30 的生命周期、代码语义或应答枚举。报警目录查询同样是非规范性 工程基线;单报警发送启停也使用显式可替换控制码。在线转换策略默认不注册, -保持既有宽松接受行为。下一优先级是通讯建立拒绝与重新建立恢复,再评估 -业务消息状态门控、报警历史和批量控制。 +保持既有宽松接受行为。通讯建立策略在 Host 与 Equipment 两侧同样默认 +不注册;拒绝不改变既有通讯状态或对端身份,恢复由应用在同一 Selected +会话显式重试,库不启动后台循环。下一优先级是默认关闭、由应用配置的业务 +消息状态门控最小切片,再评估自动恢复、报警历史和批量控制。 ## 阶段 3:元数据与工具 diff --git a/docs/SECS-ENDPOINT-ROLES.md b/docs/SECS-ENDPOINT-ROLES.md index 67e6e87..2ea48d3 100644 --- a/docs/SECS-ENDPOINT-ROLES.md +++ b/docs/SECS-ENDPOINT-ROLES.md @@ -65,6 +65,8 @@ await equipment.WaitUntilSelectedAsync(cancellationToken); 设备常量、时钟、报告定义、事件链接、Collection Event,以及最小报警通知 和远程命令; 报警目录查询支持运行期注册,Host 也可启停单个已注册报警的通知发送。 -通讯建立拒绝、自动重新建立、业务消息状态门控、报警历史/批量控制、命令 -目录/权限/调度仍未实现。相关行为、事件循环要求和未核对边界见 +Host 与 Equipment 的 GEM 服务都可注册单一通讯建立策略;拒绝保留双方 +既有通讯状态和对端身份,应用可在同一 Selected 会话显式重试。自动重新 +建立、业务消息状态门控、报警历史/批量控制、命令目录/权限/调度仍未实现。 +相关行为、事件循环要求和未核对边界见 [GEM-FOUNDATION.md](GEM-FOUNDATION.md),项目不声明 GEM 合规。 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 6388921..ad9a50c 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -9,7 +9,7 @@ |---|---|---| | SECS-II 消息内容 | SEMI E5-0725 | Item、动态消息编解码与内部 T3 数据事务已有工程实现;完整事务规则待核对 | | HSMS / HSMS-SS | SEMI E37-0222 / E37.1-0819 | 帧、Data Payload、内部传输、T5/T8 工程计时、控制状态机与会话绑定数据发送已有实现;仍待授权标准和一致性测试核对 | -| GEM | SEMI E30-0526 | 通讯建立、上下线、动态变量/常量、时钟、报告/事件、报警通知/目录/发送启停和远程命令已有可替换 profile 的工程实现;规范性条件待核对 | +| GEM | SEMI E30-0526 | 通讯建立与双向应用接受策略、上下线、动态变量/常量、时钟、报告/事件、报警通知/目录/发送启停和远程命令已有可替换 profile 的工程实现;规范性条件待核对 | | SEDD | SEMI E172-0225 | 规划中 | | SMN | SEMI E173-0721 | 规划中 | @@ -39,7 +39,7 @@ 的必选/可选边界;还需核对基础消息条件、COMMACK/ONLACK/OFLACK/TIACK 取值、MDLN 条件结构、空 SVID/ECID 列表、报告定义与链接生命周期、 Collection Event 数据结构和应答值、报警通知/目录/发送启停的数据结构与 - 控制码、应用在线转换策略、时间格式、状态转换与错误响应。 + 控制码、应用通讯建立/在线转换策略、时间格式、状态转换与错误响应。 - E172/E173:允许分发的 Schema/表示形式、版本标识、扩展点和验证规则。 ## 版权与合规边界 diff --git a/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs b/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs new file mode 100644 index 0000000..f979bb6 --- /dev/null +++ b/src/SecsFrame.Gem/GemCommunicationEstablishmentHandler.cs @@ -0,0 +1,11 @@ +namespace SecsFrame.Gem; + +/// +/// Decides whether to accept a peer-requested communication establishment. +/// +/// The identity supplied by the requesting peer. +/// Cancels the pending request. +/// to accept the request; otherwise, . +public delegate ValueTask GemCommunicationEstablishmentHandler( + GemIdentity peerIdentity, + CancellationToken cancellationToken); diff --git a/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs b/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs new file mode 100644 index 0000000..918b24c --- /dev/null +++ b/src/SecsFrame.Gem/GemCommunicationEstablishmentRegistration.cs @@ -0,0 +1,21 @@ +namespace SecsFrame.Gem; + +/// Owns one communication-establishment handler registration. +public sealed class GemCommunicationEstablishmentRegistration : IDisposable +{ + private Action? _unregister; + + internal GemCommunicationEstablishmentRegistration( + GemCommunicationEstablishmentHandler handler, + Action unregister) + { + Handler = handler; + _unregister = unregister; + } + + internal GemCommunicationEstablishmentHandler Handler { get; } + + /// Removes this exact handler registration. Repeated calls are harmless. + public void Dispose() + => Interlocked.Exchange(ref _unregister, null)?.Invoke(this); +} diff --git a/src/SecsFrame.Gem/GemEndpointServices.cs b/src/SecsFrame.Gem/GemEndpointServices.cs index b2b4d34..767a98e 100644 --- a/src/SecsFrame.Gem/GemEndpointServices.cs +++ b/src/SecsFrame.Gem/GemEndpointServices.cs @@ -4,6 +4,13 @@ internal sealed class GemEndpointServices : IDisposable { private readonly SecsEndpoint _endpoint; private readonly List _routes = new(); +#if NET9_0_OR_GREATER + private readonly Lock _gate = new(); +#else + private readonly object _gate = new(); +#endif + private GemCommunicationEstablishmentRegistration? + _communicationEstablishmentHandler; private GemIdentity? _peerIdentity; private int _communicationState; private int _onlineState; @@ -66,6 +73,30 @@ internal async Task EstablishCommunicationAsync( return reply.Identity; } + internal GemCommunicationEstablishmentRegistration + RegisterCommunicationEstablishmentHandler( + GemCommunicationEstablishmentHandler handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + lock (_gate) + { + ThrowIfDisposed(); + if (_communicationEstablishmentHandler is not null) + { + throw new InvalidOperationException( + "A communication-establishment handler is already registered."); + } + + var registration = new GemCommunicationEstablishmentRegistration( + handler, + UnregisterCommunicationEstablishmentHandler); + _communicationEstablishmentHandler = registration; + return registration; + } + } + internal async Task AreYouOnlineAsync( CancellationToken cancellationToken) { @@ -176,6 +207,9 @@ public void Dispose() if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + lock (_gate) + _communicationEstablishmentHandler = null; + for (var index = _routes.Count - 1; index >= 0; index--) _routes[index].Dispose(); _routes.Clear(); @@ -193,14 +227,26 @@ public void Dispose() var identity = GemMessageCodec.DecodeIdentity( context.Message.RootItem, "communication-establishment request"); + GemCommunicationEstablishmentHandler? handler; + lock (_gate) + { + ThrowIfDisposed(); + handler = _communicationEstablishmentHandler?.Handler; + } + + var accepted = handler is null || + await handler(identity, cancellationToken).ConfigureAwait(false); await ReplyAsync( context, Profile.EstablishCommunication, GemMessageCodec.EncodeCommunicationReply( - Profile.AcceptedAcknowledgement, - Identity), + accepted + ? Profile.AcceptedAcknowledgement + : Profile.FailedAcknowledgement, + accepted ? Identity : null), cancellationToken).ConfigureAwait(false); - SetCommunicating(identity); + if (accepted) + SetCommunicating(identity); return null; } @@ -241,6 +287,20 @@ private void ResetState() Volatile.Write(ref _onlineState, (int)GemOnlineState.Offline); } + private void UnregisterCommunicationEstablishmentHandler( + GemCommunicationEstablishmentRegistration registration) + { + lock (_gate) + { + if (ReferenceEquals( + _communicationEstablishmentHandler, + registration)) + { + _communicationEstablishmentHandler = null; + } + } + } + private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) diff --git a/src/SecsFrame.Gem/GemEquipmentServices.cs b/src/SecsFrame.Gem/GemEquipmentServices.cs index a20c0c3..9a9f300 100644 --- a/src/SecsFrame.Gem/GemEquipmentServices.cs +++ b/src/SecsFrame.Gem/GemEquipmentServices.cs @@ -68,6 +68,15 @@ public Task EstablishCommunicationAsync( CancellationToken cancellationToken = default) => _services.EstablishCommunicationAsync(cancellationToken); + /// + /// Registers the single application policy for peer-requested communication + /// establishment. + /// + public GemCommunicationEstablishmentRegistration + RegisterCommunicationEstablishmentHandler( + GemCommunicationEstablishmentHandler handler) + => _services.RegisterCommunicationEstablishmentHandler(handler); + /// Queries the Host identity. public Task AreYouOnlineAsync( CancellationToken cancellationToken = default) diff --git a/src/SecsFrame.Gem/GemHostServices.cs b/src/SecsFrame.Gem/GemHostServices.cs index 5a56a76..d1fdcae 100644 --- a/src/SecsFrame.Gem/GemHostServices.cs +++ b/src/SecsFrame.Gem/GemHostServices.cs @@ -62,6 +62,15 @@ public Task EstablishCommunicationAsync( CancellationToken cancellationToken = default) => _services.EstablishCommunicationAsync(cancellationToken); + /// + /// Registers the single application policy for peer-requested communication + /// establishment. + /// + public GemCommunicationEstablishmentRegistration + RegisterCommunicationEstablishmentHandler( + GemCommunicationEstablishmentHandler handler) + => _services.RegisterCommunicationEstablishmentHandler(handler); + /// Queries the Equipment identity. public Task AreYouOnlineAsync( CancellationToken cancellationToken = default) diff --git a/src/SecsFrame.Gem/GemMessageCodec.cs b/src/SecsFrame.Gem/GemMessageCodec.cs index 36deae7..a2afeff 100644 --- a/src/SecsFrame.Gem/GemMessageCodec.cs +++ b/src/SecsFrame.Gem/GemMessageCodec.cs @@ -17,10 +17,12 @@ internal static GemIdentity DecodeIdentity(SecsItem? root, string operation) internal static SecsItem EncodeCommunicationReply( byte acknowledgement, - GemIdentity identity) + GemIdentity? identity) => SecsItem.List( EncodeAcknowledgement(acknowledgement), - EncodeIdentity(identity)); + identity is null + ? SecsItem.List() + : EncodeIdentity(identity)); internal static (byte Acknowledgement, GemIdentity? Identity) DecodeCommunicationReply(SecsItem? root) diff --git a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs index 973bb3a..a262dba 100644 --- a/test/SecsFrame.Tests/GemEquipmentServicesTests.cs +++ b/test/SecsFrame.Tests/GemEquipmentServicesTests.cs @@ -245,6 +245,33 @@ public async Task Online_state_transition_handler_is_exact_disposable_and_replac services.RegisterOnlineStateTransitionHandler(handler)); } + [Fact] + public async Task Communication_establishment_handler_is_exact_disposable_and_replaceable() + { + await using var endpoint = new SecsEquipment( + CreateOptions(GetFreePort(), HsmsConnectionMode.Active)); + using var services = new GemEquipmentServices( + endpoint, + new GemIdentity("EQ-01", "1.0"), + new TestGemClock(Epoch)); + GemCommunicationEstablishmentHandler handler = static (_, _) => + new ValueTask(true); + + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(null!)); + var first = + services.RegisterCommunicationEstablishmentHandler(handler); + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(handler)); + first.Dispose(); + first.Dispose(); + using var replacement = + services.RegisterCommunicationEstablishmentHandler(handler); + services.Dispose(); + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(handler)); + } + [Fact] public async Task Event_link_rejects_duplicate_report_identifiers() { diff --git a/test/SecsFrame.Tests/GemFoundationTcpTests.cs b/test/SecsFrame.Tests/GemFoundationTcpTests.cs index 045a1a0..cf35479 100644 --- a/test/SecsFrame.Tests/GemFoundationTcpTests.cs +++ b/test/SecsFrame.Tests/GemFoundationTcpTests.cs @@ -20,10 +20,8 @@ public async Task Host_and_equipment_complete_foundational_dialogues_over_tcp() await AssertAlarmNotificationsAsync(context).ConfigureAwait(true); await AssertRemoteCommandsAsync(context).ConfigureAwait(true); await AssertClockAsync(context).ConfigureAwait(true); - Assert.Equal( - context.HostServices.Identity, - await context.EquipmentServices.EstablishCommunicationAsync( - context.Token).ConfigureAwait(true)); + await AssertCommunicationReestablishmentPolicyAsync(context) + .ConfigureAwait(true); await context.HostServices.RequestOfflineAsync(context.Token) .ConfigureAwait(true); await WaitUntilAsync( @@ -37,8 +35,9 @@ await WaitUntilAsync( private static async Task AssertCommunicationAndOnlineAsync( GemTcpContext context) { - var equipmentIdentity = await context.HostServices - .EstablishCommunicationAsync(context.Token).ConfigureAwait(true); + var equipmentIdentity = + await AssertInitialCommunicationEstablishmentPolicyAsync(context) + .ConfigureAwait(true); await WaitUntilAsync( () => context.EquipmentServices.CommunicationState == GemCommunicationState.Communicating, @@ -67,6 +66,107 @@ await WaitUntilAsync( Assert.Equal(GemOnlineState.Online, context.EquipmentServices.OnlineState); } + private static async Task + AssertInitialCommunicationEstablishmentPolicyAsync(GemTcpContext context) + { + var acceptEstablishment = false; + var observed = new List(); + GemIdentity equipmentIdentity; + using (context.EquipmentServices.RegisterCommunicationEstablishmentHandler( + (peerIdentity, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + observed.Add(peerIdentity); + return new ValueTask(acceptEstablishment); + })) + { + var rejected = await Assert.ThrowsAsync( + () => context.HostServices.EstablishCommunicationAsync( + context.Token)).ConfigureAwait(true); + Assert.Equal(GemOperation.EstablishCommunication, rejected.Operation); + Assert.Equal((byte)1, rejected.Acknowledgement); + Assert.Equal( + GemCommunicationState.NotCommunicating, + context.HostServices.CommunicationState); + Assert.Equal( + GemCommunicationState.NotCommunicating, + context.EquipmentServices.CommunicationState); + Assert.Null(context.HostServices.PeerIdentity); + Assert.Null(context.EquipmentServices.PeerIdentity); + + acceptEstablishment = true; + equipmentIdentity = await context.HostServices + .EstablishCommunicationAsync(context.Token).ConfigureAwait(true); + } + + Assert.Equal( + new[] + { + context.HostServices.Identity, + context.HostServices.Identity, + }, + observed); + return equipmentIdentity; + } + + private static async Task AssertCommunicationReestablishmentPolicyAsync( + GemTcpContext context) + { + var hostPeerIdentity = context.HostServices.PeerIdentity; + var equipmentPeerIdentity = context.EquipmentServices.PeerIdentity; + Assert.NotNull(hostPeerIdentity); + Assert.NotNull(equipmentPeerIdentity); + + var acceptEstablishment = false; + var observed = new List(); + using (context.HostServices.RegisterCommunicationEstablishmentHandler( + (peerIdentity, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + observed.Add(peerIdentity); + return new ValueTask(acceptEstablishment); + })) + { + var rejected = await Assert.ThrowsAsync( + () => context.EquipmentServices.EstablishCommunicationAsync( + context.Token)).ConfigureAwait(true); + Assert.Equal(GemOperation.EstablishCommunication, rejected.Operation); + Assert.Equal((byte)1, rejected.Acknowledgement); + Assert.Equal( + GemCommunicationState.Communicating, + context.HostServices.CommunicationState); + Assert.Equal( + GemCommunicationState.Communicating, + context.EquipmentServices.CommunicationState); + Assert.Equal(hostPeerIdentity, context.HostServices.PeerIdentity); + Assert.Equal( + equipmentPeerIdentity, + context.EquipmentServices.PeerIdentity); + + acceptEstablishment = true; + Assert.Equal( + context.HostServices.Identity, + await context.EquipmentServices.EstablishCommunicationAsync( + context.Token).ConfigureAwait(true)); + } + + await WaitUntilAsync( + () => context.EquipmentServices.Identity.Equals( + context.HostServices.PeerIdentity), + context.Token).ConfigureAwait(true); + Assert.Equal( + new[] + { + context.EquipmentServices.Identity, + context.EquipmentServices.Identity, + }, + observed); + Assert.Equal( + context.HostServices.Identity, + await context.EquipmentServices.EstablishCommunicationAsync( + context.Token).ConfigureAwait(true)); + } + private static async Task AssertOnlineTransitionPolicyAsync( GemTcpContext context) { diff --git a/test/SecsFrame.Tests/GemHostServicesTests.cs b/test/SecsFrame.Tests/GemHostServicesTests.cs index d0ab3a9..ca3ba25 100644 --- a/test/SecsFrame.Tests/GemHostServicesTests.cs +++ b/test/SecsFrame.Tests/GemHostServicesTests.cs @@ -57,6 +57,31 @@ public async Task Collection_event_handler_is_exact_disposable_and_replaceable() services.RegisterCollectionEventHandler(handler)); } + [Fact] + public async Task Communication_establishment_handler_is_exact_disposable_and_replaceable() + { + await using var endpoint = new SecsHost(CreateOptions()); + using var services = new GemHostServices( + endpoint, + new GemIdentity("HOST-01", "1.0")); + GemCommunicationEstablishmentHandler handler = static (_, _) => + new ValueTask(true); + + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(null!)); + var first = + services.RegisterCommunicationEstablishmentHandler(handler); + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(handler)); + first.Dispose(); + first.Dispose(); + using var replacement = + services.RegisterCommunicationEstablishmentHandler(handler); + services.Dispose(); + Assert.Throws(() => + services.RegisterCommunicationEstablishmentHandler(handler)); + } + [Fact] public async Task Alarm_notification_vectors_require_w_bit_and_strict_fields() {