Skip to content

feat: 传输层抽象(serial / TLS)设计与实施计划(3.0 架构演进设计稿) #55

Open

Description

@CSJ608

背景与目标

StreamFrame v2.5.0 的插件化已覆盖帧定界(IFramer)与编解码(ICodec<TMessage>),但传输仍硬绑 TCP socket:StreamConnection 直接持有 Socket。本 issue 把"传输"抽为可插拔层,使:

  • TLS(SslStream,包装在 TCP 之上)与串口(System.IO.Ports,工业 SECS-I 场景)可作为传输驱动接入;
  • 现有 TCP 行为与全部公共 API 语义逐字节、逐条保证不变(现有 98×3 测试全绿为回归底线);
  • 沿用 feat: 增加会话感知的发送确认与消息上下文 #39 的工作流:本 issue 为设计稿,评审定稿后再实施。

非目标:不引入任何 SECS/HSMS 专用概念;不内置证书管理/续期、串口枚举发现;不做 UDP/WebSocket/蓝牙(抽象不为它们预留形状,但也不刻意堵死)。


一、现状盘点:socket 在 StreamConnection 里的全部触点

通读 src/StreamFrame/Connection/StreamConnection.cs(1157 行)后,socket 依赖共 7 处:

触点 位置 内容 去向
字段 L69–70 _socket / _server 改为 ITransportConnection? / ITransportListener?
ConnectAsync L271–296 按目标地址族建 socket(v4 字面量纯 v4 / 双栈)、连接、失败释放本次 socket 搬进 TcpTransportFactory(代码原样迁移)
AcceptAsync + InitServer L298–379 _acceptLock + _acceptLoopId 代次门控(#47 阶段二)、SO_REUSEADDR + Bind + Listen、accept 重试、单客户端关闭监听 锁与代次门控留在连接层(它们保护的是"谁拥有监听器对象",与传输无关);bind/listen/accept 搬进 TcpTransportListener
ConfigureSocket L381–412 非阻塞、接收缓冲、TCP KeepAlive(含 ns2.0 的 SIO_KEEPALIVE_VALS 分支) 搬进 TCP 传输(connect 与 accept 两条路径都归它)
ReceiveLoopAsync / ReceiveWithIdleTimeoutAsync L680–739 writer.GetMemory(SocketReceiveBufferSize) + socket.ReceiveAsync(memory) + 空闲超时包装;0 字节 = FIN → 重连 读调用换成 ITransportConnection.ReadAsync,其余不动
SendRawAsync L851–874 _sendLock 下循环 socket.SendAsync,按实际写出分片回调 RawBytesSent 写调用换成 ITransportConnection.WriteAsync(返回本次写出计数),循环与钩子不动
拆线(StopSessionCore / Shutdown) L660–675、L1134–1147 socket.Shutdown(Both) + Dispose;_server.Dispose() 换成 ShutdownAsync()(尽力优雅)+ Dispose()

会话状态机、消息通道、发送队列、解码循环、RetryDelayScheduler、指标——全部在 IO 之上,不触碰 socket。

二、抽象面形状:为什么既不是"大 ITransport"也不是"裸 Stream"

候选 A:ITransport 自带完整 IO 面(Connect/Accept/Read/Write/Abort 全在一个接口)——重新发明 Stream,SslStream / SerialPort.BaseStream / NetworkStream 本来就是 Stream,接入还要再包一层适配,公共面最大、文档与测试负担最大。否。

候选 B:纯 Stream + 连接工厂(工厂产出已连接的 Stream,连接核心只消费 Stream)——方向正确(TLS/串口天然是 Stream),但裸 Stream 契约有四个表达不了的语义:

  1. 可中止拆除:Socket.Dispose 会中止未决 IO(现在靠它解锁 ns2.0 上不可取消的接收);Stream.Dispose 无此承诺,SerialPort.Close 更有经典的"驱动有 pending 字节时挂死"问题,需要 DiscardBuffer + 后台关闭 + 超时的专用路径。
  2. 优雅关闭:现状 socket.Shutdown(Both);TLS 的对应物是 close_notify(SslStream.ShutdownAsync);串口没有。需要一个"尽力优雅关闭、失败由调用方记日志"的显式成员。
  3. 部分写计数:SendRawAsync 依赖 socket.SendAsync 返回本次写出字节数实现"按实际写出分片回调 RawBytesSent";Stream.WriteAsync 全量写或抛、无返回计数。直接换 Stream 会悄悄改变现有 TCP 的公共语义(约束禁止)。
  4. ns2.0/netfx 缺口:Stream 没有 SocketTaskExtensions 那样的 Memory 重载等价物,net48 上 TLS/串口的读写需要 byte[] 路径的内部 shim。

推荐:折中形状——"连接工厂 + 已建连接的极小自有接口"(Stream-centric 精神 + 承载 Stream 表达不了的语义)。TCP 实现就是把现有代码原样搬进去(字节级等价重构);TLS/串口是薄适配层。

三、公共 API 草图(主包,根命名空间)

/// 传输工厂:一条连接的传输来源。连接级(StreamConnection 构造时固定)。
public interface ITransportFactory
{
    /// 诊断描述:metrics endpoint 标签与重试日志用它("127.0.0.1:5000" / "tls://127.0.0.1:5000" / "COM3@9600")
    string Description { get; }

    /// 主动建立一条传输连接(TCP connect / TLS connect+握手 / 串口打开)。失败抛异常,重试归连接层。
    Task<ITransportConnection> ConnectAsync(CancellationToken ct);

    /// 创建监听器。不支持被动模式的传输(串口)抛 NotSupportedException。
    ITransportListener CreateListener();
}

/// 传输监听器:被动模式接受一条连接。
public interface ITransportListener : IDisposable
{
    /// 接受一条连接(TLS:内层 accept + 服务端握手,返回即握手完成)。失败抛异常,重试归连接层。
    Task<ITransportConnection> AcceptAsync(CancellationToken ct);
}

/// 一条已建立的传输连接:全双工字节管道 + 拆线语义。
public interface ITransportConnection : IDisposable
{
    /// 诊断用对端描述;无地址概念的传输(串口)为 null
    string? RemoteDescription { get; }

    /// 读一段字节;>0 = 字节数,0 = 对端正常关闭(EOF;串口不会出现,拔线表现为 IO 异常)
    ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct);

    /// 尽力写出一段字节,返回本次写出数(TCP 可能部分写,调用方循环续写;TLS/串口成功即全量)
    ValueTask<int> WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct);

    /// 尽力优雅关闭(TCP: Shutdown(Both);TLS: close_notify;串口: no-op)。可抛异常,调用方捕获记日志。
    Task ShutdownAsync();

    /// 立即拆除:放弃未决 IO、不走优雅关闭(串口挂死规避路径:DiscardBuffer + 后台 Close + 超时)。之后 Dispose 幂等。
    void Abort();
}

// 内建 TCP:现有 ConnectAsync/InitServer/ConfigureSocket/Accept 原样迁移
public sealed class TcpTransportFactory : ITransportFactory { /* ctor(IPAddress, int, TcpTransportOptions?, ILogger?) */ }

// TLS = 装饰器:升级任意 Stream 型内层传输(今天是 TCP,将来 NamedPipe/Unix 也可)
public sealed class TlsTransportFactory : ITransportFactory
{
    public TcpTransportFactory Inner { get; }  // ctor(TcpTransportFactory inner, TlsTransportOptions?, ILogger?)
}

public sealed class TcpTransportOptions   { /* TcpKeepAlive 三件套(从 StreamConnectionOptions 的对应语义拆出) */ }
public sealed class TlsTransportOptions  { /* TargetHost、RemoteCertificateValidationCallback、SslProtocols、
                                              ServerCertificate、RequireClientCertificate */ }

StreamConnection 新增构造重载(现有构造函数与 IStreamConnection / ISessionAwareStreamConnection 一字不动;旧构造内部组装 TcpTransportFactory,字段映射见第四节):

// 现有(不变,行为逐字节等价)
new StreamConnection<TMessage>(framing, codec, ipAddress, port, isActive, options, logger);

// 新增:任意传输
var tls = new TlsTransportFactory(
    new TcpTransportFactory(IPAddress.Parse("192.168.1.10"), 5000, new TcpTransportOptions { TcpKeepAlive = true }),
    new TlsTransportOptions { TargetHost = "equip-01" });
new StreamConnection<TMessage>(framing, codec, tls, isActive: true, options, logger);

ValueTask<int> 在 ns2.0 经 System.IO.Pipelines 传递的 System.Threading.Tasks.Extensions 获得(实现时复核依赖审计,不新增直接依赖)。

四、选项归属与 TCP-only 选项的处置

StreamConnectionOptions 成员 归属 自定义传输下的行为
TcpKeepAlive / KeepAliveTimeMs / KeepAliveIntervalMs TCP-only,映射进 TcpTransportOptions 文档化忽略(见下)
SocketReceiveBufferSize 连接级(读取块尺寸 + Pipe 段尺寸对齐 v2.5.0),TCP 实现兼用作 socket 接收缓冲 继续生效(对任何传输都是"读块/段尺寸")
ReceiveIdleTimeoutMs / IncompleteFrameTimeoutMs 连接级(读包装/解码层),传输无关 继续生效(串口强烈建议开启前者——见第五节)
AcceptFirstClientOnly 连接级监听策略(对任意 ITransportListener 语义成立:Dispose = 停止监听) 继续生效
retry 系列(ConnectRetryDelayMs 等) 连接级状态机 继续生效

TCP-only 选项在非 TCP 传输上:文档化忽略,不抛异常。 理由:options 是可跨连接共享的数据对象,"共享一份 options + 不同传输"是合理用法,抛异常会把它判死;静默忽略配一张生效矩阵写入 XML 文档 + README。若评审倾向 fail-fast,可降级为"设置即抛"(StreamConnection 构造时检查),请确认(见第十节 ②)。

TLS 的半开检测:内核 KeepAlive 在内层 TCP 上照常工作(TlsTransportFactory 透传内层 TcpTransportOptions),与应用层 ReceiveIdleTimeoutMs 互补,语义与裸 TCP 一致。

五、被动模式与状态机映射(问题 4)

状态机零改动:Connecting → Connected → Retry → … → Disconnected 不动。Connecting 的语义拓宽为"建立传输"——TCP connect、TLS 握手、串口打开都算;Retry 覆盖对端拒绝、握手失败、端口被占/设备未插(串口热插拔场景天然契合 ConnectRetryDelayMs 重试)。

传输 主动(isActive=true) 被动(isActive=false)
TCP connect(现状) bind/listen/accept(现状)
TLS 内层 connect + AuthenticateAsClient(握手计入 Connecting;握手挂起用 WaitAsync(ct) 包装,超时后 Abort() 底层连接中止握手——net7 前的 Authenticate 无 ct 重载) CreateListener 返回 TLS 监听器:内层 accept + AuthenticateAsServer,AcceptAsync 返回即握手完成;握手失败与 accept 失败同路径重试(重新 accept 下一个连接)。无 ServerCertificate 在 TlsTransportOptions 上则工厂构造时抛
串口 打开端口(Open(),失败→Retry 重试) 构造时抛 NotSupportedException(fail-fast:串口无监听概念,SECS-I 双端都是打开即用)

串口特有语义(写入文档):无 FIN/EOF——"对端正常关闭"不存在,拔线/驱动故障表现为 IOException/TimeoutException → 会话故障 → Retry(重开端口);半开连接无法由传输探测,必须开 ReceiveIdleTimeoutMs 或应用层心跳。有界 ReceiveQueueCapacity 在串口上不会产生 TCP 式背压,消费停滞可能导致驱动缓冲溢出丢字节——文档建议串口启用硬件流控(Handshake)或保持接收通道无界。

六、TFM 策略(问题 5)

核心抽象 + TCP + TLS 全部进主包(TFM 不变:netstandard2.0;net8.0;net10.0):SslStream 在三个目标上都有 API 面(ns2.0 ref 内置 System.Net.Security、netfx 内建 System.dll、net8+ 内置于共享框架),无新增包依赖。

串口单独成包 StreamFrame.Serial,多目标 net48;net8.0;net10.0:

  • 事实核查(与任务书的前提有出入,请评审知悉):System.IO.Ports 包确实有 netstandard2.0 资产,但该资产运行时仅 Windows(非 Windows 抛 PlatformNotSupportedException,即 Andrew Lock 批评过的"撒谎的 ns2.0 资产");跨平台(Windows + Linux)支持来自 net6/net8+ 资产;macOS 不支持。
  • 因此:net48 目标不引包,直接用 .NET Framework 内建的 System.IO.Ports.SerialPort(System.dll,2.0 起就有);net8/net10 目标引 System.IO.Ports 10.x(Windows + Linux)。不做 ns2.0 目标——避免发出一个跨平台名不副实的资产。ns2.0-only 的中间库接不了串口包,属可接受限制(串口消费者本来就是具体应用)。
  • net48 目标在 Linux CI 上可构建:加 Microsoft.NETFramework.ReferenceAssemblies(PrivateAssets,仅构建期)。
  • 串口包的 ns2.0/netfx Stream Memory 缺口与 TLS 共用一个内部 byte[] shim(见下)。

netfx 的 Stream Memory shim:StreamTransportCompat.ReadAsync(Stream, Memory, ct) / Write——ns2.0/net48 编译目标用持久 byte[] + 一次拷贝;net8+ 直走 Memory 重载。只影响 netfx 上的 TLS/串口读路径(每次读多一次 memcpy),TCP 路径零改动(socket 直连,不经 Stream)——与仓库既有兼容取舍一致(PolySharp、SocketTaskExtensions 回退、StxEtx 手工扫描)。

CI 影响:串口测试并入现有 test/StreamFrame.Tests(测试项目已多目标 net8/net10/net48,加 ProjectReference 按目标自动匹配)——ci.yml 的 dotnet test StreamFrame.slnx 循环自动带上,不新增必需检查名(AGENTS.md 警告的分支保护检查名问题不触发)。若评审倾向独立测试工程,则需同步改分支保护必需检查名,请确认(第十节 ⑦)。

AOT 门禁:AotSmoke 增加 TLS 路径(SslStream 无反射重路径,预期绿);串口包 AOT 预期安全(SerialPort 无反射),首版不设门禁、留观察。

七、现有并发保证的传输无关性逐条核对(约束 1 审计)

机制 所在层 是否触碰 socket 抽象后 风险点
会话编号/纪元线性化(CommunicationStateChanging:分配/归零先于 Connected/Retry 发布,CurrentSessionId 语义) 纯状态机 否 不动 无——传输重构不进这一层
_acceptLoopId 代次门控(#47 阶段二) 连接层 间接(保护 _server 所有权) 锁与代次检查留在连接层,保护对象换成 _listener;InitServer 的 bind/listen/SO_REUSEADDR 搬进 TcpTransportListener,bind 失败从 CreateListener() 抛出、走既有异常路径(StartAsync catch → 退避重试)——与现状 InitServer 抛出路径等价 bind 失败时监听器对象不得残留(TCP 实现内自清理,对应现状"Bind 抛出时 _server 未赋值"语义)
未完成帧超时(IncompleteFrameTimeoutMs,T8 类) FrameDecoder 解码层 否 不动 无
接收空闲超时(ReceiveWithIdleTimeoutAsync) 读循环包装 是(socket.ReceiveAsync) 泛化为包装 ITransportConnection.ReadAsync;OCE→SessionFaultException 与"Windows 取消折算 0 字节"防御原样保留(防御性,对 Stream 语义无害) 无
发送串行化(_sendLock)/ 会话绑定条目三态 CAS / 提交点=认领 / "绝不跨会话重放" Channel + 状态字 否(SendRawAsync 只换底层调用) 不动;WriteAsync 返回计数保住"循环续写"结构 无
RawBytesReceived/RawBytesSent 钩子 收发路径 是 Received 按读块回调(不变);Sent 按传输写出粒度:TCP 部分写计数原样保留(语义不变),TLS/串口为整帧粒度(SslStream.WriteAsync 无部分计数) 文档写明粒度差异;TLS 回调的是明文(加密前/解密后),密文不可见——请确认口径(第十节 ⑥)
会话拆线(StopSessionCore/Shutdown 的 Shutdown(Both)+Dispose) 拆线路径 是 换 ShutdownAsync()(尽力,catch+log 与现状一致)+ Dispose();TCP 实现逐字节等价;串口 Abort 走防挂死路径 串口 Close 挂死是经典坑,Abort 必须 Discard + 后台线程 + 超时兜底(实现项)
WaitForConnectedAsync / GetMessages 通道生命周期 状态机/通道 否 不动 无
metrics endpoint 标签 ConnectionMetrics 间接 取 factory.Description;内建 TCP 路径生成与现状完全相同的字符串("{ip}:{port}"),现有仪表盘不受影响 自定义传输下标签值变化(必然,文档化)
Pipe 段尺寸对齐(v2.5.0) StartSession 否 不动(继续用 SocketReceiveBufferSize) 无

结论:约束 1 的全部保证都是传输无关的,前提是上述三处搬运(监听器创建路径、读调用、写调用)保持异常路径等价——这将是 PR-A 评审的重点清单。

八、测试策略(问题 6)

回归底线:现有 98×3(net8.0 / net10.0 / net48)测试原样全绿,一个不改。

新增:

  1. 等价性:显式 TcpTransportFactory 构造 vs 旧构造,双连接对同一裸 TCP 回环对端,状态序列/消息收发/RemoteIpAddress/metrics 标签一致。
  2. Fake 传输(内存双工字节管道 + 故障注入:读/写抛异常、EOF 注入、部分写、握手挂起):在无 socket 下测状态机——连接失败退避重试、EOF→Retry、空闲超时、ShutdownAsync 失败不阻断拆线、僵尸接受循环复现 bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 场景(fake listener 上两代 accept 竞速,断言无泄漏监听器)。这套测试三个 TFM 全跑。
  3. TLS e2e(net8/net10):运行时 CertificateRequest 生成一次性自签证书(无密钥进仓)做回环——收发往返、杀内层 socket 触发重连、证书校验拒绝路径、握手挂起→Abort、RawBytesReceived 收到明文断言。服务端用 TlsTransportFactory 被动模式全覆盖状态机。
  4. TLS on net48:运行时 e2e 跳过(CertificateRequest 无 netfx 实现;进仓 PFX 或 CI 生成证书两案成本/整洁度不佳——请确认,第十节 ④);ns2.0 的 Stream Memory shim 用 MemoryStream 单测覆盖正确性,net48 兼容由既有矩阵构建保证。
  5. 串口:选项→SerialPort 属性映射、无监听 NotSupportedException、端口打开失败→Retry 状态机(内部缝隙注入 fake Stream 模拟 BaseStream,SerialTransportFactory 预留 internal 构造缝隙)。真实 COM 不进 CI:com0com 本地手动验证写成 docs 手册(两台设备互测的 checklist)。
  6. soak/混沌:每夜 soak 增加 TLS 回环变体(复用 Soak_ReconnectRacing_LongRun 与随机故障混沌的全部动作);fake 传输变体可选(状态机并发正确性验收面)。
  7. 基准:bench/EndToEndBenchmarks 改造前后各跑一轮,TCP 吞吐/延迟差异须在噪声内(每次 IO 多一次虚调用,量级可忽略,以数据说话)。

九、需要评审确认的决策点(对应 #39 的"请确认"环节)

  1. ITransportConnection 形状:小接口(推荐,第二节四缺口论证)vs 裸 Stream(更少概念但丢部分写可见性/Abort/优雅关)。特别确认:WriteAsync 返回部分写出计数这个形状。
  2. TCP-only 选项在自定义传输下文档化忽略(推荐)vs 构造时抛异常。
  3. 串口包 TFM:net48;net8.0;net10.0(推荐,第六节论证)vs 仅 net8.0;net10.0(受众收缩、net48 工业场景损失大)。
  4. net48 的 TLS e2e:跳过运行时测试(推荐)vs 进仓一次性测试证书 vs CI Windows 作业生成证书注入。
  5. IpAddress/Port/RemoteIpAddress 在非 TCP 传输的取值:接口不动;TCP/TLS 工厂构造时照常填充(工厂暴露地址信息,连接层识别采用),其它传输 IPAddress.None/0/null + 文档。可接受?
  6. TLS 下 RawBytes* = 明文、密文不可见:HEX 日志口径确认。
  7. 测试并入现有测试项目(推荐,无新必需检查名)vs 独立串口测试工程(需同步改分支保护必需检查名)。
  8. 版本号 3.0.0(主包与串口包同版起步;"3.0 级架构演进"定位)。

十、实施切分

定稿后按三个 PR、每个独立可合(都走 AGENTS.md 标准流程):

  • PR-A(核心重构,行为等价):传输三接口 + TcpTransportFactory/TcpTransportListener/TcpTransportConnection(代码原样迁移)+ StreamConnection 改造(字段/四条调用路径/新构造重载)+ Fake 传输 + 等价性测试。
  • PR-B(TLS):TlsTransportFactory + TLS 测试 + soak 变体 + AotSmoke TLS 路径 + README/DESIGN 传输章节。
  • PR-C(串口包):src/StreamFrame.Serial + CI 适配(无新检查名)+ docs 手册 + release.yml 增加第三个 csproj 的版本一致性校验与打包。

CHANGELOG Unreleased 按新增/变更分类记录;API 兼容性以"现有构造函数与两个接口签名不动"为硬约束自查。

验收标准

  • 现有 98×3 全绿(零修改);新增测试全绿。
  • 旧构造路径 TCP 行为逐字节等价:全部既有测试 + 基准在噪声内。
  • 第七节审计表的每一行在 PR 描述中逐条对应到实现与测试证据(v2.3.0 评审教训:并发边界写清线性化点)。
  • TLS 回环进入每夜 soak 且绿;混沌动作全复用。
  • AOT 门禁含 TLS 路径且绿。
  • 文档(README 双语 + DESIGN.md)含:选项生效矩阵、传输语义差异表(EOF/半开/背压)、串口手册、TLS 证书与校验回调指引。

参考:#39(能力接口设计先例与评审流程)、#47(代次门控——本设计必须保住其语义)、PR #42(#39 的实现)、CHANGELOG 2.3.1(v2.3.0 评审教训:线性化点、双检、提交点)。

Activity

  1. CSJ608 commented on Aug 29, 2026

    @CSJ608
    OwnerAuthor

    评审结论

    整体方向认可:把传输来源抽为工厂、把已连接字节通道收敛为小型 ITransportConnection,比直接暴露裸 Stream 更能保住现有 TCP 的部分写、强制中止和监听生命周期语义。

    但当前设计稿还有几处会影响实现正确性或公共契约的缺口,建议先修订后再进入 PR-A。

    定稿前需要补齐

    1. 串口被动模式目前不能按草图 fail-fast。

      当前 StartAsync 会捕获连接/监听阶段的异常并持续重试。如果只让 CreateListener() 抛 NotSupportedException,实际行为会是无限重试,而不是“构造时抛”。建议引入可选能力接口(如 ITransportListenerFactory),或在工厂上提供明确的被动模式能力元数据;StreamConnection 构造时据此校验,监听器仍在 Start 阶段创建。

    2. ShutdownAsync 与当前同步拆线模型不兼容,需要先定义完整时序。

      目前 StopSessionCore 在 _sessionGate 内同步执行,并最多等待会话任务 2 秒;DisposeAsync 也实际同步完成。若先等待任务、后中止传输,TLS/串口的未决读取可能让每次重连白等 2 秒;若在锁内同步等待 TLS close_notify,又会扩大阻塞/死锁风险。

      建议明确区分:

      • 会话故障、显式 Reconnect、生命周期取消:Abort() 先解除未决 IO,再等待任务退出,最后 Dispose();不尝试 TLS 优雅关闭。
      • 用户显式 DisposeAsync:可尝试有界的优雅关闭,超时或失败立即回退 Abort();不能无限等待。

      如果首版不准备把核心拆线状态机整体异步化,则 TLS close_notify 不应作为硬性保证。

    3. 地址兼容承诺缺少对应的抽象。

      现有公共 API 是强类型的 IPAddress IpAddress、int Port、string? RemoteIpAddress,而草图只有 Description / RemoteDescription 字符串,无法可靠填充,也不应通过解析描述字符串或识别 TcpTransportFactory 具体类型实现。

      建议增加可选 endpoint 元数据能力,例如 IPEndPoint? ConfiguredEndPoint、EndPoint? RemoteEndPoint;TCP/TLS 实现该能力,串口不实现。若不增加能力接口,则必须明确所有自定义传输构造路径的哨兵值,并接受 TLS 地址信息也无法通过通用抽象获得。

    4. TLS 的 TFM/API 矩阵需要修正。

      netstandard2.0 和 .NET Framework 4.8 的引用 API 中没有 SslStream.ShutdownAsync、SslClientAuthenticationOptions / SslServerAuthenticationOptions 及现代的带 CancellationToken 认证重载。TLS 可以支持这些目标,但不能按统一的现代 API 路径实现。

      设计稿应明确:旧目标使用传统认证重载,并通过关闭底层连接中止握手;net8+ 使用现代 options + cancellation API。ShutdownAsync 在旧目标上只能降级为关闭/释放语义,不能承诺显式发送 close_notify。

    5. TLS“通用装饰器”与当前 API 草图互相矛盾。

      TlsTransportFactory.Inner 被写死为 TcpTransportFactory,这实际是 TLS-over-TCP,而不是可装饰任意 Stream 型传输。首版建议诚实限定为 TLS-over-TCP,避免为了未来场景扩大抽象;以后出现 NamedPipe/Unix Socket 的真实需求再泛化。

      另外,若要支持 mTLS,RequireClientCertificate 只覆盖服务端要求客户端证书,客户端侧仍需要证书集合/选择回调;否则首版应明确只支持单向 TLS,或补齐客户端证书配置。

    6. 传输接口必须补充第三方实现契约与防御。

      至少写清:

      • Dispose 必须解除未决 accept;
      • Abort 必须及时解除未决读写并且幂等;
      • 非空 buffer 写成功时返回值必须满足 1..buffer.Length;
      • 允许一个 reader 与一个 writer 并发,单方向不要求并发安全;
      • ReadAsync 返回 0 只表示 EOF。

      核心层也应防御 WriteAsync 对非空输入返回 0 或越界计数,直接判定传输故障,避免第三方实现令发送循环永久空转。

    测试与 TFM 建议

    1. 不建议跳过 net48 TLS e2e。 net48 正好消费主包的 netstandard2.0 资产,是兼容路径风险最高的目标。建议提交一张明确标注“仅测试用途”的自签名 PFX;测试私钥不是生产秘密。至少覆盖握手、双向收发、校验拒绝和断线重连。

    2. 串口 TFM 先做最小验证再定。 System.IO.Ports 10.x 确实发布 netstandard2.0、net462 和现代 .NET 资产,但运行时会按最终应用 TFM/RID 选择实现。建议先验证“netstandard2.0 类库 -> net8 Linux 应用”的 restore、加载、打开伪终端行为,再决定是否排除 StreamFrame.Serial 的 netstandard2.0 资产。当前“ns2.0 运行时仅 Windows”的表述过于绝对。

    3. 串口真实 IO 不必依赖实体 COM:Linux CI 可以考虑用 PTY 做一个最小 e2e;Windows 的 com0com 继续作为手工验证。属性映射和 fake Stream 测试不能覆盖原生驱动资产选择、打开/关闭及拔线异常路径。

    4. Fake transport 测试应额外覆盖:ConnectAsync 成功后、赋给 _connection 前发生取消/代次失效时,新连接必须被释放;TLS 服务端握手失败时,已 accept 的内层连接必须释放,然后继续接受下一个客户端。

    对原 8 个决策点的建议

    1. 保留小型 ITransportConnection,保留 WriteAsync 的部分写计数;不退回裸 Stream。
    2. TCP-only 旧选项在自定义传输路径文档化忽略可以接受,但更清晰的长期方向是标记为兼容遗留项;新工厂只读取 TcpTransportOptions,避免同一参数有两个权威来源。
    3. StreamFrame.Serial 保留 net48;是否增加 netstandard2.0 待最小验证结果决定。
    4. net48 TLS e2e 不跳过,采用测试证书。
    5. 使用可选 endpoint 元数据能力填充 TCP/TLS 地址;其它传输返回 IPAddress.None / 0 / null 并文档化。
    6. TLS 下 RawBytes* 定义为 framing 看到的明文字节,密文不暴露;名称虽然沿用,但 XML 文档要从“Socket 原始字节”改成“传输交给 framing 的字节”。
    7. 测试继续并入现有测试工程,避免无必要地改变必需检查名。
    8. 如果现有构造函数和公共接口保持二进制/源码兼容,建议版本为 2.6.0;“架构变化大”本身不是 SemVer major 的理由。只有主动做公共破坏性调整时再使用 3.0.0。

    建议负责人确认的三个方向

    1. TLS close_notify 是否为首版硬性保证?建议否:故障/重连优先可靠中止;仅显式异步释放尝试有界优雅关闭。
    2. 版本是否坚持 3.0.0?建议 2.6.0,除非定稿后存在明确的公共破坏性变化。
    3. 是否接受先做一个小型验证工程,再决定串口包是否发布 netstandard2.0 资产?建议接受,用实测替代对 NuGet 资产选择的推断。

    完成上述修订后,我认为可以批准进入 PR-A。PR-A 的评审重点应放在:监听器代次所有权、连接建立后取消时的资源释放、Abort 的时序,以及旧 TCP 构造路径的字节与异常等价性。

  2. CSJ608 commented on Aug 29, 2026

    @CSJ608
    OwnerAuthor

    回执:设计稿 v2 处置记录(评审意见逐条核对后)

    评审指出的六个缺口全部成立,其中五条照单修订;第 2 条(拆除时序)结论采纳、药方修正(理由见下)。维护者已定稿:设计冻结,实施暂缓、待排期后按 PR-A/B/C 开工。

    逐条处置

    1. 串口被动模式 fail-fast 缺机制 —— 采纳。 已核实 StartAsync 的通用 catch(StreamConnection.cs:244)会把 NotSupportedException 吞进无限重试。修订:CreateListener 从 ITransportFactory 移除,拆出能力接口 ITransportListenerFactory : ITransportFactory { ITransportListener CreateListener(); }(与本仓库 IStreamingFramer / IFrameDiscardReporting / ISessionAwareStreamConnection 的能力接口先例同构);StreamConnection 构造时校验 isActive=false ⇒ 工厂必须实现该接口,否则构造即抛;监听器仍在 Start 阶段惰性创建(与现状 InitServer 时机一致)。

    2. 拆除时序 —— 病因采纳,修正方案如下。 承认:串口/netfx-TLS 的未决读不认 ct,照搬现状"等任务 2 秒 → 再关传输"会每次重连白等满 2 秒。但评审建议的"故障路径 Abort() 先行"不能全局套用:今天故障拆线是 cancel → 任务退出 → Shutdown(Both)(发 FIN)→ Dispose,改 Abort-first 后 TCP 对端看到的是 RST/无 FIN,直接违背 PR-A"旧 TCP 路径字节与异常等价"的硬约束。修订方案:

      • 把代码库既有的"取消 = 放弃等待,孤儿操作由 Dispose 收尾"模式(NetStandard20Shims.WithCancellation 已对 netfx socket 这么做)升格为 ITransportConnection.ReadAsync 的 ct 契约:ns2.0/netfx/串口的 shim 统一用 abandonment 包装,任务在 ct 触发时立即以 OCE 退出;
      • 核心拆除顺序一行不改(cancel → 等任务(≤2s)→ 尽力 ShutdownAsync → Dispose)——TCP 逐字节等价,串口/netfx-TLS 任务秒退,2 秒不再白等;
      • 仅用户显式 DisposeAsync 做有界优雅关闭尝试(真 close_notify 仅 net8+ 可用),超时/失败回退 Abort();close_notify 首版不作硬性保证(采纳方向确认 ①);
      • 附带发现并纳入:abandonment 丢弃的操作若以异常收场会成为未观察任务异常(现有 netfx socket 路径已存在此潜在问题,netfx 默认不崩故未暴露);新 shim 统一经 ObserveLater 模式观察,收口这一隐患。
    3. 地址信息缺抽象 —— 采纳。 增加可选能力:工厂侧 ITransportEndPointInfo(配置端点 IPEndPoint?,主动=远端/被动=监听,对应 IpAddress/Port 语义)、连接侧 RemoteEndPoint 能力;TCP 实现前者、TLS 经 Inner 透传,串口均不实现(IPAddress.None/0/null,文档化)。RemoteIpAddress 改读连接的能力接口(替代对 _socket.RemoteEndPoint 的直读)。不解析描述字符串、不识别具体工厂类型。

    4. TLS 的 TFM/API 矩阵 —— 采纳。 定稿两套握手实现:net8+ 用 SslClient/ServerAuthenticationOptions + 带 ct 重载;ns2.0/net48 用经典 AuthenticateAsClient/Server 重载 + WaitAsync(ct) 包装 + 取消时关闭底层连接中止握手。ShutdownAsync 在旧目标降级为 Dispose 语义(与第 2 条的"不作硬性保证"一致)。

    5. TLS 定位 —— 采纳。 首版诚实定版 TLS-over-TCP:TlsTransportFactory.Inner 类型即为 TcpTransportFactory;待 NamedPipe/Unix 等真实内层需求出现再泛化。mTLS 补齐而非收缩:TlsTransportOptions 增加 ClientCertificates(客户端证书集合)与 LocalCertificateSelectionCallback——两代握手 API 均有对应参数,成本低于砍成单向 TLS。

    6. 第三方实现契约与防御 —— 采纳。 六条契约全部写入接口 XML 文档(Dispose 解除未决 accept;Abort 及时解除未决读写且幂等;非空写成功返回 1..buffer.Length;单 reader + 单 writer 并发模型;ReadAsync 返回 0 仅指 EOF)。核心层防御:WriteAsync/ReadAsync 对非空缓冲返回 0 或越界计数 → 判定传输故障(防发送循环 buffer = buffer[n..] 永久空转)。

    测试与 TFM 处置

    • net48 TLS e2e 保留,提交明确标注"仅测试用途"的自签 PFX(测试私钥非生产秘密);覆盖握手、双向收发、校验拒绝、断线重连。实现时对 CodeQL 硬编码凭据告警做对应标注/忽略条目。
    • 串口 ns2.0 目标待 spike 定:评审人对 NuGet 资产选择的指正成立(ns2.0 类库被 net8 Linux 应用消费时会解析到 System.IO.Ports 的 net8 资产,Linux 可用)——原稿"ns2.0 运行时仅 Windows"表述过于绝对,仅对只解析 ns2.0 资产的运行时(netcoreapp3.1/netfx)成立。PR-C 先做"ns2.0 库 → net8 Linux 应用"最小验证再定。
    • PTY e2e:PR-C spike 验证(socat 造 pty 对 + SerialPort 开 /dev/pts),CI 先 continue-on-error 起步、验证稳定后再转正;Windows com0com 保持手工验证。
    • 补充的 fake 测试场景采纳:① ConnectAsync 成功后、赋给连接字段前发生取消/代次失效 → 新连接必须释放;② TLS 服务端握手失败 → 已 accept 的内层连接必须释放、继续接受下一客户端(bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 与 ConnectAsync 失败防泄漏教训在新接缝上的重演)。

    八个决策点定稿

    # 定稿
    ① 小型 ITransportConnection + WriteAsync 部分写计数,保留;不回裸 Stream
    ② TCP-only 旧选项文档化忽略;XML 文档级标记"遗留别名,新代码请用 TcpTransportOptions"。不加 [Obsolete]——会让存量 TreatWarningsAsErrors 用户升级即编译失败,属变相破坏性变更
    ③ StreamFrame.Serial 保留 net48;ns2.0 待 spike
    ④ net48 TLS e2e 用测试 PFX,不跳过
    ⑤ endpoint 可选能力接口填充;其余传输 None/0/null 文档化
    ⑥ RawBytes* 口径改为"传输交给 framing 的字节"(TLS 下为明文),XML 文档同步改写;密文不暴露
    ⑦ 测试并入现有测试工程,不改必需检查名
    ⑧ 版本号 3.0.0(维护者决定)。评审人的 SemVer 论证成立(本次全为增量、无公共破坏,2.6.0 更严格合规),但维护者选择以 3.0.0 表达传输插件化这一架构演进的定位——知情决策,覆写建议

    状态

    • 设计定稿(本回执 + 原稿第二节起按上述修订);实施暂缓,待维护者排期。
    • 开工时按原切分 PR-A(核心重构,行为等价)→ PR-B(TLS)→ PR-C(串口包)。
    • PR-A 评审重点采纳评审人清单:监听器代次所有权、连接建立后取消时的资源释放、Abort 时序(按第 2 条修正后的模型)、旧 TCP 构造路径的字节与异常等价性。
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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions