diff --git a/CHANGELOG.md b/CHANGELOG.md index 1098a1e..9dd4f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ### 修复 +- 升级官方 StreamFrame 2.6.0,使用 FrameError 自带的来源 + Session ID 关联四种成帧故障,并保留实际观测字节数与截断状态;迟到旧 + 会话故障不再依赖回调时的当前会话推断。 +- SecsFrame 对四种故障统一只复制最多 8 KiB 前缀,避免重同步丢弃样本形成 + 无界诊断数据,同时保留上游实际字节数供完整性判断。 - 升级官方 StreamFrame 2.5.0,采用自适应发送编码缓冲,并获得 Passive 接受循环代次门控,避免显式关闭与自动重连竞速泄漏监听器。 - 增加真实 TCP Passive 重连竞态测试,连续验证监听恢复、Session ID 单调 @@ -24,14 +29,17 @@ - 增加真实 TCP Active/Passive 重复会话抖动故障注入测试;固定三轮断开与 重选并复用相同 System Bytes,验证待处理事务确定中断、旧回复与强制迟到 T3 回调不污染替换会话,且新事务可成功完成。 -- 增加默认关闭的独立 T8 transport fault 观测流,保留 StreamFrame TCP - Session ID、actor 状态与最多 8 KiB 的未完成帧前缀快照。 -- T8 观测队列容量显式可配且满时丢弃最旧项,不阻塞协议 actor;默认路径 +- 增加默认关闭的独立 transport fault 观测流,覆盖 DecodeFailed、 + DiscardedByResync、IncompleteFrameOverflow 与 IncompleteFrameTimeout, + 保留 StreamFrame TCP Session ID、actor 状态、实际字节数、截断状态与最多 + 8 KiB 的前缀快照。 +- 故障观测队列容量显式可配且满时丢弃最旧项,不阻塞协议 actor;默认路径 不创建该通道,旧会话观测不会归入替换会话。 -- 增加独立 SecsFrame-TransportFaultTrace/1 信封,沿用 - MetadataOnly、RedactedPayload 与 RawPayload 分级,payload codec 默认关闭。 -- 增加真实 TCP T8、Session 过滤、队列容量、黄金向量、脱敏复核、防御性 - 复制和资源限制测试;快照不声明完整 TCP 片段且不能进入重放路径。 +- 将独立信封升级为 SecsFrame-TransportFaultTrace/2,新增实际 + 字节数和 Complete/Truncated 字段,沿用 MetadataOnly、RedactedPayload + 与 RawPayload 分级;严格 codec 不读取 v1,也不会推断旧记录完整性。 +- 增加四种故障、真实 TCP T8、跨 Session 过滤、8192/8193 字节边界、队列 + 容量、黄金向量、脱敏复核、防御性复制和资源限制测试;快照不进入重放路径。 - 增加独立 SecsFrame-FaultSampleTrace/1 信封,为公共 DataMessageDecodeFailed 事件提供已成帧 HSMS data body 样本。 - 捕获必须显式选择 MetadataOnly、RedactedPayload 或 RawPayload;默认 codec diff --git a/Directory.Packages.props b/Directory.Packages.props index e857de2..daf77a0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + diff --git a/README.md b/README.md index cd992ce..8b45b3d 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报 显式启用的完整控制消息观测流或既有未认领控制事件的十字节头元数据。 解码失败样本另用独立信封,并以元数据为默认导出边界;Body 捕获与 codec 导入导出都必须显式开启,声明的脱敏字节范围会固定清零并严格复核。 -T8 互操作诊断另有默认关闭、容量有界的独立观测流;它保留 transport -Session ID 和最多 8 KiB 的未完成帧前缀快照。Trace 默认仍只导出元数据, -快照捕获和 payload codec 必须分别显式开启。控制与 T8 观测都和业务事件流 -独立;该包不会自行接管任何连接消费者,也不会复用原事务标识。 +运输层互操作诊断另有默认关闭、容量有界的独立观测流;它覆盖解码失败、 +重同步丢弃、未完成帧溢出和 T8 超时,保留错误来源 transport Session ID、 +实际观测字节数、截断状态和最多 8 KiB 的前缀快照。Trace 默认仍只导出 +元数据,快照捕获和 payload codec 必须分别显式开启。控制与运输故障观测 +都和业务事件流独立;该包不会自行接管任何连接消费者,也不会复用原事务 +标识。 Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ff6e99..9209e3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,9 +25,9 @@ Trace 的时序重放必须显式启用,只在允许发送记录之间等待 该流并创建记录,不自行订阅连接。既有公共未认领控制事件工厂继续可用。 已成帧但 SECS-II Body 解码失败的样本使用独立信封:捕获和 codec 分别要求 显式数据边界,默认只允许头与原 Body 长度;payload-bearing 记录不能进入 -重放。T8 使用另一个独立信封:核心必须先显式启用容量有界的观测流,Trace -再选择元数据、脱敏或原始前缀快照;快照最多 8 KiB 且不声明完整 TCP 片段。 -其他成帧错误仍未进入该模型。 +重放。运输成帧故障使用另一个独立信封:核心必须先显式启用容量有界的观测 +流,Trace 再选择元数据、脱敏或原始前缀快照;四种错误均保留来源 Session、 +实际字节数和截断状态,快照统一最多 8 KiB。 ## 核心原则 @@ -70,7 +70,7 @@ HSMS 会话切换后,旧会话的控制请求、数据事务和等待中的应 IHsmsTransport 使用单一事件流按 Session ID 报告会话打开、 帧到达和会话关闭。StreamFrameHsmsTransport 已在内部实现: -- 直接采用 StreamFrame 2.5.0 的单调 TCP Session ID,并在 Connected +- 直接采用 StreamFrame 2.6.0 的单调 TCP Session ID,并在 Connected 发布点同步分配会话 epoch; - 通过会话感知接收流保留帧的原始 Session ID,迟到消费不会被标成新会话; - 会话绑定发送在 StreamFrame 原生 FIFO 中校验 Session ID,旧会话消息 @@ -80,13 +80,14 @@ HSMS 会话切换后,旧会话的控制请求、数据事务和等待中的应 - Active 连接把显式 T5 映射为固定 StreamFrame 重试间隔,关闭指数退避; - 显式 T8 映射为 StreamFrame 原生未完成帧超时,并把对应 FrameError 转换为带 transport Session ID 的 HSMS 关闭原因;显式启用时还在同一 - transport 事件序列中复制有界前缀快照。 + transport 事件序列中发布四种成帧故障的有界前缀和完整性元数据。 这些能力仍隔离在 IHsmsTransport 后,不进入公共 API。此前 跟踪的 StreamFrame #38/#39 已在 2.3.0 完成迁移,2.3.1 进一步封闭迟到 旧故障污染活会话和排队消息跨 Socket 错发的竞态,2.4.0 又收口发布窗口 旧 epoch 故障并加固 Passive 监听恢复,2.5.0 进一步以接受循环代次门控 -阻止重连竞速泄漏旧监听器,并加入不改变编码契约的自适应发送缓冲; +阻止重连竞速泄漏旧监听器,并加入不改变编码契约的自适应发送缓冲;2.6.0 +再为四种 FrameError 提供统一的来源 Session 与快照完整性元数据; SecsFrame 只保留协议关闭原因和异常边界转换。详细失效模式与验证证据见 [STREAMFRAME-ADAPTER.md](STREAMFRAME-ADAPTER.md)。 @@ -141,11 +142,11 @@ Selected 转发数据消息;提前到达的数据、可识别的不支持类 通道默认不创建,记录不含 Body、原始字节、transport Session generation 或时间戳;它是诊断元数据边界,不参与协议决策。 -可选 T8 观测通道同样由 actor 写入,但使用容量有界、满时丢弃最旧项的 +可选运输故障观测通道同样由 actor 写入,但使用容量有界、满时丢弃最旧项的 独立队列,避免未消费的原始快照形成无界内存或阻塞协议处理。状态机只接受 当前 transport Session ID 的 fault;观测保留最多 8 KiB 前缀,不参与关闭 -决策。StreamFrame 2.5.0 的其他 FrameError 不带 session/epoch,核心不会 -用读取“当前 ID”的方式把它们冒充为严格会话关联事件。 +决策。四种故障都使用 StreamFrame 2.6.0 事件自带的来源 Session、实际字节 +数和截断状态;只有 IncompleteFrameTimeout 参与 T8 关闭原因决策。 ### 会话绑定的数据事务 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 8bbbe2e..4961d7d 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -22,9 +22,10 @@ StreamFrame #38(未完成帧超时)与 #39(会话感知发送确认及消息上下文) 已经随官方 StreamFrame 2.3.0 发布,2.3.1 随后补充迟到旧会话故障隔离、 排队发送 Session ID 复核和稳定失效异常修复。SecsFrame 当前固定官方 -2.5.0 NuGet 包;2.4.0 收口 Connected 发布点的会话 epoch,并加固 Passive +2.6.0 NuGet 包;2.4.0 收口 Connected 发布点的会话 epoch,并加固 Passive 接受重试与监听端口重绑;2.5.0 再以接受循环代次门控阻止显式关闭/自动重连 -竞速泄漏旧监听器,并采用不改变编码契约的自适应发送缓冲。内部 +竞速泄漏旧监听器,并采用不改变编码契约的自适应发送缓冲;2.6.0 为四种 +FrameError 统一补充来源 Session、实际字节数和截断状态。内部 StreamFrameHsmsTransport 使用原生会话 API;此前基于 原始字节回调、发送信封和自建 Session ID 的临时实现已经删除。 diff --git a/docs/DEVELOPMENT-STATUS.md b/docs/DEVELOPMENT-STATUS.md index 3a218b7..9243744 100644 --- a/docs/DEVELOPMENT-STATUS.md +++ b/docs/DEVELOPMENT-STATUS.md @@ -20,22 +20,24 @@ GitHub Issue/Actions: ## 当前基线 -截至 2026-08-29,功能基线为 `main` 的 PR #40(提交 `40743c6`): +截至 2026-08-30,本切片基于 `main` 的 PR #42(提交 `64713b7`),升级后 +基线为: -- StreamFrame 固定为官方 NuGet `2.5.0`。 +- StreamFrame 固定为官方 NuGet `2.6.0`。 - secs4net 互操作测试固定为 nuget.org 官方 `3.1.0`,不得改用源码引用或 非官方包。 - SML 调试读写、已解码消息 Trace 导出、结构化脱敏、受控及时序重放、 - 诊断/控制元数据 Trace、解码失败样本和 T8 前缀快照均已落地。 -- T8 故障观测默认关闭、队列有界,最多保存 StreamFrame 提供的 8 KiB - 前缀;它不是完整 TCP 片段,也不能进入消息重放路径。 + 诊断/控制元数据 Trace、解码失败样本和运输故障快照均已落地。 +- 运输故障观测覆盖 StreamFrame 的四种 FrameErrorKind,默认 + 关闭、队列有界,保留来源 Session、实际字节数和截断状态,并由 SecsFrame + 统一只保存最多 8 KiB 前缀;它不能进入消息重放路径。 +- SecsFrame-TransportFaultTrace/2 显式保存实际字节数和 + Complete/Truncated;严格 codec 拒绝 v1,不对旧记录静默推断完整性。 - 真实 TCP Active/Passive 事务测试固定重复三次断开和重选,复用相同 System Bytes 并强制投递旧 T3 回调,验证旧回复、旧计时器和待处理事务 不会污染替换会话。 -- SecsFrame 当前没有开放 Issue。当前 `main` 提交 `8fc4599`(PR #41)的 - `ci` 与 `codeql` 均通过。 - 最近一次完整本地验证中,核心测试在 `net48`、`net8.0`、`net10.0` 各 - 通过 274 项;官方 secs4net 互操作测试在 `net8.0`、`net10.0` 各通过 + 通过 289 项;官方 secs4net 互操作测试在 `net8.0`、`net10.0` 各通过 2 项。 所有新提交仍须重新运行: @@ -45,33 +47,21 @@ dotnet build SecsFrame.slnx -c Release dotnet test SecsFrame.slnx -c Release --no-build ``` -## 当前阻塞 - -### StreamFrame 错误归属 - -[StreamFrame #56](https://github.com/CSJ608/StreamFrame/issues/56) 已由 -[PR #57](https://github.com/CSJ608/StreamFrame/pull/57) 实现并关闭,但截至 -2026-08-29,最新 GitHub Release 与 nuget.org 官方包仍为 `2.5.0`。上游 -计划在 StreamFrame `2.6.0` 提供: +## 已解除的上游依赖 -- `FrameError` 的原始 transport Session ID; -- 错误发生时的实际观测字节数; -- `Bytes` 快照是否截断; -- 四种 `FrameErrorKind` 一致的来源会话语义,并保留现有兼容性。 +[StreamFrame #56](https://github.com/CSJ608/StreamFrame/issues/56) 已随官方 +[2.6.0 发布](https://github.com/CSJ608/StreamFrame/releases/tag/v2.6.0)。 +SecsFrame 已核对 Release、NuGet 包和实际 API,并完成以下集成: -上游发布前,不扩展 `DecodeFailed`、`IncompleteFrameOverflow` 与 -`DiscardedByResync` 的公共运输故障观测。不得用回调时读取的 -`CurrentSessionId` 冒充错误的原始会话归属。 +- 四种 FrameErrorKind 均使用事件自带的原始 transport + Session ID,迟到旧会话错误不会归入替换会话; +- 公共观测与 Trace 保留 ObservedByteCount 和 + IsTruncated,SecsFrame 对所有类型再统一封顶 8 KiB; +- Trace 严格格式升级到 v2,v1 明确拒绝而不是推断缺失字段; +- 四种错误、跨会话、8192/8193 字节、真实 TCP T8、脱敏和严格读取均有 + 自动化证据。 -上游发布后,先核对 Release、NuGet 包和真实 API,再安排一个独立升级切片: - -1. 升级 StreamFrame,并确认旧会话迟到错误仍携带旧 Session ID。 -2. T8 改用事件自带的来源会话,并扩展其余三种错误类型。 -3. 在观测和 Trace 中忠实保留实际字节数与截断状态;需要时升级 Trace - 信封版本,不静默改变现有严格格式。 -4. 增加四种错误、跨会话、8 KiB 上下边界、脱敏和严格读取测试。 -5. 更新 README、路线图、专题文档与 `CHANGELOG.md`,完成完整验证并通过 - Pull Request 交付。 +## 当前阻塞 ### 授权标准材料 @@ -101,8 +91,6 @@ Primary/Secondary、断开和重选,并验证: ## 状态更新规则 -- StreamFrame #56 发布后,将其从“当前阻塞”移入完成基线,并记录实际采用的 - StreamFrame 版本和 SecsFrame PR。 - 每个改变路线图优先级或外部阻塞的 PR 都应同步更新本文。 - 已完成能力的长期说明归入对应专题文档;本文只保留继续开发所需的状态, 避免演变成提交日志或聊天记录。 diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md index 9fb2e3e..8dae7ad 100644 --- a/docs/HSMS-CONNECTION.md +++ b/docs/HSMS-CONNECTION.md @@ -16,7 +16,7 @@ StreamFrame 运输适配、HSMS-SS 会话 actor 和数据事务 actor,调用 - Linktest、Deselect 和 Separate 控制命令; - 状态、数据、未消费控制消息和解码失败的单消费者异步事件流; - 默认关闭、与业务事件独立的完整控制消息头元数据观测流; -- 默认关闭、容量有界的 T8 transport Session 前缀快照观测流。 +- 默认关闭、容量有界的四类运输故障前缀快照观测流。 Host/Equipment 是后续业务能力角色,不由 Active/Passive 推断。 需要按运行期 Stream/Function 处理 Primary 时,可在本事件流上组合 @@ -38,8 +38,8 @@ Host/Equipment 是后续业务能力角色,不由 Active/Passive 推断。 | T7 | TCP 建立后等待进入 Selected | | T8 | 未完成消息接收进展等待 | | EnableControlMessageObservation | 是否创建完整控制消息元数据观测流;默认 false | -| EnableTransportFaultObservation | 是否创建 T8 前缀快照观测流;默认 false | -| TransportFaultObservationCapacity | T8 队列容量;满时丢弃最旧项,默认 16 | +| EnableTransportFaultObservation | 是否创建运输故障快照观测流;默认 false | +| TransportFaultObservationCapacity | 故障队列容量;满时丢弃最旧项,默认 16 | 仓库没有内置或暗示标准默认值。T5 必须能无损转换成 StreamFrame 使用的 整毫秒范围;Passive 监听重试不被解释成 T5。每个取值应依据团队合法获得 @@ -126,7 +126,7 @@ await foreach (var observation in connection transport Session generation 或时间戳,也不改变未认领 ControlMessageReceived 业务事件的既有行为。 -T8 原始诊断必须另外显式设置 +运输故障原始诊断必须另外显式设置 EnableTransportFaultObservation。该流不占用业务事件消费者: ~~~csharp @@ -140,13 +140,14 @@ await foreach (var fault in connection } ~~~ -HsmsTransportFaultObservation 保留 -IncompleteFrameTimeout、StreamFrame TCP Session ID、actor -观察状态和防御性复制的前缀快照。快照最多 8 KiB,可能包含四字节 HSMS -长度前缀;StreamFrame 2.5.0 不报告原缓冲总长度或完整性,因此不得把它 -解释成完整 TCP 片段。队列按配置容量保留最新项,满时丢弃最旧项而不阻塞 -协议 actor;应用启用后应持续消费。该观测在对应会话关闭事件处理前写入, -但两个独立异步流之间不承诺消费者可见的全局顺序。 +HsmsTransportFaultObservation 覆盖 DecodeFailed、 +DiscardedByResync、IncompleteFrameOverflow 和 IncompleteFrameTimeout, +保留 StreamFrame 报告的原始 TCP Session ID、actor 观察状态、实际字节数、 +截断状态和防御性复制的前缀快照。SecsFrame 对所有类型统一最多保留 8 KiB; +快照可能为空,也可能包含四字节 HSMS 长度前缀。队列按配置容量保留最新项, +满时丢弃最旧项而不阻塞协议 actor;应用启用后应持续消费。只有 T8 超时会 +影响关闭原因,其余观测不改变连接行为。该观测在对应会话关闭事件处理前 +写入,但两个独立异步流之间不承诺消费者可见的全局顺序。 带错误的状态事件和解码失败事件还会提供可选 HsmsDiagnostic,用于按稳定代码、层级、操作与计时器处理, diff --git a/docs/HSMS-DIAGNOSTICS.md b/docs/HSMS-DIAGNOSTICS.md index 46176ac..c35a360 100644 --- a/docs/HSMS-DIAGNOSTICS.md +++ b/docs/HSMS-DIAGNOSTICS.md @@ -86,12 +86,13 @@ System Bytes 和远端状态字节仍属于运维元数据,导出文件仍应 带 Body 的导入或导出都要再次显式开启。该信封不包含异常、TCP 长度前缀、 分片、transport Session generation 或 T8 未完成片段。 -T8 另有默认关闭的 HsmsTransportFaultObservation 独立流和 -SecsFrame-TransportFaultTrace/1 信封。观测只保留当前 T8 所属 -transport Session ID、actor 状态和 StreamFrame 已复制的最多 8 KiB 前缀; -队列有界且满时丢弃最旧项。Trace 捕获必须再选择仅元数据、声明范围清零或 -原始快照,默认 codec 仍拒绝带快照的记录。它不包含异常、原缓冲总长度、 -完整性标记或分片时序,也不能用于重放。 +运输成帧错误另有默认关闭的 HsmsTransportFaultObservation +独立流和 SecsFrame-TransportFaultTrace/2 信封。观测覆盖四种 +FrameErrorKind,保留错误来源 transport Session ID、actor 状态、 +实际观测字节数、截断状态和最多 8 KiB 前缀;队列有界且满时丢弃最旧项。 +Trace 捕获必须再选择仅元数据、声明范围清零或原始快照,默认 codec 仍拒绝 +带快照的记录。它不包含异常或分片时序,也不能用于重放;v1 因缺少完整性 +元数据而由严格 codec 明确拒绝。 ## 标准边界 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4631fd8..0768343 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,8 +10,8 @@ - [x] 完整 SECS-II Item 类型、嵌套 List 和严格二进制编解码。 - [x] 动态 SecsMessage、可选单根 Item 与 HSMS Data Message Payload 编解码。 -- [x] 迁移到 StreamFrame 2.5.0 原生会话绑定、写出确认、会话 epoch/监听 - 竞态修复和未完成帧超时。 +- [x] 迁移到 StreamFrame 2.6.0 原生会话绑定、写出确认、会话 epoch/监听 + 竞态修复、未完成帧超时和四类成帧故障来源元数据。 - [x] Active/Passive 共用状态机的 Select/Select Response、Separate、 T6/T7、System Bytes 关联和会话失效处理。 - [x] Linktest、Reject、Deselect 控制消息与对应 T6 分支。 @@ -28,8 +28,9 @@ 阶段 1 的工程能力已经形成完整基础链路,StreamFrame #38/#39 的正式包 迁移与 2.3.1 会话隔离修复已经完成;2.4.0 又收口发布窗口 epoch 并加固 -Passive 监听。当前固定 2.5.0,并增加接受循环代次门控与自适应发送缓冲的 -本地回归证据。后续切片继续扩展 GEM 通用行为。任何 +Passive 监听。当前固定 2.6.0,并增加接受循环代次门控、自适应发送缓冲、 +四类故障原始 Session 和快照完整性元数据的本地回归证据。后续切片继续 +扩展 GEM 通用行为。任何 标准默认值 和完整合规结论都依赖团队合法获得的 E37/E37.1 及相关 GEM 标准副本与 一致性测试。 @@ -114,8 +115,8 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 - [x] 公共未认领控制消息的受限头元数据 Trace 导出和严格读取。 - [x] 默认关闭、不含 Body/原始字节的完整控制面头元数据观测。 - [x] 显式分级、默认不导出 Body 的数据消息解码失败样本。 -- [x] 默认关闭、有界且显式分级的 T8 前缀快照诊断。 -- 其他成帧失败原始片段等运输层互操作诊断扩展。 +- [x] 默认关闭、有界且显式分级的四类运输故障前缀快照诊断。 +- 其他运输层互操作诊断扩展。 当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认 关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除 @@ -125,13 +126,12 @@ Body、原始线上字节、transport generation 或时间戳。后续故障原 已先覆盖公共事件中忠实保留的完整 data frame:捕获分为 MetadataOnly、 RedactedPayload 与 RawPayload,payload codec 默认关闭,声明脱敏范围必须 全为零且可严格复核。它不包含 TCP 长度前缀或分片,也不能进入重放。 -T8 现通过默认关闭的独立流与当前 transport Session ID 关联;队列容量有界、 -满时丢弃最旧项,StreamFrame 提供的最多 8 KiB 前缀再按 MetadataOnly、 +运输故障现通过默认关闭的独立流与 StreamFrame 2.6.0 报告的原始 transport +Session ID 关联;四种错误都保留实际字节数与截断状态,队列容量有界、满时 +丢弃最旧项。SecsFrame 统一最多保留 8 KiB 前缀,再按 MetadataOnly、 RedactedPayload 或 RawPayload 捕获,payload codec 默认关闭。该前缀可能含 -四字节长度头,但上游不报告原缓冲总长度或完整性,不能解释为完整 TCP 片段, -也不能进入重放。其他 DecodeFailed、IncompleteFrameOverflow 与 -DiscardedByResync 仍需要 StreamFrame 在 FrameError 中提供原始 session/epoch -归属后再扩展,不能用回调时读取当前 Session ID 替代严格关联。 +四字节长度头,不能进入重放;Trace v2 明确记录 Complete/Truncated,并拒绝 +缺少完整性字段的 v1。 E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。 ## 发布门槛 diff --git a/docs/STREAMFRAME-ADAPTER.md b/docs/STREAMFRAME-ADAPTER.md index f946332..1bb5105 100644 --- a/docs/STREAMFRAME-ADAPTER.md +++ b/docs/STREAMFRAME-ADAPTER.md @@ -2,7 +2,7 @@ ## 上游基线 -SecsFrame 固定依赖官方 [StreamFrame 2.5.0](https://www.nuget.org/packages/StreamFrame/2.5.0)。 +SecsFrame 固定依赖官方 [StreamFrame 2.6.0](https://www.nuget.org/packages/StreamFrame/2.6.0)。 2.3.0 已经解决此前跟踪的两项能力缺口: - [#38](https://github.com/CSJ608/StreamFrame/issues/38) / [PR #41](https://github.com/CSJ608/StreamFrame/pull/41): @@ -35,10 +35,17 @@ ID 一样在 Connected 发布点分配,闭合状态已经对外可见但会话 [PR #51](https://github.com/CSJ608/StreamFrame/pull/51) 和 [PR #52](https://github.com/CSJ608/StreamFrame/pull/52)。 +2.6.0 在兼容既有构造函数的同时,为四种 FrameErrorKind 统一 +增加错误来源 SessionId、ObservedByteCount 和 +IsTruncated。SecsFrame 因而不再需要在回调时读取当前会话来 +推断故障归属,并可安全公开迟到旧会话错误。详见 +[2.6.0 发布说明](https://github.com/CSJ608/StreamFrame/releases/tag/v2.6.0) +和 [Issue #56](https://github.com/CSJ608/StreamFrame/issues/56)。 + SecsFrame 不再维护基于原始字节回调的 T8 监视器、发送确认计数器、会话 信封 codec 或自建 TCP Session ID。内部 IHsmsTransport 现可在 -显式启用时转发 T8 的会话关联前缀快照;上层 HSMS 状态机仍不直接依赖 -StreamFrame 类型。 +显式启用时转发四种成帧故障的会话关联前缀快照与完整性元数据;上层 HSMS +状态机仍不直接依赖 StreamFrame 类型。 ## 会话边界 @@ -84,27 +91,31 @@ StreamFrame 只在已有未完成帧时运行该超时:空闲连接和完整 FrameErrorKind.IncompleteFrameTimeout,再拆除对应会话。 适配器把该诊断转换为携带 transport Session ID 的 HsmsT8TimeoutException,不再次请求重连。公共连接另提供默认 -关闭的独立 T8 观测流:适配器在错误回调同步执行期间验证当前 Session ID, -先复制 FrameErrorEventArgs.Bytes,再由会话 actor 过滤旧 Session -并附加观察状态。默认路径不发布该 transport fault 事件。 - -StreamFrame 2.5.0 的 T8 Bytes 是已复制、最多 8 KiB 的缓冲前缀,可能包含 -四字节 HSMS 长度头;API 不提供原缓冲总长度或完整性,SecsFrame 因而只称 -其为前缀快照。观测通道容量显式可配,满时丢弃最旧项,不能反向阻塞协议 -actor。DecodeFailed、IncompleteFrameOverflow 和 DiscardedByResync 的公共 -FrameError 不携带 session/epoch,本切片不把它们映射成会话关联观测。 +关闭的独立运输故障观测流。适配器使用 +FrameErrorEventArgs.SessionId 发布 DecodeFailed、 +DiscardedByResync、IncompleteFrameOverflow 和 IncompleteFrameTimeout; +会话 actor 过滤旧 Session 并附加观察状态。默认路径不发布该 transport +fault 事件,且只有 IncompleteFrameTimeout 会成为 T8 关闭原因。 + +观测保留 StreamFrame 报告的实际字节数与截断状态。SecsFrame 对所有错误 +类型再统一只复制最多 8 KiB 前缀,因此即使重同步丢弃样本更大也不会形成 +无界公共诊断数据;二次截断会反映在 IsTruncated 中。快照可能 +为空,也可能包含四字节 HSMS 长度头,必须结合 +ObservedByteCount 和 IsTruncated 判断完整性。 +观测通道容量显式可配,满时丢弃最旧项,不能反向阻塞协议 actor。 ## 验证边界 适配器单元测试覆盖原生 Session ID、迟到消息、发送完成、并发入队、会话 -失效、显式关闭原因、关闭/释放竞态、T8 诊断映射、快照复制、旧会话过滤和 -有界队列。真实 TCP 回环测试覆盖分片帧收发、实际 T8 到期及前缀快照,以及 +失效、显式关闭原因、关闭/释放竞态、四种故障映射、实际字节数、截断状态、 +快照复制、旧会话过滤、8192/8193 字节边界和有界队列。真实 TCP 回环测试 +覆盖分片帧收发、实际 T8 到期及前缀快照,以及 会话替换时排队发送的精确失效与新 Socket 内容隔离;Passive 重连竞态测试反复并发对端关闭与本地显式关闭,验证监听 恢复、Session ID 单调递增和原会话精确关闭。完整套件继续覆盖 T3/T6 从 发送完成点启动以及旧会话不重放。 -这些测试证明当前工程契约与 StreamFrame 2.5.0 的互操作行为,不构成 +这些测试证明当前工程契约与 StreamFrame 2.6.0 的互操作行为,不构成 SEMI 合规声明。T5/T8 的标准默认值、精确启停边界和异常恢复仍须依据团队 合法获得的 SEMI E37/E37.1 版本及一致性测试核对。仓库不得提交标准正文、 表格或 Schema。 diff --git a/docs/TRACE.md b/docs/TRACE.md index b5af2bc..ab5f348 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -12,9 +12,9 @@ - 显式启用的完整控制观测或公共未认领控制事件的十字节头元数据导出与 严格读取。 - 已成帧但 SECS-II Body 解码失败的显式分级故障样本。 -- 显式启用、会话关联且容量有界的 T8 前缀快照分级导出。 +- 显式启用、会话关联且容量有界的运输故障快照分级导出。 -当前切片不是原始网络抓包器;除显式启用的 T8 有界前缀外,不记录 TCP +当前切片不是原始网络抓包器;除显式启用的运输故障有界前缀外,不记录 TCP 字节、transport Session generation、控制帧的线上原始字节或异常对象, 也不会接管 HsmsConnection 的单消费者事件流。 应用应在自己的事件循环中从 HsmsIncomingDataMessage 创建接收记录, @@ -185,9 +185,9 @@ payload 捕获默认限制为 64 KiB;超限立即失败,不截断样本。 未完成片段,也没有重放 API。清零范围可验证,但哪些范围包含敏感数据仍是 调用方的数据分类责任;未经独立审查不得把 Redacted 等同于已安全公开。 -## T8 前缀快照 +## 运输故障快照 -连接必须先显式启用独立且容量有界的 T8 观测流;Trace 不自动订阅: +连接必须先显式启用独立且容量有界的运输故障观测流;Trace 不自动订阅: ~~~csharp var options = new HsmsConnectionOptions( @@ -216,32 +216,33 @@ await foreach (var fault in connection } ~~~ -观测保留 IncompleteFrameTimeout、transport Session ID、actor -状态和 StreamFrame 已复制的前缀。通道满时丢弃最旧项而不阻塞协议 actor; -默认关闭时不创建通道。快照最多 8 KiB,可能包含四字节 HSMS 长度前缀, -但 StreamFrame 2.5.0 不提供原缓冲总长度或完整性标记,因此不得宣称它是 -完整 TCP 片段。 +观测覆盖 DecodeFailed、DiscardedByResync、 +IncompleteFrameOverflow 与 IncompleteFrameTimeout, +保留原始 transport Session ID、actor 状态、实际观测字节数和截断状态。 +通道满时丢弃最旧项而不阻塞协议 actor;默认关闭时不创建通道。SecsFrame +对所有类型统一最多保留 8 KiB 前缀,快照可能为空,也可能包含四字节 HSMS +长度前缀;ObservedByteCount 和 IsTruncated 明确 +说明该前缀是否完整。 SecsTraceTransportFaultCaptureOptions 同样要求明确选择 -MetadataOnly、RedactedPayload 或 RawPayload。payload 模式不允许截断, -超过调用方收紧后的限制立即失败;Redacted 范围相对快照起点,必须有序、 -非重叠、不越界并在记录中全部为零。记录和公共观测都防御性复制字节。 +MetadataOnly、RedactedPayload 或 RawPayload。Trace 不会再次截断观测已经 +保留的快照;超过调用方收紧后的限制立即失败。上游或 SecsFrame 已发生的 +截断会原样保留。Redacted 范围相对快照起点,必须有序、非重叠、不越界并 +在记录中全部为零。记录和公共观测都防御性复制字节。 -独立信封使用九个单空格分隔字段: +独立 v2 信封使用十一个单空格分隔字段: ~~~text -SecsFrame-TransportFaultTrace/1 -TransportFault 2026-08-29T05:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 10 4:6 0000000A000000000000 +SecsFrame-TransportFaultTrace/2 +TransportFault 2026-08-29T05:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 10 10 Complete 4:6 0000000A000000000000 ~~~ 字段依次为 UTC 时间、fault kind、HSMS 状态、transport Session ID、数据 -分类、StreamFrame 实际提供的快照长度、脱敏范围和大写十六进制快照。默认 -codec 只允许 MetadataOnly;带快照的导入和导出必须再次显式启用。信封不含 -异常、原缓冲总长度、分片时序或完整性推断,也没有重放 API。 - -当前只接受 T8。StreamFrame 2.5.0 的其他 FrameError 不携带原始 session/epoch, -SecsFrame 不会以回调时读取当前 Session ID 的方式把它们包装成严格会话关联 -样本。 +分类、保留快照长度、实际观测字节数、Complete/Truncated、脱敏范围和大写 +十六进制快照。默认 codec 只允许 MetadataOnly;带快照的导入和导出必须 +再次显式启用。信封不含异常或分片时序,也没有重放 API。v2 严格 codec +明确拒绝 SecsFrame-TransportFaultTrace/1,因为 v1 不含实际 +字节数和完整性字段,不能可靠推断。 ## 结构化脱敏 @@ -337,6 +338,6 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync( 测试。控制信封另有完整 Select/Linktest 双向观测、未匹配 Reject 黄金向量、 未知 SType 往返、Data Message 拒绝、严格字段与资源限制测试。故障样本 信封另有三种分类、payload 默认拒绝、范围清零复核、防御性复制、畸形字段 -和 Body/记录/文本资源限制测试。T8 信封另有真实 TCP 前缀、旧 Session -过滤、有界队列、三种分类、默认拒绝、脱敏复核、畸形字段和快照/记录/文本 -资源限制测试。 +和 Body/记录/文本资源限制测试。运输故障信封另有四种错误、真实 TCP T8 +前缀、旧 Session 过滤、8192/8193 字节边界、有界队列、三种分类、默认 +拒绝、脱敏复核、畸形字段和快照/记录/文本资源限制测试。 diff --git a/src/SecsFrame.Trace/SecsTraceTransportFaultCaptureOptions.cs b/src/SecsFrame.Trace/SecsTraceTransportFaultCaptureOptions.cs index 69b16f7..da9b805 100644 --- a/src/SecsFrame.Trace/SecsTraceTransportFaultCaptureOptions.cs +++ b/src/SecsFrame.Trace/SecsTraceTransportFaultCaptureOptions.cs @@ -2,10 +2,10 @@ namespace SecsFrame.Trace; -/// Defines the explicit data boundary for one T8 prefix-snapshot capture. +/// Defines the explicit data boundary for one transport-fault capture. public sealed class SecsTraceTransportFaultCaptureOptions { - /// The maximum StreamFrame T8 prefix snapshot: 8 KiB. + /// The maximum retained transport-fault prefix snapshot: 8 KiB. public const int DefaultMaxSnapshotBytes = HsmsTransportFaultObservation.MaxSnapshotBytes; diff --git a/src/SecsFrame.Trace/SecsTraceTransportFaultCodec.cs b/src/SecsFrame.Trace/SecsTraceTransportFaultCodec.cs index d235419..44a5339 100644 --- a/src/SecsFrame.Trace/SecsTraceTransportFaultCodec.cs +++ b/src/SecsFrame.Trace/SecsTraceTransportFaultCodec.cs @@ -4,11 +4,11 @@ namespace SecsFrame.Trace; -/// Encodes and decodes explicitly classified T8 prefix snapshots. +/// Encodes and decodes explicitly classified transport-fault snapshots. public sealed class SecsTraceTransportFaultCodec { /// The exact transport-fault trace format identifier. - public const string FormatIdentifier = "SecsFrame-TransportFaultTrace/1"; + public const string FormatIdentifier = "SecsFrame-TransportFaultTrace/2"; /// The default maximum number of records. public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount; @@ -127,6 +127,10 @@ private static void AppendRecord( text.Append(' '); text.Append(record.ObservedSnapshotLength.ToString(CultureInfo.InvariantCulture)); text.Append(' '); + text.Append(record.ObservedByteCount.ToString(CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.IsTruncated ? "Truncated" : "Complete"); + text.Append(' '); AppendRanges(text, record.RedactionRanges); text.Append(' '); AppendSnapshot(text, record.Snapshot); @@ -209,10 +213,10 @@ private SecsTraceTransportFaultRecord ParseRecord() { var recordOffset = _index; var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None); - if (fields.Length != 9 || + if (fields.Length != 11 || !string.Equals(fields[0], "TransportFault", StringComparison.Ordinal)) { - throw Error("A transport-fault record must contain exactly nine single-space-separated fields", _recordIndex, recordOffset); + throw Error("A transport-fault record must contain exactly eleven single-space-separated fields", _recordIndex, recordOffset); } var classification = ParseEnum(fields[5], "data classification", recordOffset); @@ -222,17 +226,28 @@ private SecsTraceTransportFaultRecord ParseRecord() throw Error("Payload-bearing transport-fault samples require AllowPayloadRecords to be enabled explicitly", _recordIndex, recordOffset); } - var snapshot = ParseSnapshot(fields[8], recordOffset); - var ranges = ParseRanges(fields[7], recordOffset); + var snapshot = ParseSnapshot(fields[10], recordOffset); + var ranges = ParseRanges(fields[9], recordOffset); try { return new SecsTraceTransportFaultRecord( ParseTimestamp(fields[1], recordOffset), ParseEnum(fields[2], "transport-fault kind", recordOffset), ParseEnum(fields[3], "session state", recordOffset), - ParseInt64(fields[4], "transport Session ID", recordOffset), + ParsePositiveInt64( + fields[4], + "transport Session ID", + recordOffset), classification, - ParseInt32(fields[6], "observed snapshot length", recordOffset), + ParseNonNegativeInt32( + fields[6], + "observed snapshot length", + recordOffset), + ParseNonNegativeInt64( + fields[7], + "observed byte count", + recordOffset), + ParseTruncation(fields[8], recordOffset), snapshot, ranges); } @@ -270,30 +285,60 @@ private TEnum ParseEnum(string value, string fieldName, int offset) return parsed; } - private int ParseInt32(string value, string fieldName, int offset) + private int ParseNonNegativeInt32( + string value, + string fieldName, + int offset) { if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || - parsed <= 0 || + parsed < 0 || !string.Equals(value, parsed.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { - throw Error($"The {fieldName} must be a canonical positive 32-bit integer", _recordIndex, offset); + throw Error($"The {fieldName} must be a canonical nonnegative 32-bit integer", _recordIndex, offset); } return parsed; } - private long ParseInt64(string value, string fieldName, int offset) + private long ParseNonNegativeInt64( + string value, + string fieldName, + int offset) { if (!long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || - parsed <= 0 || + parsed < 0 || !string.Equals(value, parsed.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { - throw Error($"The {fieldName} must be a canonical positive 64-bit integer", _recordIndex, offset); + throw Error($"The {fieldName} must be a canonical nonnegative 64-bit integer", _recordIndex, offset); } return parsed; } + private long ParsePositiveInt64( + string value, + string fieldName, + int offset) + { + var parsed = ParseNonNegativeInt64(value, fieldName, offset); + if (parsed == 0) + throw Error($"The {fieldName} must be positive", _recordIndex, offset); + return parsed; + } + + private bool ParseTruncation(string value, int offset) + { + if (string.Equals(value, "Complete", StringComparison.Ordinal)) + return false; + if (string.Equals(value, "Truncated", StringComparison.Ordinal)) + return true; + + throw Error( + "The snapshot completeness must be Complete or Truncated", + _recordIndex, + offset); + } + private IReadOnlyList ParseRanges( string value, int offset) diff --git a/src/SecsFrame.Trace/SecsTraceTransportFaultRecord.cs b/src/SecsFrame.Trace/SecsTraceTransportFaultRecord.cs index 707782f..31a55a6 100644 --- a/src/SecsFrame.Trace/SecsTraceTransportFaultRecord.cs +++ b/src/SecsFrame.Trace/SecsTraceTransportFaultRecord.cs @@ -2,17 +2,19 @@ namespace SecsFrame.Trace; -/// Describes an explicitly classified T8 prefix-snapshot sample. +/// Describes an explicitly classified transport-fault sample. /// /// The sample never contains an exception or a claimed complete TCP fragment. -/// StreamFrame provides at most the first 8 KiB and does not expose the original -/// buffered length or a completeness flag. +/// The retained snapshot is capped at 8 KiB while the original observed byte +/// count and truncation state remain explicit. /// public sealed class SecsTraceTransportFaultRecord { private readonly byte[] _snapshot; - /// Creates an immutable T8 transport-fault record. + /// + /// Creates a record whose retained snapshot is known to be complete. + /// public SecsTraceTransportFaultRecord( DateTimeOffset timestamp, HsmsTransportFaultKind kind, @@ -22,6 +24,32 @@ public SecsTraceTransportFaultRecord( int observedSnapshotLength, ReadOnlySpan snapshot = default, IEnumerable? redactionRanges = null) + : this( + timestamp, + kind, + state, + transportSessionId, + dataClassification, + observedSnapshotLength, + observedSnapshotLength, + false, + snapshot, + redactionRanges) + { + } + + /// Creates an immutable transport-fault record. + public SecsTraceTransportFaultRecord( + DateTimeOffset timestamp, + HsmsTransportFaultKind kind, + HsmsSessionState state, + long transportSessionId, + SecsTraceFaultSampleDataClassification dataClassification, + int observedSnapshotLength, + long observedByteCount, + bool isTruncated, + ReadOnlySpan snapshot = default, + IEnumerable? redactionRanges = null) { if (!Enum.IsDefined(typeof(HsmsTransportFaultKind), kind)) throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown HSMS transport-fault kind."); @@ -31,14 +59,18 @@ public SecsTraceTransportFaultRecord( throw new ArgumentOutOfRangeException(nameof(transportSessionId), transportSessionId, "The transport session identifier must be positive."); if (!Enum.IsDefined(typeof(SecsTraceFaultSampleDataClassification), dataClassification)) throw new ArgumentOutOfRangeException(nameof(dataClassification), dataClassification, "Unknown fault-sample data classification."); - if (observedSnapshotLength is <= 0 or > + if (observedSnapshotLength is < 0 or > HsmsTransportFaultObservation.MaxSnapshotBytes) { throw new ArgumentOutOfRangeException( nameof(observedSnapshotLength), observedSnapshotLength, - $"The observed snapshot length must be between 1 and {HsmsTransportFaultObservation.MaxSnapshotBytes} bytes."); + $"The observed snapshot length must be between 0 and {HsmsTransportFaultObservation.MaxSnapshotBytes} bytes."); } + if (observedByteCount < observedSnapshotLength) + throw new ArgumentOutOfRangeException(nameof(observedByteCount), observedByteCount, "The observed byte count cannot be smaller than the retained snapshot."); + if (isTruncated != (observedSnapshotLength < observedByteCount)) + throw new ArgumentException("The truncation flag must equal whether the retained snapshot is shorter than the observed byte count.", nameof(isTruncated)); var ranges = PrepareRanges(redactionRanges); ValidateDataBoundary( @@ -53,6 +85,8 @@ public SecsTraceTransportFaultRecord( TransportSessionId = transportSessionId; DataClassification = dataClassification; ObservedSnapshotLength = observedSnapshotLength; + ObservedByteCount = observedByteCount; + IsTruncated = isTruncated; _snapshot = snapshot.ToArray(); RedactionRanges = ranges; } @@ -72,16 +106,22 @@ public SecsTraceTransportFaultRecord( /// Gets how much snapshot data the record contains. public SecsTraceFaultSampleDataClassification DataClassification { get; } - /// Gets the number of prefix bytes supplied by StreamFrame. + /// Gets the number of bytes retained in the observation snapshot. public int ObservedSnapshotLength { get; } + /// Gets the number of bytes observed for the framing error. + public long ObservedByteCount { get; } + + /// Gets whether the retained snapshot is a truncated prefix. + public bool IsTruncated { get; } + /// Gets the copied snapshot bytes, empty for metadata-only records. public ReadOnlySpan Snapshot => _snapshot; /// Gets the ordered ranges that were zeroed before capture. public IReadOnlyList RedactionRanges { get; } - /// Creates a record from a public T8 transport-fault observation. + /// Creates a record from a public transport-fault observation. public static SecsTraceTransportFaultRecord Create( DateTimeOffset timestamp, HsmsTransportFaultObservation observation, @@ -101,7 +141,9 @@ public static SecsTraceTransportFaultRecord Create( observation.State, observation.TransportSessionId, options.DataClassification, - observation.Snapshot.Length), + observation.Snapshot.Length, + observation.ObservedByteCount, + observation.IsTruncated), SecsTraceFaultSampleDataClassification.RedactedPayload => CreateRedacted(timestamp, observation, options), SecsTraceFaultSampleDataClassification.RawPayload => @@ -127,7 +169,7 @@ private static SecsTraceTransportFaultRecord CreateRedacted( throw new ArgumentOutOfRangeException( nameof(options), range, - "A byte redaction range exceeds the T8 prefix snapshot."); + "A byte redaction range exceeds the transport-fault snapshot."); } Array.Clear(snapshot, range.Offset, range.Length); @@ -140,6 +182,8 @@ private static SecsTraceTransportFaultRecord CreateRedacted( observation.TransportSessionId, options.DataClassification, snapshot.Length, + observation.ObservedByteCount, + observation.IsTruncated, snapshot, options.RedactionRanges); } @@ -157,6 +201,8 @@ private static SecsTraceTransportFaultRecord CreateRaw( observation.TransportSessionId, options.DataClassification, observation.Snapshot.Length, + observation.ObservedByteCount, + observation.IsTruncated, observation.Snapshot); } @@ -167,7 +213,7 @@ private static void ValidateSnapshotSize( if (snapshotLength > maxSnapshotBytes) { throw new InvalidOperationException( - $"The T8 prefix snapshot length {snapshotLength} exceeds the configured maximum {maxSnapshotBytes}."); + $"The transport-fault snapshot length {snapshotLength} exceeds the configured maximum {maxSnapshotBytes}."); } } @@ -219,7 +265,7 @@ private static void ValidateDataBoundary( foreach (var range in ranges) { if (range.Offset + range.Length > snapshot.Length) - throw new ArgumentOutOfRangeException(nameof(ranges), range, "A byte redaction range exceeds the T8 prefix snapshot."); + throw new ArgumentOutOfRangeException(nameof(ranges), range, "A byte redaction range exceeds the transport-fault snapshot."); for (var index = range.Offset; index < range.Offset + range.Length; index++) { if (snapshot[index] != 0) diff --git a/src/SecsFrame/Hsms/Connection/HsmsTransportFaultKind.cs b/src/SecsFrame/Hsms/Connection/HsmsTransportFaultKind.cs index 17cebc8..94f7cdc 100644 --- a/src/SecsFrame/Hsms/Connection/HsmsTransportFaultKind.cs +++ b/src/SecsFrame/Hsms/Connection/HsmsTransportFaultKind.cs @@ -5,4 +5,13 @@ public enum HsmsTransportFaultKind { /// T8 expired while an incomplete HSMS frame was buffered. IncompleteFrameTimeout, + + /// A complete framed payload could not be decoded. + DecodeFailed, + + /// Bytes were discarded while the framer resynchronized. + DiscardedByResync, + + /// An incomplete frame exceeded the configured buffer limit. + IncompleteFrameOverflow, } diff --git a/src/SecsFrame/Hsms/Connection/HsmsTransportFaultObservation.cs b/src/SecsFrame/Hsms/Connection/HsmsTransportFaultObservation.cs index 33f5271..97414bb 100644 --- a/src/SecsFrame/Hsms/Connection/HsmsTransportFaultObservation.cs +++ b/src/SecsFrame/Hsms/Connection/HsmsTransportFaultObservation.cs @@ -4,22 +4,25 @@ namespace SecsFrame; /// Describes one explicitly enabled, session-associated HSMS transport fault. /// /// -/// The snapshot is a defensive copy of at most the first 8 KiB retained by -/// StreamFrame. It can include the four-byte HSMS length prefix and is not -/// guaranteed to contain the complete network fragment. +/// The snapshot is a defensive copy of at most the first 8 KiB associated +/// with the framing error. and +/// describe whether the copied prefix contains all +/// bytes observed for that error. /// public sealed class HsmsTransportFaultObservation { private readonly byte[] _snapshot; - /// The maximum prefix snapshot retained by StreamFrame. + /// The maximum prefix snapshot retained by SecsFrame. public const int MaxSnapshotBytes = 8 * 1024; internal HsmsTransportFaultObservation( HsmsTransportFaultKind kind, long transportSessionId, HsmsSessionState state, - ReadOnlySpan snapshot) + ReadOnlySpan snapshot, + long observedByteCount, + bool isTruncated) { if (!Enum.IsDefined(typeof(HsmsTransportFaultKind), kind)) throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown HSMS transport-fault kind."); @@ -27,15 +30,19 @@ internal HsmsTransportFaultObservation( throw new ArgumentOutOfRangeException(nameof(transportSessionId), transportSessionId, "The transport session identifier must be positive."); if (!Enum.IsDefined(typeof(HsmsSessionState), state)) throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown HSMS session state."); - if (snapshot.IsEmpty) - throw new ArgumentException("A T8 observation requires a nonempty prefix snapshot.", nameof(snapshot)); if (snapshot.Length > MaxSnapshotBytes) - throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.Length, $"A T8 prefix snapshot cannot exceed {MaxSnapshotBytes} bytes."); + throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.Length, $"A transport-fault snapshot cannot exceed {MaxSnapshotBytes} bytes."); + if (observedByteCount < snapshot.Length) + throw new ArgumentOutOfRangeException(nameof(observedByteCount), observedByteCount, "The observed byte count cannot be smaller than the retained snapshot."); + if (isTruncated != (snapshot.Length < observedByteCount)) + throw new ArgumentException("The truncation flag must equal whether the retained snapshot is shorter than the observed byte count.", nameof(isTruncated)); Kind = kind; TransportSessionId = transportSessionId; State = state; _snapshot = snapshot.ToArray(); + ObservedByteCount = observedByteCount; + IsTruncated = isTruncated; } /// Gets the transport fault kind. @@ -49,4 +56,10 @@ internal HsmsTransportFaultObservation( /// Gets the copied, bounded network-prefix snapshot. public ReadOnlySpan Snapshot => _snapshot; + + /// Gets the number of bytes observed for the framing error. + public long ObservedByteCount { get; } + + /// Gets whether is a truncated prefix. + public bool IsTruncated { get; } } diff --git a/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs b/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs index a6451f6..8bd522d 100644 --- a/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs +++ b/src/SecsFrame/Hsms/Session/HsmsSessionStateMachine.cs @@ -374,7 +374,9 @@ private void ProcessTransportEvent(HsmsTransportEvent transportEvent) transportEvent.FaultKind!.Value, transportEvent.SessionId.Value, State, - transportEvent.Snapshot.Span)); + transportEvent.Snapshot.Span, + transportEvent.ObservedByteCount, + transportEvent.IsTruncated)); } break; case HsmsTransportEventKind.SessionClosed: diff --git a/src/SecsFrame/Transport/HsmsTransportEvent.cs b/src/SecsFrame/Transport/HsmsTransportEvent.cs index c6b3f2c..d504846 100644 --- a/src/SecsFrame/Transport/HsmsTransportEvent.cs +++ b/src/SecsFrame/Transport/HsmsTransportEvent.cs @@ -8,7 +8,9 @@ private HsmsTransportEvent( HsmsFrame? frame, Exception? error, HsmsTransportFaultKind? faultKind, - ReadOnlyMemory snapshot) + ReadOnlyMemory snapshot, + long observedByteCount, + bool isTruncated) { if (!sessionId.IsValid) throw new ArgumentOutOfRangeException(nameof(sessionId), sessionId, "The transport session identifier must be positive."); @@ -23,12 +25,16 @@ private HsmsTransportEvent( throw new ArgumentNullException(nameof(faultKind)); if (!isFault && faultKind is not null) throw new ArgumentException("Only a transport-fault event can carry a fault kind.", nameof(faultKind)); - if (isFault && snapshot.IsEmpty) - throw new ArgumentException("A transport-fault event requires a nonempty prefix snapshot.", nameof(snapshot)); if (!isFault && !snapshot.IsEmpty) throw new ArgumentException("Only a transport-fault event can carry a prefix snapshot.", nameof(snapshot)); if (snapshot.Length > HsmsTransportFaultObservation.MaxSnapshotBytes) throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.Length, "The transport-fault prefix snapshot exceeds the supported limit."); + if (isFault && observedByteCount < snapshot.Length) + throw new ArgumentOutOfRangeException(nameof(observedByteCount), observedByteCount, "The observed byte count cannot be smaller than the retained snapshot."); + if (isFault && isTruncated != (snapshot.Length < observedByteCount)) + throw new ArgumentException("The truncation flag must equal whether the retained snapshot is shorter than the observed byte count.", nameof(isTruncated)); + if (!isFault && (observedByteCount != 0 || isTruncated)) + throw new ArgumentException("Only a transport-fault event can carry snapshot completeness metadata.", nameof(observedByteCount)); Kind = kind; SessionId = sessionId; @@ -36,6 +42,8 @@ private HsmsTransportEvent( Error = error; FaultKind = faultKind; Snapshot = snapshot; + ObservedByteCount = observedByteCount; + IsTruncated = isTruncated; } public HsmsTransportEventKind Kind { get; } @@ -50,26 +58,34 @@ private HsmsTransportEvent( public ReadOnlyMemory Snapshot { get; } + public long ObservedByteCount { get; } + + public bool IsTruncated { get; } + public static HsmsTransportEvent SessionOpened(HsmsTransportSessionId sessionId) - => new(HsmsTransportEventKind.SessionOpened, sessionId, null, null, null, default); + => new(HsmsTransportEventKind.SessionOpened, sessionId, null, null, null, default, 0, false); public static HsmsTransportEvent FrameReceived(HsmsTransportSessionId sessionId, HsmsFrame frame) - => new(HsmsTransportEventKind.FrameReceived, sessionId, frame, null, null, default); + => new(HsmsTransportEventKind.FrameReceived, sessionId, frame, null, null, default, 0, false); public static HsmsTransportEvent TransportFaultObserved( HsmsTransportSessionId sessionId, HsmsTransportFaultKind faultKind, - ReadOnlySpan snapshot) + ReadOnlySpan snapshot, + long observedByteCount, + bool isTruncated) => new( HsmsTransportEventKind.TransportFaultObserved, sessionId, null, null, faultKind, - snapshot.ToArray()); + snapshot.ToArray(), + observedByteCount, + isTruncated); public static HsmsTransportEvent SessionClosed( HsmsTransportSessionId sessionId, Exception? error = null) - => new(HsmsTransportEventKind.SessionClosed, sessionId, null, error, null, default); + => new(HsmsTransportEventKind.SessionClosed, sessionId, null, error, null, default, 0, false); } diff --git a/src/SecsFrame/Transport/HsmsTransportOptions.cs b/src/SecsFrame/Transport/HsmsTransportOptions.cs index e0f8395..1d22cb3 100644 --- a/src/SecsFrame/Transport/HsmsTransportOptions.cs +++ b/src/SecsFrame/Transport/HsmsTransportOptions.cs @@ -5,7 +5,7 @@ internal sealed class HsmsTransportOptions public HsmsTransportOptions( TimeSpan t5, TimeSpan t8, - bool enableT8FaultObservation = false) + bool enableTransportFaultObservation = false) { if (t5 <= TimeSpan.Zero) { @@ -61,14 +61,14 @@ public HsmsTransportOptions( T8 = t8; T5Milliseconds = (int)t5Milliseconds; T8Milliseconds = (int)t8Milliseconds; - EnableT8FaultObservation = enableT8FaultObservation; + EnableTransportFaultObservation = enableTransportFaultObservation; } public TimeSpan T5 { get; } public TimeSpan T8 { get; } - public bool EnableT8FaultObservation { get; } + public bool EnableTransportFaultObservation { get; } internal int T5Milliseconds { get; } diff --git a/src/SecsFrame/Transport/StreamFrameHsmsTransport.cs b/src/SecsFrame/Transport/StreamFrameHsmsTransport.cs index 3fc8630..7173ebb 100644 --- a/src/SecsFrame/Transport/StreamFrameHsmsTransport.cs +++ b/src/SecsFrame/Transport/StreamFrameHsmsTransport.cs @@ -10,7 +10,7 @@ internal sealed class StreamFrameHsmsTransport : IHsmsTransport { private readonly ISessionAwareStreamConnection _connection; private readonly Channel _events; - private readonly bool _enableT8FaultObservation; + private readonly bool _enableTransportFaultObservation; #if NET9_0_OR_GREATER private readonly Lock _lifecycleGate = new(); private readonly Lock _sessionGate = new(); @@ -26,10 +26,10 @@ internal sealed class StreamFrameHsmsTransport : IHsmsTransport internal StreamFrameHsmsTransport( ISessionAwareStreamConnection connection, - bool enableT8FaultObservation = false) + bool enableTransportFaultObservation = false) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); - _enableT8FaultObservation = enableT8FaultObservation; + _enableTransportFaultObservation = enableTransportFaultObservation; _events = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = false, @@ -65,7 +65,7 @@ public static StreamFrameHsmsTransport Create( adaptedOptions); return new StreamFrameHsmsTransport( connection, - hsmsOptions.EnableT8FaultObservation); + hsmsOptions.EnableTransportFaultObservation); } public void Start(CancellationToken cancellationToken) @@ -278,34 +278,64 @@ private void OnConnectionChanged(object? sender, ConnectionState state) private void OnFrameError(object? sender, FrameErrorEventArgs args) { - if (args.Kind != FrameErrorKind.IncompleteFrameTimeout) - return; - - var sessionValue = Volatile.Read(ref _currentSessionId); - if (sessionValue <= 0) + if (!TryMapFrameErrorKind(args.Kind, out var faultKind) || + args.SessionId <= 0) + { return; + } - var isCurrentSession = false; - lock (_sessionGate) + if (faultKind == HsmsTransportFaultKind.IncompleteFrameTimeout) { - if (_currentSessionId == sessionValue && - _connection.CurrentSessionId == sessionValue && - _sessions.TryGetValue(sessionValue, out var context) && - !context.IsClosed) + lock (_sessionGate) { - isCurrentSession = true; - context.CloseReason ??= new HsmsT8TimeoutException( - new HsmsTransportSessionId(sessionValue)); + if (_sessions.TryGetValue(args.SessionId, out var context) && + !context.IsClosed) + { + context.CloseReason ??= new HsmsT8TimeoutException( + new HsmsTransportSessionId(args.SessionId)); + } } } - if (isCurrentSession && _enableT8FaultObservation) + if (!_enableTransportFaultObservation) + return; + + var snapshotLength = Math.Min( + args.Bytes.Length, + HsmsTransportFaultObservation.MaxSnapshotBytes); + var isTruncated = + args.IsTruncated || + snapshotLength < args.ObservedByteCount; + _events.Writer.TryWrite( + HsmsTransportEvent.TransportFaultObserved( + new HsmsTransportSessionId(args.SessionId), + faultKind, + args.Bytes.Span[..snapshotLength], + args.ObservedByteCount, + isTruncated)); + } + + private static bool TryMapFrameErrorKind( + FrameErrorKind kind, + out HsmsTransportFaultKind faultKind) + { + switch (kind) { - _events.Writer.TryWrite( - HsmsTransportEvent.TransportFaultObserved( - new HsmsTransportSessionId(sessionValue), - HsmsTransportFaultKind.IncompleteFrameTimeout, - args.Bytes.Span)); + case FrameErrorKind.DecodeFailed: + faultKind = HsmsTransportFaultKind.DecodeFailed; + return true; + case FrameErrorKind.DiscardedByResync: + faultKind = HsmsTransportFaultKind.DiscardedByResync; + return true; + case FrameErrorKind.IncompleteFrameOverflow: + faultKind = HsmsTransportFaultKind.IncompleteFrameOverflow; + return true; + case FrameErrorKind.IncompleteFrameTimeout: + faultKind = HsmsTransportFaultKind.IncompleteFrameTimeout; + return true; + default: + faultKind = default; + return false; } } diff --git a/test/SecsFrame.Tests/HsmsConnectionTests.cs b/test/SecsFrame.Tests/HsmsConnectionTests.cs index 12244ca..bc348a6 100644 --- a/test/SecsFrame.Tests/HsmsConnectionTests.cs +++ b/test/SecsFrame.Tests/HsmsConnectionTests.cs @@ -110,6 +110,8 @@ await client.GetStream().WriteAsync( Assert.True(observation.TransportSessionId > 0); Assert.Equal(HsmsSessionState.Connected, observation.State); Assert.Equal(prefix, observation.Snapshot.ToArray()); + Assert.Equal(prefix.Length, observation.ObservedByteCount); + Assert.False(observation.IsTruncated); } private static async Task AssertSelectObservationsAsync( diff --git a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.TransportFaultObservation.cs b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.TransportFaultObservation.cs index 8241add..00edc37 100644 --- a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.TransportFaultObservation.cs +++ b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.TransportFaultObservation.cs @@ -3,7 +3,7 @@ namespace SecsFrame.Tests; public sealed partial class HsmsSessionStateMachineTests { [Fact] - public async Task Enabled_T8_observation_filters_stale_transport_sessions() + public async Task Enabled_transport_fault_observation_filters_stale_sessions() { var transport = new FakeHsmsTransport(); await using var machine = CreateMachine( @@ -30,21 +30,31 @@ await WaitUntilAsync(() => machine.State == HsmsSessionState.Disconnected) await WaitUntilAsync(() => machine.State == HsmsSessionState.Connected) .ConfigureAwait(true); - transport.ObserveT8(firstSession, new byte[] { 0x01 }); - transport.ObserveT8(secondSession, new byte[] { 0x02 }); + transport.ObserveTransportFault( + firstSession, + HsmsTransportFaultKind.DecodeFailed, + new byte[] { 0x01 }); + transport.ObserveTransportFault( + secondSession, + HsmsTransportFaultKind.DiscardedByResync, + new byte[] { 0x02 }, + observedByteCount: 4, + isTruncated: true); var observation = await NextTransportFaultObservationAsync(observations) .ConfigureAwait(true); Assert.Equal(secondSession.Value, observation.TransportSessionId); Assert.Equal(HsmsSessionState.Connected, observation.State); Assert.Equal( - HsmsTransportFaultKind.IncompleteFrameTimeout, + HsmsTransportFaultKind.DiscardedByResync, observation.Kind); Assert.Equal(new byte[] { 0x02 }, observation.Snapshot.ToArray()); + Assert.Equal(4, observation.ObservedByteCount); + Assert.True(observation.IsTruncated); } [Fact] - public async Task T8_observation_queue_drops_oldest_without_blocking_state_machine() + public async Task Transport_fault_queue_drops_oldest_without_blocking_state_machine() { var transport = new FakeHsmsTransport(); await using var machine = CreateMachine( @@ -60,9 +70,18 @@ public async Task T8_observation_queue_drops_oldest_without_blocking_state_machi await WaitUntilAsync(() => machine.State == HsmsSessionState.Connected) .ConfigureAwait(true); - transport.ObserveT8(sessionId, new byte[] { 0x01 }); - transport.ObserveT8(sessionId, new byte[] { 0x02 }); - transport.ObserveT8(sessionId, new byte[] { 0x03 }); + transport.ObserveTransportFault( + sessionId, + HsmsTransportFaultKind.IncompleteFrameTimeout, + new byte[] { 0x01 }); + transport.ObserveTransportFault( + sessionId, + HsmsTransportFaultKind.IncompleteFrameOverflow, + new byte[] { 0x02 }); + transport.ObserveTransportFault( + sessionId, + HsmsTransportFaultKind.DecodeFailed, + new byte[] { 0x03 }); transport.CloseCurrent(); await WaitUntilAsync(() => machine.State == HsmsSessionState.Disconnected) .ConfigureAwait(true); @@ -82,7 +101,7 @@ await WaitUntilAsync(() => machine.State == HsmsSessionState.Disconnected) } [Fact] - public async Task T8_observation_is_disabled_by_default() + public async Task Transport_fault_observation_is_disabled_by_default() { var transport = new FakeHsmsTransport(); await using var machine = CreateMachine( diff --git a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs index f284cfb..f450e39 100644 --- a/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs +++ b/test/SecsFrame.Tests/HsmsSessionStateMachineTests.cs @@ -890,14 +890,19 @@ public void Receive(HsmsFrame frame) HsmsTransportEvent.FrameReceived(sessionId, frame)); } - public void ObserveT8( + public void ObserveTransportFault( HsmsTransportSessionId sessionId, - ReadOnlySpan snapshot) + HsmsTransportFaultKind kind, + ReadOnlySpan snapshot, + long? observedByteCount = null, + bool? isTruncated = null) => _events.Writer.TryWrite( HsmsTransportEvent.TransportFaultObserved( sessionId, - HsmsTransportFaultKind.IncompleteFrameTimeout, - snapshot)); + kind, + snapshot, + observedByteCount ?? snapshot.Length, + isTruncated ?? observedByteCount > snapshot.Length)); public void CompleteSend(int index) { diff --git a/test/SecsFrame.Tests/SecsTraceTransportFaultTests.cs b/test/SecsFrame.Tests/SecsTraceTransportFaultTests.cs index 8ae2f92..e4d070b 100644 --- a/test/SecsFrame.Tests/SecsTraceTransportFaultTests.cs +++ b/test/SecsFrame.Tests/SecsTraceTransportFaultTests.cs @@ -33,8 +33,8 @@ public void Redacted_capture_produces_a_deterministic_verifiable_vector() var text = codec.Encode(new[] { record }); Assert.Equal( - "SecsFrame-TransportFaultTrace/1\n" + - "TransportFault 2026-08-29T05:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 10 4:6 0000000A000000000000\n", + "SecsFrame-TransportFaultTrace/2\n" + + "TransportFault 2026-08-29T05:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 10 10 Complete 4:6 0000000A000000000000\n", text); Assert.DoesNotContain("534543524554", text, StringComparison.Ordinal); var decoded = Assert.Single(codec.Decode(text)); @@ -68,6 +68,8 @@ public void Default_codec_allows_metadata_only_and_rejects_payload_records() SecsTraceFaultSampleDataClassification.MetadataOnly, decoded.DataClassification); Assert.Equal(2, decoded.ObservedSnapshotLength); + Assert.Equal(2, decoded.ObservedByteCount); + Assert.False(decoded.IsTruncated); Assert.True(decoded.Snapshot.IsEmpty); Assert.Throws( () => defaultCodec.Encode(new[] { raw })); @@ -96,6 +98,47 @@ public void Raw_capture_is_defensively_copied_and_round_trips_when_enabled() AssertRecordEqual(record, decoded); } + [Theory] + [InlineData(HsmsTransportFaultKind.DecodeFailed)] + [InlineData(HsmsTransportFaultKind.DiscardedByResync)] + [InlineData(HsmsTransportFaultKind.IncompleteFrameOverflow)] + [InlineData(HsmsTransportFaultKind.IncompleteFrameTimeout)] + public void All_transport_fault_kinds_round_trip( + HsmsTransportFaultKind kind) + { + var observation = CreateObservation(new byte[] { 0x00, 0x01 }, kind); + var record = SecsTraceTransportFaultRecord.Create( + Epoch, + observation, + SecsTraceTransportFaultCaptureOptions.RawPayload()); + var codec = new SecsTraceTransportFaultCodec(allowPayloadRecords: true); + + var decoded = Assert.Single(codec.Decode(codec.Encode(new[] { record }))); + + AssertRecordEqual(record, decoded); + } + + [Fact] + public void Truncated_observation_preserves_actual_byte_count() + { + var observation = CreateObservation( + new byte[HsmsTransportFaultObservation.MaxSnapshotBytes], + HsmsTransportFaultKind.IncompleteFrameOverflow, + observedByteCount: 9004); + var record = SecsTraceTransportFaultRecord.Create( + Epoch, + observation, + SecsTraceTransportFaultCaptureOptions.MetadataOnly()); + var codec = new SecsTraceTransportFaultCodec(); + + var decoded = Assert.Single(codec.Decode(codec.Encode(new[] { record }))); + + Assert.Equal(8192, decoded.ObservedSnapshotLength); + Assert.Equal(9004, decoded.ObservedByteCount); + Assert.True(decoded.IsTruncated); + Assert.True(decoded.Snapshot.IsEmpty); + } + [Fact] public void Capture_factory_rejects_null_oversize_and_outside_ranges() { @@ -126,18 +169,21 @@ public void Capture_factory_rejects_null_oversize_and_outside_ranges() public void Codec_rejects_malformed_fields_and_unverifiable_redaction() { const string valid = - "SecsFrame-TransportFaultTrace/1\n" + - "TransportFault 1970-01-01T00:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 4 1:2 41000044\n"; + "SecsFrame-TransportFaultTrace/2\n" + + "TransportFault 1970-01-01T00:00:00.0000000Z IncompleteFrameTimeout Connected 17 RedactedPayload 4 6 Truncated 1:2 41000044\n"; var codec = new SecsTraceTransportFaultCodec( allowPayloadRecords: true); Assert.Throws(() => codec.Decode(string.Empty)); - Assert.Throws(() => codec.Decode(valid.Replace("TransportFaultTrace/1", "TransportFaultTrace/2"))); - Assert.Throws(() => codec.Decode(valid.Replace("IncompleteFrameTimeout", "DecodeFailed"))); + Assert.Throws(() => codec.Decode(valid.Replace("TransportFaultTrace/2", "TransportFaultTrace/1"))); + Assert.Throws(() => codec.Decode(valid.Replace("IncompleteFrameTimeout", "Unknown"))); Assert.Throws(() => codec.Decode(valid.Replace("Connected", "connected"))); Assert.Throws(() => codec.Decode(valid.Replace(" 17 ", " 01 "))); Assert.Throws(() => codec.Decode(valid.Replace("RedactedPayload", "Payload"))); - Assert.Throws(() => codec.Decode(valid.Replace(" 4 1:2 ", " 04 1:2 "))); + Assert.Throws(() => codec.Decode(valid.Replace(" 4 6 Truncated ", " 04 6 Truncated "))); + Assert.Throws(() => codec.Decode(valid.Replace(" 4 6 Truncated ", " 4 06 Truncated "))); + Assert.Throws(() => codec.Decode(valid.Replace("Truncated", "Partial"))); + Assert.Throws(() => codec.Decode(valid.Replace(" 4 6 Truncated ", " 4 4 Truncated "))); Assert.Throws(() => codec.Decode(valid.Replace("1:2", "01:2"))); Assert.Throws(() => codec.Decode(valid.Replace("1:2 41000044", "3:1,0:1 00000000"))); Assert.Throws(() => codec.Decode(valid.Replace("41000044", "41424344"))); @@ -147,7 +193,7 @@ public void Codec_rejects_malformed_fields_and_unverifiable_redaction() } [Fact] - public void Options_observations_and_records_enforce_data_boundaries() + public void Options_and_observations_enforce_data_boundaries() { Assert.Throws(() => SecsTraceTransportFaultCaptureOptions.RedactedPayload(null!)); @@ -166,18 +212,35 @@ public void Options_observations_and_records_enforce_data_boundaries() Assert.Throws(() => SecsTraceTransportFaultCaptureOptions.RawPayload( HsmsTransportFaultObservation.MaxSnapshotBytes + 1)); + Assert.Throws(() => + new HsmsTransportFaultObservation( + HsmsTransportFaultKind.IncompleteFrameTimeout, + 1, + HsmsSessionState.Connected, + new byte[2], + 1, + false)); Assert.Throws(() => new HsmsTransportFaultObservation( HsmsTransportFaultKind.IncompleteFrameTimeout, 1, HsmsSessionState.Connected, - ReadOnlySpan.Empty)); + new byte[2], + 2, + true)); Assert.Throws(() => new HsmsTransportFaultObservation( HsmsTransportFaultKind.IncompleteFrameTimeout, 1, HsmsSessionState.Connected, - new byte[HsmsTransportFaultObservation.MaxSnapshotBytes + 1])); + new byte[HsmsTransportFaultObservation.MaxSnapshotBytes + 1], + HsmsTransportFaultObservation.MaxSnapshotBytes + 1, + false)); + } + + [Fact] + public void Records_enforce_data_boundaries() + { Assert.Throws(() => new SecsTraceTransportFaultRecord( Epoch, @@ -186,6 +249,8 @@ public void Options_observations_and_records_enforce_data_boundaries() 17, SecsTraceFaultSampleDataClassification.RedactedPayload, 2, + 2, + false, new byte[] { 1, 2 }, new[] { new SecsTraceByteRedactionRange(0, 1) })); Assert.Throws(() => @@ -196,6 +261,8 @@ public void Options_observations_and_records_enforce_data_boundaries() 17, SecsTraceFaultSampleDataClassification.RedactedPayload, 4, + 4, + false, new byte[4], new[] { @@ -204,6 +271,22 @@ public void Options_observations_and_records_enforce_data_boundaries() })); } + [Fact] + public void Legacy_record_constructor_marks_snapshot_complete() + { + var record = new SecsTraceTransportFaultRecord( + Epoch, + HsmsTransportFaultKind.DecodeFailed, + HsmsSessionState.Connected, + 17, + SecsTraceFaultSampleDataClassification.RawPayload, + 2, + new byte[] { 1, 2 }); + + Assert.Equal(2, record.ObservedByteCount); + Assert.False(record.IsTruncated); + } + [Fact] public void Codec_enforces_record_text_and_snapshot_resource_limits() { @@ -249,12 +332,20 @@ public void Codec_enforces_record_text_and_snapshot_resource_limits() } private static HsmsTransportFaultObservation CreateObservation( - byte[] snapshot) - => new( + byte[] snapshot, + HsmsTransportFaultKind kind = HsmsTransportFaultKind.IncompleteFrameTimeout, + long? observedByteCount = null) + { + var actualByteCount = observedByteCount ?? snapshot.LongLength; + return new HsmsTransportFaultObservation( + kind, 17, HsmsSessionState.Connected, - snapshot); + snapshot, + actualByteCount, + snapshot.LongLength < actualByteCount); + } private static DateTimeOffset Epoch => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); @@ -271,6 +362,8 @@ private static void AssertRecordEqual( Assert.Equal( expected.ObservedSnapshotLength, actual.ObservedSnapshotLength); + Assert.Equal(expected.ObservedByteCount, actual.ObservedByteCount); + Assert.Equal(expected.IsTruncated, actual.IsTruncated); Assert.Equal(expected.Snapshot.ToArray(), actual.Snapshot.ToArray()); Assert.Equal(expected.RedactionRanges, actual.RedactionRanges); } diff --git a/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs b/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs index 5432440..b33c5cd 100644 --- a/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs +++ b/test/SecsFrame.Tests/StreamFrameHsmsTransportTests.cs @@ -175,13 +175,27 @@ public async Task Native_incomplete_frame_timeout_reports_HSMS_T8_reason() await events.DisposeAsync().ConfigureAwait(true); } - [Fact] - public async Task Enabled_native_T8_observation_preserves_session_and_snapshot() + [Theory] + [InlineData( + FrameErrorKind.DecodeFailed, + HsmsTransportFaultKind.DecodeFailed)] + [InlineData( + FrameErrorKind.DiscardedByResync, + HsmsTransportFaultKind.DiscardedByResync)] + [InlineData( + FrameErrorKind.IncompleteFrameOverflow, + HsmsTransportFaultKind.IncompleteFrameOverflow)] + [InlineData( + FrameErrorKind.IncompleteFrameTimeout, + HsmsTransportFaultKind.IncompleteFrameTimeout)] + public async Task Enabled_native_transport_fault_observation_preserves_metadata( + FrameErrorKind nativeKind, + HsmsTransportFaultKind expectedKind) { var connection = new FakeStreamConnection(); await using var transport = CreateTransport( connection, - enableT8FaultObservation: true); + enableTransportFaultObservation: true); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var events = transport.GetEventsAsync(cancellation.Token).GetAsyncEnumerator(); transport.Start(cancellation.Token); @@ -190,24 +204,84 @@ public async Task Enabled_native_T8_observation_preserves_session_and_snapshot() var snapshot = new byte[] { 0x00, 0x00, 0x00, 0x0A, 0x00 }; connection.RaiseFrameError( - FrameErrorKind.IncompleteFrameTimeout, - snapshot); + nativeKind, + snapshot, + observedByteCount: 9); snapshot[0] = 0xFF; var fault = await NextAsync(events).ConfigureAwait(true); - connection.Reconnect(); - var closed = await NextAsync(events).ConfigureAwait(true); Assert.Equal(HsmsTransportEventKind.TransportFaultObserved, fault.Kind); Assert.Equal(opened.SessionId, fault.SessionId); - Assert.Equal( - HsmsTransportFaultKind.IncompleteFrameTimeout, - fault.FaultKind); + Assert.Equal(expectedKind, fault.FaultKind); Assert.Equal( new byte[] { 0x00, 0x00, 0x00, 0x0A, 0x00 }, fault.Snapshot.ToArray()); - Assert.Equal(HsmsTransportEventKind.SessionClosed, closed.Kind); - Assert.Equal(opened.SessionId, closed.SessionId); - Assert.IsType(closed.Error); + Assert.Equal(9, fault.ObservedByteCount); + Assert.True(fault.IsTruncated); + await events.DisposeAsync().ConfigureAwait(true); + } + + [Fact] + public async Task Late_native_transport_fault_keeps_its_original_session() + { + var connection = new FakeStreamConnection(); + await using var transport = CreateTransport( + connection, + enableTransportFaultObservation: true); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var events = transport.GetEventsAsync(cancellation.Token).GetAsyncEnumerator(); + transport.Start(cancellation.Token); + connection.RaiseState(ConnectionState.Connected); + var originalSession = (await NextAsync(events)).SessionId; + connection.RaiseState(ConnectionState.Retry); + await NextAsync(events); + connection.RaiseState(ConnectionState.Connecting); + connection.RaiseState(ConnectionState.Connected); + var currentSession = (await NextAsync(events)).SessionId; + + connection.RaiseFrameError( + FrameErrorKind.DiscardedByResync, + new byte[] { 0xAA }, + sessionId: originalSession.Value); + var fault = await NextAsync(events); + + Assert.True(currentSession.Value > originalSession.Value); + Assert.Equal(HsmsTransportEventKind.TransportFaultObserved, fault.Kind); + Assert.Equal(originalSession, fault.SessionId); + Assert.Equal(HsmsTransportFaultKind.DiscardedByResync, fault.FaultKind); + await events.DisposeAsync().ConfigureAwait(true); + } + + [Theory] + [InlineData(8191, 8191, 8191, false)] + [InlineData(8192, 8192, 8192, false)] + [InlineData(8193, 8193, 8192, true)] + [InlineData(8192, 9004, 8192, true)] + public async Task Native_transport_fault_snapshot_is_bounded( + int nativeSnapshotLength, + long observedByteCount, + int retainedSnapshotLength, + bool isTruncated) + { + var connection = new FakeStreamConnection(); + await using var transport = CreateTransport( + connection, + enableTransportFaultObservation: true); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var events = transport.GetEventsAsync(cancellation.Token).GetAsyncEnumerator(); + transport.Start(cancellation.Token); + connection.RaiseState(ConnectionState.Connected); + await NextAsync(events); + + connection.RaiseFrameError( + FrameErrorKind.DiscardedByResync, + new byte[nativeSnapshotLength], + observedByteCount: observedByteCount); + var fault = await NextAsync(events); + + Assert.Equal(retainedSnapshotLength, fault.Snapshot.Length); + Assert.Equal(observedByteCount, fault.ObservedByteCount); + Assert.Equal(isTruncated, fault.IsTruncated); await events.DisposeAsync().ConfigureAwait(true); } @@ -537,8 +611,8 @@ public async Task Real_passive_reconnect_race_keeps_listener_available() private static StreamFrameHsmsTransport CreateTransport( FakeStreamConnection connection, - bool enableT8FaultObservation = false) - => new(connection, enableT8FaultObservation); + bool enableTransportFaultObservation = false) + => new(connection, enableTransportFaultObservation); private static StreamFrameHsmsTransport CreateControlledTransport( int port, @@ -884,10 +958,17 @@ public void RaiseState(ConnectionState state) public void RaiseFrameError( FrameErrorKind kind, - ReadOnlyMemory bytes = default) + ReadOnlyMemory bytes = default, + long? sessionId = null, + long? observedByteCount = null) => FrameError?.Invoke( this, - new FrameErrorEventArgs(kind, bytes)); + new FrameErrorEventArgs( + kind, + bytes, + null, + sessionId ?? CurrentSessionId, + observedByteCount ?? bytes.Length)); public void Emit(long sessionId, HsmsFrame frame) => _messages.Writer.TryWrite(new SessionMessage(sessionId, frame));