Skip to content

feat: 为 FrameError 提供原始会话归属与快照完整性元数据 #56

Closed

Description

@CSJ608

你想解决什么问题?

SecsFrame 正在扩展 HSMS 传输故障的可观测、Trace 导出与重放能力。当前只能对 T8(IncompleteFrameTimeout)做受限支持;若要安全覆盖 FrameErrorKind 的全部错误类型,还缺少两类由 StreamFrame 才能准确提供的事实。

  1. 错误的原始会话归属

    FrameErrorEventArgs 目前没有携带产生错误的 TCP 会话 ID。下游在回调中读取 ISessionAwareStreamConnection.CurrentSessionId,得到的是回调时刻的当前会话,而不是检测该错误的 FrameDecoder<TMessage> 所属会话。连接重建、会话替换或旧解码器的延迟事件发生时,下游无法严格证明错误属于哪个会话。

    这对 DiscardedByResync 尤其重要:它未必伴随立即断开,不能依靠后续连接状态变化反推出来源会话。FrameDecoder<TMessage> 内部已有 _sessionId,但该信息在构造 FrameErrorEventArgs 时被丢弃。

  2. 错误字节快照是否完整

    IncompleteFrameTimeout 和 IncompleteFrameOverflow 只把最多 8192 字节的快照放进 FrameErrorEventArgs.Bytes。内部当时知道实际待处理字节数(pendingBytes / buffer.Length),但事件没有公开原始字节数或截断标记。下游因此无法诚实地区分“完整的 8192 字节错误数据”和“更长数据的 8192 字节前缀”。

下游用例见 SecsFrame PR #40:
CSJ608/SecsFrame#40

当前路线图说明:
https://github.com/CSJ608/SecsFrame/blob/main/docs/ROADMAP.md

你的设想(可选)

希望 FrameError 能提供以下语义;具体 API 形式可以按 StreamFrame 的兼容性要求决定:

public sealed class FrameErrorEventArgs : EventArgs
{
    public long SessionId { get; }
    public long ObservedByteCount { get; }
    public bool IsTruncated { get; }
}

也可以保留现有事件,并在 ISessionAwareStreamConnection<TMessage> 上增加会话感知的错误事件。关键不是属性名称,而是这些契约:

  • 每个由 StreamConnection 发出的 FrameError 都携带检测错误的 FrameDecoder 实例所绑定的正数会话 ID,而不是在事件投递时读取的 CurrentSessionId。
  • 连接重建后,即使旧会话事件延迟到达,它仍保留旧会话 ID,不能被标记为新会话。
  • 上述会话归属适用于 DecodeFailed、DiscardedByResync、IncompleteFrameOverflow、IncompleteFrameTimeout 四种 FrameErrorKind。
  • 原始观测字节数满足 ObservedByteCount >= Bytes.Length;IsTruncated 等价于 Bytes.Length < ObservedByteCount。
  • DecodeFailed 和 DiscardedByResync 当前提供完整字节时,两者相等且 IsTruncated == false。
  • timeout/overflow 使用内部已知的实际待处理字节数,并在超过 8192 字节快照上限时标记截断。
  • 现有 Bytes 的复制与生命周期保证、Exception 行为,以及连接断开/重试策略保持不变。
  • 如可行,请通过新增构造重载、可选接口事件或其他方式尽量保持现有使用方的源码/二进制兼容。

建议验收覆盖:

  • 四种 FrameErrorKind 都能报告来源会话 ID。
  • 会话重建/替换测试证明旧会话错误不会被归到新会话。
  • timeout 和 overflow 在大于 8192 字节时报告实际字节数并标记截断。
  • 不超过 8192 字节时,原始字节数等于 Bytes.Length 且不标记截断。
  • XML 文档明确来源会话和快照完整性的语义。

这项能力到位后,SecsFrame 可以在默认关闭的前提下,把有界故障观测扩展到其余错误类型,并对跨会话过滤、Trace 导出和重放给出可验证的语义;脱敏、容量和 payload 默认拒绝策略仍由 SecsFrame 自己实现。

Activity

  1. CSJ608 commented on Aug 29, 2026

    @CSJ608
    OwnerAuthor

    评审结论:需求合理,建议接受 ✅

    对照现有框架逐条核实后,两项诉求都成立,且实现成本低(两个标量 + 一个 long,全部只在错误路径上)。以下是逐项评估与几个契约细节的修正意见。


    一、会话归属(SessionId)——痛点属实,且是既有设计的收口

    现状核实(与 issue 描述完全一致):

    • FrameDecoder 已持有 _sessionId(构造时传入),但四处构造 FrameErrorEventArgs 的位置(DecodeFailed / Overflow / Timeout / DiscardedByResync)全部丢弃了它。
    • 下游在回调里读 CurrentSessionId 确实无法证明归属,且比 issue 描述的还要糟一点:Retry/Disconnected 发布时 CurrentSessionId 已归零为 0——超时/超限事件的投递与状态发布之间存在竞速窗口,下游读到 0 既非旧会话也非新会话,直接无法归属。
    • 延迟事件不是理论场景:DiscardedByResync 与 SkipFrame 策略下的 DecodeFailed 都不断开、解码循环继续消费;用户并发 Reconnect() 时,旧解码器在拆除等待窗口内(StopSessionCore 最长等 2s)仍可能上报错误,此刻 CurrentSessionId 已是新会话编号——误归到新会话正是 issue 点名要防的情形,代码路径上真实存在。

    架构一致性论证(这是本评审支持接受的最强理由):
    SessionMessage<TMessage>.SessionId 存在的意义就是"旧会话解码任务迟到投递的消息,仍携带旧会话的编号"(ISessionAwareStreamConnection / SessionMessage 的 XML 契约)。消息通道已经做了会话归属,错误通道没有——这是同一场景下的既有不一致。本次增补是补齐,不是开新口子。

    实现是构造性保证,不是协议:事件参数在解码器内部构造,天然绑定该解码器实例的会话;旧会话事件无论多晚到达都带旧编号,不需要锁、快照或投递时校验。四种 Kind 全部在解码器内发出,_sessionId 无遗漏路径。

    二、快照完整性(ObservedByteCount / IsTruncated)——缺口真实,值在内部现成

    • ErrorSnapshotBytes = 8192 上限存在;timeout 用 pendingBytes(实际缓冲全长)、overflow 用 buffer.Length,内部值都在手边,只是没放进事件。零热路径成本(仅错误路径携带)。
    • 下游 SecsFrame PR docs: LOGO 资产对齐 social preview 定稿(波上穿环洞 + 渐变底) #40 已明确因此只能把 T8 快照解释为"不可解释的前缀",无法区分"恰好 8192 字节的完整错误数据"与"更长数据的前缀"。需求有真实消费方,不是投机性 API。

    三、契约细节的修正意见(实现前需要对齐的点)

    1. ObservedByteCount 语义按 Kind 分化,XML 文档必须给语义表:

      Kind ObservedByteCount 含义
      DecodeFailed 帧内负载字节数(不含定界字节)
      DiscardedByResync 本次被丢弃的字节数
      IncompleteFrameTimeout 超时判定时缓冲中半帧的全部字节数(含已收到的长度头部分)
      IncompleteFrameOverflow 超限判定时的缓冲字节数
    2. DecodeFailed / DiscardedByResync 的"完整拷贝"现状要写死进契约:这两类目前是全量拷贝、无 8192 上限。注意 StxEtxFramer 的 discarded 理论可达缓冲全长(噪声 + 被中止半帧),并非小数据。按现状如实公开 ObservedByteCount == Bytes.Length && IsTruncated == false 即可;若未来有人想给 DiscardedByResync 统一加 8192 截断,那是行为变更,需另行讨论。

    3. IsTruncated 建议在构造函数内自动计算(= bytes.Length < observedByteCount),不做独立传参,从根上杜绝两者不一致。旧构造重载委托新重载(observedByteCount: bytes.Length),不变式自动成立——这同时满足 issue 的源/二进制兼容要求(sealed 类 + 保留旧构造签名)。

    4. SessionId 取值域写清:与 CurrentSessionId / SessionMessage.SessionId 同一计数器(正数、单调、有间隔、不复用),可跨 API 直接关联比对。由连接发出的事件恒 > 0;旧构造重载默认 0 仅出现在用户自建的事件参数中(文档标注 0 = 未提供)。

    5. 方案选型:同意 issue 首选——直接增补 FrameErrorEventArgs,不建议"在 ISessionAwareStreamConnection 上另开会话感知错误事件"的替代方案:同一事实两个事件源,订阅者容易漏订/双收;FrameError 本就是连接级单事件,增补字段已满足会话感知。ns2.0 目标无兼容障碍(纯标量属性)。

    6. 测试验收补充一条(issue 四条都可行,现有基建能承载):DiscardedByResync 在旧会话发生、随后会话重建,事件仍带旧编号——这是四个 Kind 里唯一"不断开还继续解码"的,跨会话延迟投递窗口最大。另外现有 SnapshotIsCappedAtLimit 测试可顺手加断言 IsTruncated == true && ObservedByteCount == 9004(4 字节头 + 9000 正文)。


    四、发布

    纯增量、无破坏性变更,按语义化版本走 2.6.0 minor,正常走 PR + build-test 绿 + Unreleased 段。

    issue 对上游代码的事实陈述(_sessionId 被丢弃、8192 上限、四种 Kind 的边界)经核对全部准确,无需要纠正的前提。可以进入实现。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions