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);
}