From f253c06c0dc6a14c2f4a9ba12f73c139fcaa611c Mon Sep 17 00:00:00 2001 From: Marci Date: Fri, 28 Aug 2026 19:28:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20HSMS=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + README.md | 2 + docs/HSMS-CONNECTION.md | 11 +- docs/HSMS-DIAGNOSTICS.md | 77 ++++++ docs/ROADMAP.md | 8 +- .../Hsms/Connection/HsmsConnectionEvent.cs | 15 +- .../Hsms/Connection/HsmsDiagnostic.cs | 235 ++++++++++++++++++ .../Hsms/Connection/HsmsDiagnosticCode.cs | 47 ++++ .../Hsms/Connection/HsmsDiagnosticLayer.cs | 17 ++ .../Hsms/Connection/HsmsOperation.cs | 35 +++ src/SecsFrame/Hsms/Connection/HsmsTimer.cs | 20 ++ ...sControlTransactionInterruptedException.cs | 21 ++ .../Hsms/Session/HsmsSessionStateMachine.cs | 27 +- .../Session/HsmsSessionTimeoutException.cs | 23 +- test/SecsFrame.Tests/HsmsConnectionTests.cs | 1 + test/SecsFrame.Tests/HsmsDiagnosticTests.cs | 159 ++++++++++++ ...msSessionStateMachineTests.ControlPlane.cs | 7 +- .../HsmsSessionStateMachineTests.cs | 2 + 18 files changed, 694 insertions(+), 17 deletions(-) create mode 100644 docs/HSMS-DIAGNOSTICS.md create mode 100644 src/SecsFrame/Hsms/Connection/HsmsDiagnostic.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsDiagnosticCode.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsDiagnosticLayer.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsOperation.cs create mode 100644 src/SecsFrame/Hsms/Connection/HsmsTimer.cs create mode 100644 src/SecsFrame/Hsms/Session/HsmsControlTransactionInterruptedException.cs create mode 100644 test/SecsFrame.Tests/HsmsDiagnosticTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 81fc041..1b07e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,5 +51,9 @@ 由中央包管理固定版本且只从 nuget.org 还原,不引用本地源码 checkout。 - 增加双方 Active/Passive、Select、双方 Linktest、双方 Primary/Secondary 及嵌套 Item 边界值的真实 TCP 跨实现测试。 +- 增加公共 HsmsDiagnostic 结构化诊断模型,在保留原异常的 + 同时分类运输、协议、会话、事务、T3/T6/T7/T8 和解码故障。 +- 连接状态与解码失败事件公开可选诊断;操作异常可显式分类,调用方取消、 + 释放和生命周期误用不会被误报为协议故障。 [Unreleased]: https://github.com/CSJ608/SecsFrame/compare/main...HEAD diff --git a/README.md b/README.md index d05d080..554fbd0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor [docs/HSMS-DATA-TRANSACTIONS.md](docs/HSMS-DATA-TRANSACTIONS.md), 公共连接用法见 [docs/HSMS-CONNECTION.md](docs/HSMS-CONNECTION.md), +结构化故障分类见 +[docs/HSMS-DIAGNOSTICS.md](docs/HSMS-DIAGNOSTICS.md), 官方 secs4net 跨实现证据见 [docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md), 整体路线图见 diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md index bfe8192..a2c3c91 100644 --- a/docs/HSMS-CONNECTION.md +++ b/docs/HSMS-CONNECTION.md @@ -80,6 +80,12 @@ var secondary = await connection.SendAsync( - ControlMessageReceived:未被当前控制或数据事务认领的帧; - DataMessageDecodeFailed:原始帧和严格解码错误。 +带错误的状态事件和解码失败事件还会提供可选 +HsmsDiagnostic,用于按稳定代码、层级、操作与计时器处理, +无需解析异常消息。调用任务抛出的异常可通过 +HsmsDiagnostic.Classify 使用同一分类。完整契约和原始帧的 +数据安全边界见 [HSMS-DIAGNOSTICS.md](HSMS-DIAGNOSTICS.md)。 + 入站消息只有 ReplyExpected 为真时可以回复。回复自动复制原 协议 Session ID 与 System Bytes,并绑定原 transport session;开始回复 后令牌不可再次使用,也不会在替换连接上重放。 @@ -93,9 +99,10 @@ var secondary = await connection.SendAsync( - Deselect、Separate、断线、替换会话或释放会结束绑定旧会话的等待; - 事件枚举的取消只结束该消费者;连接仍须显式释放。 -当前具体异常类型仍以 TimeoutException、 +具体异常类型继续以 TimeoutException、 IOException、OperationCanceledException 和参数/ -状态异常为稳定分类;更细的公共诊断模型将在互操作证据后再收敛。 +状态异常为兼容分类;HsmsDiagnostic 在其上提供更细的机器可读 +上下文。调用方取消、释放和生命周期误用不被分类成协议故障。 ## 验证边界 diff --git a/docs/HSMS-DIAGNOSTICS.md b/docs/HSMS-DIAGNOSTICS.md new file mode 100644 index 0000000..691ffb2 --- /dev/null +++ b/docs/HSMS-DIAGNOSTICS.md @@ -0,0 +1,77 @@ +# HSMS 结构化诊断 + +## 目标与边界 + +HsmsDiagnostic 为公共连接事件和操作异常提供稳定、机器可读的 +故障上下文。应用可以按 Code、Layer、 +Operation 和 Timer 做指标、告警与恢复决策,不需要 +解析英文异常消息。 + +诊断不替换原异常:HsmsConnectionEvent.Error 和调用任务抛出的 +异常保持原有类型层级,诊断的 Error 属性指向同一个异常实例。 +这使现有按 TimeoutException、IOException 或 +OperationCanceledException 处理的代码继续工作。 + +## 事件诊断 + +HsmsConnectionEvent.Diagnostic 在以下事件中按当前证据填充: + +- 带可分类错误的 StateChanged; +- 所有 DataMessageDecodeFailed。 + +正常状态转换、业务消息和未消费控制消息的诊断为空。远端正常关闭但没有 +运输错误的状态转换也不会被强行解释成故障。 + +~~~csharp +await foreach (var connectionEvent in connection.GetEventsAsync(cancellationToken)) +{ + if (connectionEvent.Diagnostic is not { } diagnostic) + continue; + + RecordMetric( + diagnostic.Code, + diagnostic.Layer, + diagnostic.Operation, + diagnostic.Timer); +} +~~~ + +## 操作异常分类 + +SendAsync、ReplyAsync 和控制命令的异常可以通过 +HsmsDiagnostic.Classify 分类: + +~~~csharp +try +{ + await connection.SendAsync(primary, cancellationToken); +} +catch (Exception error) +{ + var diagnostic = HsmsDiagnostic.Classify(error, connection.State); + if (diagnostic is null) + throw; + + HandleHsmsFailure(diagnostic); +} +~~~ + +当前稳定代码覆盖运输失败/会话失效、协议错误、T3/T6/T7/T8、选择与 +控制拒绝、数据拒绝、事务中断和数据消息解码失败。T3 诊断携带协议 +Session ID 与 System Bytes;拒绝诊断保留远端状态或原因字节。 + +调用方取消、释放、参数错误、生命周期误用和未知异常返回空诊断。这些结果 +不能被监控系统误记为协议或设备故障。T5 当前是 Active 连接重试节流, +不是每次失败都会产生一个独立公共诊断;HsmsTimer.T5 只保留 +计时器身份,等待未来有明确、经过验证的可观测契约。 + +## 数据安全 + +解码失败诊断的 Frame 与事件中的原始帧相同,可能包含设备或 +工艺数据。库不自动格式化、写日志或上传它。应用在记录前应按自己的数据 +分类策略做脱敏和长度限制;常规指标优先只使用代码、层级、操作和计时器。 + +## 标准边界 + +诊断名称描述 SecsFrame 已实现并测试的工程行为,不新增 SEMI 默认值,也 +不改变 E37/E37.1 状态机。诊断覆盖不代表完整标准合规或设备认证结果。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d1c4db4..ccabe1f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,12 +20,14 @@ 确定的取消/释放语义。 - [x] 使用 nuget.org 官方 Secs4Net 3.1.0 的 Active/Passive、Select、 双方 Linktest 和双方 Primary/Secondary 跨实现测试。 +- [x] 公共结构化诊断模型,覆盖运输、协议、会话、事务和解码故障。 - [ ] 使用授权 E37/E37.1 核对 T5/T8 默认值与精确启停边界。 - [ ] Host、Equipment 双角色能力及对应回环集成测试。 -阶段 1 的下一最小切片是根据跨实现证据收敛公共诊断模型,同时继续跟踪 -StreamFrame #38/#39 的正式包发布并准备等价迁移。任何标准默认值和完整 -合规结论都依赖团队合法获得的 E37/E37.1 副本与一致性测试。 +阶段 1 的下一最小切片是运行期 Primary 路由与处理器注册,随后建立 +Host/Equipment 角色能力,同时继续跟踪 StreamFrame #38/#39 的正式包 +发布并准备等价迁移。任何标准默认值和完整合规结论都依赖团队合法获得的 +E37/E37.1 副本与一致性测试。 ## 阶段 2:GEM 通用能力 diff --git a/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs b/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs index 9a3d9ec..d6109d3 100644 --- a/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs +++ b/src/SecsFrame/Hsms/Connection/HsmsConnectionEvent.cs @@ -8,13 +8,15 @@ private HsmsConnectionEvent( HsmsSessionState state, HsmsIncomingDataMessage? incomingMessage, HsmsFrame? frame, - Exception? error) + Exception? error, + HsmsDiagnostic? diagnostic) { Kind = kind; State = state; IncomingMessage = incomingMessage; Frame = frame; Error = error; + Diagnostic = diagnostic; } /// Gets the event kind. @@ -32,6 +34,9 @@ private HsmsConnectionEvent( /// Gets the state or decoding error, when applicable. public Exception? Error { get; } + /// Gets structured failure context, when this event reports a diagnostic. + public HsmsDiagnostic? Diagnostic { get; } + internal static HsmsConnectionEvent StateChanged( HsmsSessionState state, Exception? error) @@ -40,7 +45,8 @@ internal static HsmsConnectionEvent StateChanged( state, null, null, - error); + error, + error is null ? null : HsmsDiagnostic.Classify(error, state)); internal static HsmsConnectionEvent DataMessageReceived( HsmsIncomingDataMessage incomingMessage) @@ -49,6 +55,7 @@ internal static HsmsConnectionEvent DataMessageReceived( HsmsSessionState.Selected, incomingMessage, null, + null, null); internal static HsmsConnectionEvent ControlMessageReceived( @@ -59,6 +66,7 @@ internal static HsmsConnectionEvent ControlMessageReceived( state, null, frame, + null, null); internal static HsmsConnectionEvent DataMessageDecodeFailed( @@ -69,5 +77,6 @@ internal static HsmsConnectionEvent DataMessageDecodeFailed( HsmsSessionState.Selected, null, frame, - error); + error, + HsmsDiagnostic.DataMessageDecodeFailed(frame, error)); } diff --git a/src/SecsFrame/Hsms/Connection/HsmsDiagnostic.cs b/src/SecsFrame/Hsms/Connection/HsmsDiagnostic.cs new file mode 100644 index 0000000..29aad63 --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsDiagnostic.cs @@ -0,0 +1,235 @@ +using System.IO; + +namespace SecsFrame; + +/// +/// Provides stable, structured context for a failure observed through +/// . +/// +public sealed class HsmsDiagnostic +{ + private HsmsDiagnostic( + HsmsDiagnosticCode code, + HsmsDiagnosticLayer layer, + HsmsOperation operation, + HsmsSessionState state, + Exception error, + HsmsTimer? timer = null, + ushort? protocolSessionId = null, + uint? systemBytes = null, + byte? peerStatus = null, + byte? rejectedMessageType = null, + HsmsFrame? frame = null) + { + Code = code; + Layer = layer; + Operation = operation; + State = state; + Error = error; + Timer = timer; + ProtocolSessionId = protocolSessionId; + SystemBytes = systemBytes; + PeerStatus = peerStatus; + RejectedMessageType = rejectedMessageType; + Frame = frame; + } + + /// Gets the stable diagnostic code. + public HsmsDiagnosticCode Code { get; } + + /// Gets the layer that produced the diagnostic. + public HsmsDiagnosticLayer Layer { get; } + + /// Gets the operation associated with the diagnostic. + public HsmsOperation Operation { get; } + + /// Gets the HSMS session state observed with the diagnostic. + public HsmsSessionState State { get; } + + /// Gets the associated timer, when the diagnostic is a timer expiry. + public HsmsTimer? Timer { get; } + + /// Gets the protocol Session ID, when available. + public ushort? ProtocolSessionId { get; } + + /// Gets the transaction System Bytes, when available. + public uint? SystemBytes { get; } + + /// Gets a peer-provided status or reject reason byte, when available. + public byte? PeerStatus { get; } + + /// Gets the rejected HSMS SType byte, when available. + public byte? RejectedMessageType { get; } + + /// Gets the undecodable frame for a decode diagnostic, when available. + /// The frame can contain application data and should be logged deliberately. + public HsmsFrame? Frame { get; } + + /// Gets the original exception for detailed local investigation. + public Exception Error { get; } + + /// Classifies an exception produced by a public HSMS operation. + /// The exception to classify. + /// The connection state observed by the caller. + /// + /// A structured diagnostic, or for caller cancellation, + /// disposal, argument errors, invalid lifecycle use, or unknown exceptions. + /// + public static HsmsDiagnostic? Classify( + Exception error, + HsmsSessionState state) + { + if (error is null) + throw new ArgumentNullException(nameof(error)); + + if (error is OperationCanceledException || + error is ObjectDisposedException || + error is ArgumentException || + error is InvalidOperationException) + { + return null; + } + + return ClassifyTimeout(error, state) ?? + ClassifyRejection(error, state) ?? + ClassifyFailure(error, state); + } + + private static HsmsDiagnostic? ClassifyTimeout( + Exception error, + HsmsSessionState state) + => error switch + { + HsmsDataTransactionTimeoutException timeout => new HsmsDiagnostic( + HsmsDiagnosticCode.T3Timeout, + HsmsDiagnosticLayer.Transaction, + HsmsOperation.WaitForSecondary, + state, + timeout, + HsmsTimer.T3, + timeout.Primary.SessionId, + timeout.Primary.SystemBytes), + HsmsSessionTimeoutException timeout => new HsmsDiagnostic( + timeout.Timer == HsmsTimer.T6 + ? HsmsDiagnosticCode.T6Timeout + : HsmsDiagnosticCode.T7Timeout, + HsmsDiagnosticLayer.Session, + timeout.Operation, + state, + timeout, + timeout.Timer), + HsmsT8TimeoutException timeout => new HsmsDiagnostic( + HsmsDiagnosticCode.T8Timeout, + HsmsDiagnosticLayer.Transport, + HsmsOperation.ReceiveFrame, + state, + timeout, + HsmsTimer.T8), + _ => null, + }; + + private static HsmsDiagnostic? ClassifyRejection( + Exception error, + HsmsSessionState state) + => error switch + { + HsmsSelectionRejectedException rejected => new HsmsDiagnostic( + HsmsDiagnosticCode.SelectionRejected, + HsmsDiagnosticLayer.Session, + HsmsOperation.Select, + state, + rejected, + peerStatus: (byte)rejected.Status), + HsmsDeselectRejectedException rejected => new HsmsDiagnostic( + HsmsDiagnosticCode.DeselectRejected, + HsmsDiagnosticLayer.Session, + HsmsOperation.Deselect, + state, + rejected, + peerStatus: (byte)rejected.Status), + HsmsControlRejectedException rejected => new HsmsDiagnostic( + HsmsDiagnosticCode.ControlRejected, + HsmsDiagnosticLayer.Session, + GetControlOperation(rejected.RejectedMessageType), + state, + rejected, + peerStatus: (byte)rejected.Reason, + rejectedMessageType: rejected.RejectedMessageType), + HsmsDataMessageRejectedException rejected => new HsmsDiagnostic( + HsmsDiagnosticCode.DataMessageRejected, + HsmsDiagnosticLayer.Transaction, + HsmsOperation.SendData, + state, + rejected, + peerStatus: (byte)rejected.Reason, + rejectedMessageType: (byte)HsmsMessageType.DataMessage), + _ => null, + }; + + private static HsmsDiagnostic? ClassifyFailure( + Exception error, + HsmsSessionState state) + => error switch + { + HsmsDataTransactionInterruptedException interrupted => new HsmsDiagnostic( + HsmsDiagnosticCode.TransactionInterrupted, + HsmsDiagnosticLayer.Transaction, + HsmsOperation.SendData, + interrupted.State, + interrupted), + HsmsControlTransactionInterruptedException interrupted => new HsmsDiagnostic( + HsmsDiagnosticCode.TransactionInterrupted, + HsmsDiagnosticLayer.Session, + interrupted.Operation, + interrupted.State, + interrupted), + HsmsTransportSessionExpiredException expired => new HsmsDiagnostic( + HsmsDiagnosticCode.TransportSessionExpired, + HsmsDiagnosticLayer.Transport, + HsmsOperation.None, + state, + expired), + HsmsProtocolException protocol => new HsmsDiagnostic( + HsmsDiagnosticCode.ProtocolViolation, + HsmsDiagnosticLayer.Session, + HsmsOperation.ReceiveFrame, + state, + protocol), + SecsProtocolException codec => new HsmsDiagnostic( + HsmsDiagnosticCode.CodecFailure, + HsmsDiagnosticLayer.Codec, + HsmsOperation.None, + state, + codec), + IOException transport => new HsmsDiagnostic( + HsmsDiagnosticCode.TransportFailure, + HsmsDiagnosticLayer.Transport, + HsmsOperation.Connect, + state, + transport), + _ => null, + }; + + internal static HsmsDiagnostic DataMessageDecodeFailed( + HsmsFrame frame, + Exception error) + => new( + HsmsDiagnosticCode.DataMessageDecodeFailed, + HsmsDiagnosticLayer.Codec, + HsmsOperation.DecodeData, + HsmsSessionState.Selected, + error, + protocolSessionId: frame.Header.SessionId, + systemBytes: frame.Header.SystemBytes, + frame: frame); + + private static HsmsOperation GetControlOperation(byte messageType) + => (HsmsMessageType)messageType switch + { + HsmsMessageType.SelectRequest => HsmsOperation.Select, + HsmsMessageType.LinktestRequest => HsmsOperation.Linktest, + HsmsMessageType.DeselectRequest => HsmsOperation.Deselect, + HsmsMessageType.SeparateRequest => HsmsOperation.Separate, + _ => HsmsOperation.None, + }; +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsDiagnosticCode.cs b/src/SecsFrame/Hsms/Connection/HsmsDiagnosticCode.cs new file mode 100644 index 0000000..b25f6aa --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsDiagnosticCode.cs @@ -0,0 +1,47 @@ +namespace SecsFrame; + +/// Identifies a stable, machine-readable HSMS diagnostic. +public enum HsmsDiagnosticCode +{ + /// The transport reported an I/O failure. + TransportFailure, + + /// An operation was bound to a transport session that ended. + TransportSessionExpired, + + /// An incoming HSMS header or control message violated protocol rules. + ProtocolViolation, + + /// A SECS-II message could not be encoded or decoded in the current operation. + CodecFailure, + + /// T3 expired while waiting for a Secondary. + T3Timeout, + + /// T6 expired while waiting for an HSMS control response. + T6Timeout, + + /// T7 expired before the HSMS session became Selected. + T7Timeout, + + /// T8 expired while an incomplete HSMS frame was being received. + T8Timeout, + + /// The peer rejected HSMS selection. + SelectionRejected, + + /// The peer rejected HSMS deselection. + DeselectRejected, + + /// The peer sent Reject Request for an HSMS control message. + ControlRejected, + + /// The peer sent Reject Request for an HSMS data message. + DataMessageRejected, + + /// An open data transaction ended because its HSMS session changed. + TransactionInterrupted, + + /// An incoming HSMS data message could not be decoded. + DataMessageDecodeFailed, +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsDiagnosticLayer.cs b/src/SecsFrame/Hsms/Connection/HsmsDiagnosticLayer.cs new file mode 100644 index 0000000..c66a543 --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsDiagnosticLayer.cs @@ -0,0 +1,17 @@ +namespace SecsFrame; + +/// Identifies the SecsFrame layer that produced an HSMS diagnostic. +public enum HsmsDiagnosticLayer +{ + /// The TCP transport or transport-session adapter. + Transport, + + /// The HSMS selection and control-session state machine. + Session, + + /// The HSMS data transaction manager. + Transaction, + + /// The HSMS or SECS-II message codec. + Codec, +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsOperation.cs b/src/SecsFrame/Hsms/Connection/HsmsOperation.cs new file mode 100644 index 0000000..5ec3a74 --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsOperation.cs @@ -0,0 +1,35 @@ +namespace SecsFrame; + +/// Identifies the operation associated with an HSMS diagnostic. +public enum HsmsOperation +{ + /// No more specific operation is available. + None, + + /// Opening or maintaining the TCP transport. + Connect, + + /// Selecting the HSMS session. + Select, + + /// Running an HSMS Linktest transaction. + Linktest, + + /// Deselecting the HSMS session. + Deselect, + + /// Separating the HSMS session. + Separate, + + /// Sending an HSMS data message. + SendData, + + /// Waiting for a Secondary data message. + WaitForSecondary, + + /// Receiving an HSMS frame. + ReceiveFrame, + + /// Decoding an HSMS data message and its SECS-II Item. + DecodeData, +} diff --git a/src/SecsFrame/Hsms/Connection/HsmsTimer.cs b/src/SecsFrame/Hsms/Connection/HsmsTimer.cs new file mode 100644 index 0000000..519bac1 --- /dev/null +++ b/src/SecsFrame/Hsms/Connection/HsmsTimer.cs @@ -0,0 +1,20 @@ +namespace SecsFrame; + +/// Identifies an HSMS timer associated with a diagnostic. +public enum HsmsTimer +{ + /// Reply transaction timeout. + T3, + + /// Active connection retry delay. + T5, + + /// Control transaction timeout. + T6, + + /// Selection timeout after TCP connection. + T7, + + /// Incomplete frame receive timeout. + T8, +} diff --git a/src/SecsFrame/Hsms/Session/HsmsControlTransactionInterruptedException.cs b/src/SecsFrame/Hsms/Session/HsmsControlTransactionInterruptedException.cs new file mode 100644 index 0000000..ddc3a03 --- /dev/null +++ b/src/SecsFrame/Hsms/Session/HsmsControlTransactionInterruptedException.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace SecsFrame; + +internal sealed class HsmsControlTransactionInterruptedException : IOException +{ + public HsmsControlTransactionInterruptedException( + HsmsOperation operation, + HsmsSessionState state) + : base( + $"The HSMS {operation} control transaction was interrupted " + + $"because the session entered {state}.") + { + Operation = operation; + State = state; + } + + public HsmsOperation Operation { get; } + + public HsmsSessionState State { get; } +} diff --git a/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs b/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs index a6b8a5c..9a1155d 100644 --- a/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs +++ b/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs @@ -711,7 +711,11 @@ private void ProcessT6Expired(int generation) return; } - AbortCurrentSession(new HsmsSessionTimeoutException("T6")); + var operation = _pendingSelectSystemBytes is not null + ? HsmsOperation.Select + : GetControlOperation(_pendingControlCommand!.RequestType); + AbortCurrentSession( + new HsmsSessionTimeoutException(HsmsTimer.T6, operation)); } private void ProcessT7Expired(int generation) @@ -723,7 +727,10 @@ private void ProcessT7Expired(int generation) return; } - AbortCurrentSession(new HsmsSessionTimeoutException("T7")); + AbortCurrentSession( + new HsmsSessionTimeoutException( + HsmsTimer.T7, + HsmsOperation.Select)); } private void ProcessSeparateRequested( @@ -957,8 +964,9 @@ private void EnterConnectedAfterDeselect() else { pending.Completion.TrySetException( - new IOException( - $"{pending.RequestType} was interrupted because the HSMS session was deselected.")); + new HsmsControlTransactionInterruptedException( + GetControlOperation(pending.RequestType), + HsmsSessionState.Connected)); } } } @@ -990,6 +998,17 @@ private static HsmsMessageType GetRequestType(SendPurpose purpose) "The send purpose is not a control request."), }; + private static HsmsOperation GetControlOperation( + HsmsMessageType requestType) + => requestType switch + { + HsmsMessageType.SelectRequest => HsmsOperation.Select, + HsmsMessageType.LinktestRequest => HsmsOperation.Linktest, + HsmsMessageType.DeselectRequest => HsmsOperation.Deselect, + HsmsMessageType.SeparateRequest => HsmsOperation.Separate, + _ => HsmsOperation.None, + }; + private void AbortCurrentSession(Exception error) { if (_sessionId.IsValid) diff --git a/src/SecsFrame/Hsms/Session/HsmsSessionTimeoutException.cs b/src/SecsFrame/Hsms/Session/HsmsSessionTimeoutException.cs index 67c256f..b286337 100644 --- a/src/SecsFrame/Hsms/Session/HsmsSessionTimeoutException.cs +++ b/src/SecsFrame/Hsms/Session/HsmsSessionTimeoutException.cs @@ -2,11 +2,26 @@ namespace SecsFrame; internal sealed class HsmsSessionTimeoutException : TimeoutException { - public HsmsSessionTimeoutException(string timerName) - : base($"The HSMS {timerName} timer expired.") + public HsmsSessionTimeoutException( + HsmsTimer timer, + HsmsOperation operation) + : base($"The HSMS {timer} timer expired.") { - TimerName = timerName; + if (timer != HsmsTimer.T6 && timer != HsmsTimer.T7) + { + throw new ArgumentOutOfRangeException( + nameof(timer), + timer, + "The session timer must be T6 or T7."); + } + + Timer = timer; + Operation = operation; } - public string TimerName { get; } + public HsmsTimer Timer { get; } + + public HsmsOperation Operation { get; } + + public string TimerName => Timer.ToString(); } diff --git a/test/SecsFrame.Tests/HsmsConnectionTests.cs b/test/SecsFrame.Tests/HsmsConnectionTests.cs index 655392c..caad07e 100644 --- a/test/SecsFrame.Tests/HsmsConnectionTests.cs +++ b/test/SecsFrame.Tests/HsmsConnectionTests.cs @@ -200,6 +200,7 @@ private static async Task AssertSelectedEventAsync( Assert.Null(selected.IncomingMessage); Assert.Null(selected.Frame); Assert.Null(selected.Error); + Assert.Null(selected.Diagnostic); Assert.Equal(HsmsSessionState.Selected, passive.State); Assert.Equal(HsmsSessionState.Selected, active.State); } diff --git a/test/SecsFrame.Tests/HsmsDiagnosticTests.cs b/test/SecsFrame.Tests/HsmsDiagnosticTests.cs new file mode 100644 index 0000000..90d5583 --- /dev/null +++ b/test/SecsFrame.Tests/HsmsDiagnosticTests.cs @@ -0,0 +1,159 @@ +namespace SecsFrame.Tests; + +public sealed class HsmsDiagnosticTests +{ + [Fact] + public void T3_diagnostic_preserves_transaction_identity() + { + var primary = new HsmsDataMessage( + sessionId: 10, + systemBytes: 0x10203040, + new SecsMessage(6, 11, true)); + var error = new HsmsDataTransactionTimeoutException(primary); + + var diagnostic = HsmsDiagnostic.Classify( + error, + HsmsSessionState.Selected)!; + + Assert.Equal(HsmsDiagnosticCode.T3Timeout, diagnostic.Code); + Assert.Equal(HsmsDiagnosticLayer.Transaction, diagnostic.Layer); + Assert.Equal(HsmsOperation.WaitForSecondary, diagnostic.Operation); + Assert.Equal(HsmsTimer.T3, diagnostic.Timer); + Assert.Equal((ushort)10, diagnostic.ProtocolSessionId); + Assert.Equal(0x10203040U, diagnostic.SystemBytes); + Assert.Same(error, diagnostic.Error); + Assert.Null(diagnostic.Frame); + } + + [Fact] + public void Session_and_transport_timeouts_keep_timer_and_operation() + { + var t6 = HsmsDiagnostic.Classify( + new HsmsSessionTimeoutException( + HsmsTimer.T6, + HsmsOperation.Linktest), + HsmsSessionState.Disconnected)!; + var t7 = HsmsDiagnostic.Classify( + new HsmsSessionTimeoutException( + HsmsTimer.T7, + HsmsOperation.Select), + HsmsSessionState.Disconnected)!; + var t8 = HsmsDiagnostic.Classify( + new HsmsT8TimeoutException(new HsmsTransportSessionId(7)), + HsmsSessionState.Disconnected)!; + + Assert.Equal(HsmsDiagnosticCode.T6Timeout, t6.Code); + Assert.Equal(HsmsOperation.Linktest, t6.Operation); + Assert.Equal(HsmsTimer.T6, t6.Timer); + Assert.Equal(HsmsDiagnosticCode.T7Timeout, t7.Code); + Assert.Equal(HsmsOperation.Select, t7.Operation); + Assert.Equal(HsmsTimer.T7, t7.Timer); + Assert.Equal(HsmsDiagnosticCode.T8Timeout, t8.Code); + Assert.Equal(HsmsDiagnosticLayer.Transport, t8.Layer); + Assert.Equal(HsmsOperation.ReceiveFrame, t8.Operation); + Assert.Equal(HsmsTimer.T8, t8.Timer); + } + + [Fact] + public void Peer_rejection_preserves_operation_and_status_bytes() + { + var error = new HsmsControlRejectedException( + (byte)HsmsMessageType.DeselectRequest, + HsmsRejectReason.TransactionNotOpen); + + var diagnostic = HsmsDiagnostic.Classify( + error, + HsmsSessionState.Selected)!; + + Assert.Equal(HsmsDiagnosticCode.ControlRejected, diagnostic.Code); + Assert.Equal(HsmsDiagnosticLayer.Session, diagnostic.Layer); + Assert.Equal(HsmsOperation.Deselect, diagnostic.Operation); + Assert.Equal((byte)HsmsRejectReason.TransactionNotOpen, diagnostic.PeerStatus); + Assert.Equal((byte)HsmsMessageType.DeselectRequest, diagnostic.RejectedMessageType); + } + + [Fact] + public void Control_transaction_interruption_is_not_misclassified_as_transport() + { + var error = new HsmsControlTransactionInterruptedException( + HsmsOperation.Linktest, + HsmsSessionState.Connected); + + var diagnostic = HsmsDiagnostic.Classify( + error, + HsmsSessionState.Connected)!; + + Assert.Equal(HsmsDiagnosticCode.TransactionInterrupted, diagnostic.Code); + Assert.Equal(HsmsDiagnosticLayer.Session, diagnostic.Layer); + Assert.Equal(HsmsOperation.Linktest, diagnostic.Operation); + Assert.Equal(HsmsSessionState.Connected, diagnostic.State); + Assert.Same(error, diagnostic.Error); + } + + [Fact] + public void Decode_event_exposes_diagnostic_without_replacing_error_or_frame() + { + var frame = new HsmsFrame( + HsmsMessageHeader.CreateData( + sessionId: 23, + stream: 1, + function: 2, + replyExpected: false, + systemBytes: 0xAABBCCDD), + new byte[] { 0xFF }); + var error = new SecsProtocolException("Invalid Item header."); + + var connectionEvent = HsmsConnectionEvent.DataMessageDecodeFailed( + frame, + error); + var diagnostic = connectionEvent.Diagnostic!; + + Assert.Equal(HsmsConnectionEventKind.DataMessageDecodeFailed, connectionEvent.Kind); + Assert.Equal(HsmsDiagnosticCode.DataMessageDecodeFailed, diagnostic.Code); + Assert.Equal(HsmsDiagnosticLayer.Codec, diagnostic.Layer); + Assert.Equal(HsmsOperation.DecodeData, diagnostic.Operation); + Assert.Equal((ushort)23, diagnostic.ProtocolSessionId); + Assert.Equal(0xAABBCCDDU, diagnostic.SystemBytes); + Assert.Same(frame, diagnostic.Frame); + Assert.Same(frame, connectionEvent.Frame); + Assert.Same(error, diagnostic.Error); + Assert.Same(error, connectionEvent.Error); + } + + [Fact] + public void State_event_classifies_failure_and_normal_state_has_no_diagnostic() + { + var error = new IOException("Connection reset."); + + var failed = HsmsConnectionEvent.StateChanged( + HsmsSessionState.Disconnected, + error); + var selected = HsmsConnectionEvent.StateChanged( + HsmsSessionState.Selected, + error: null); + + Assert.Equal(HsmsDiagnosticCode.TransportFailure, failed.Diagnostic!.Code); + Assert.Equal(HsmsDiagnosticLayer.Transport, failed.Diagnostic.Layer); + Assert.Equal(HsmsOperation.Connect, failed.Diagnostic.Operation); + Assert.Same(error, failed.Diagnostic.Error); + Assert.Null(selected.Diagnostic); + Assert.Null(selected.Error); + } + + [Fact] + public void Caller_control_flow_and_unknown_errors_are_not_diagnostics() + { + Assert.Null(HsmsDiagnostic.Classify( + new OperationCanceledException(), + HsmsSessionState.Selected)); + Assert.Null(HsmsDiagnostic.Classify( + new ObjectDisposedException("connection"), + HsmsSessionState.Disconnected)); + Assert.Null(HsmsDiagnostic.Classify( + new InvalidOperationException("Not started."), + HsmsSessionState.Disconnected)); + Assert.Null(HsmsDiagnostic.Classify( + new Exception("Unknown."), + HsmsSessionState.Selected)); + } +} diff --git a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.ControlPlane.cs b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.ControlPlane.cs index 84f1c7a..0a8e730 100644 --- a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.ControlPlane.cs +++ b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.ControlPlane.cs @@ -64,6 +64,8 @@ public async Task Linktest_T6_expiry_closes_session_and_fails_command() async () => await linktest.ConfigureAwait(true)).ConfigureAwait(true); Assert.Equal("T6", error.TimerName); + Assert.Equal(HsmsTimer.T6, error.Timer); + Assert.Equal(HsmsOperation.Linktest, error.Operation); Assert.Equal(HsmsSessionState.Disconnected, machine.State); } @@ -262,9 +264,12 @@ public async Task Peer_deselect_interrupts_open_linktest_and_cancels_its_T6() 81))); await WaitUntilAsync(() => transport.SendCount == 3).ConfigureAwait(true); transport.CompleteSend(2); - await Assert.ThrowsAsync( + var interrupted = await Assert.ThrowsAsync< + HsmsControlTransactionInterruptedException>( async () => await linktest.ConfigureAwait(true)).ConfigureAwait(true); + Assert.Equal(HsmsOperation.Linktest, interrupted.Operation); + Assert.Equal(HsmsSessionState.Connected, interrupted.State); Assert.Equal(HsmsSessionState.Connected, machine.State); Assert.False(timers.Timers[1].IsArmed); Assert.Equal(T7, timers.Timers[^1].DueTime); diff --git a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs index 3d447e5..6836d30 100644 --- a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs +++ b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs @@ -75,6 +75,8 @@ public async Task T6_starts_after_select_request_write_confirmation() var t7Error = Assert.IsType(transport.LastCloseError); Assert.Equal("T7", t7Error.TimerName); + Assert.Equal(HsmsTimer.T7, t7Error.Timer); + Assert.Equal(HsmsOperation.Select, t7Error.Operation); Assert.Single(timers.Timers); }