Repository navigation
feat: 传输层抽象(serial / TLS)设计与实施计划(3.0 架构演进设计稿) #55
Description
背景与目标
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 契约有四个表达不了的语义:
- 可中止拆除:
Socket.Dispose会中止未决 IO(现在靠它解锁 ns2.0 上不可取消的接收);Stream.Dispose无此承诺,SerialPort.Close更有经典的"驱动有 pending 字节时挂死"问题,需要DiscardBuffer+ 后台关闭 + 超时的专用路径。 - 优雅关闭:现状
socket.Shutdown(Both);TLS 的对应物是close_notify(SslStream.ShutdownAsync);串口没有。需要一个"尽力优雅关闭、失败由调用方记日志"的显式成员。 - 部分写计数:
SendRawAsync依赖socket.SendAsync返回本次写出字节数实现"按实际写出分片回调RawBytesSent";Stream.WriteAsync全量写或抛、无返回计数。直接换 Stream 会悄悄改变现有 TCP 的公共语义(约束禁止)。 - 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.Ports10.x(Windows + Linux)。不做 ns2.0 目标——避免发出一个跨平台名不副实的资产。ns2.0-only 的中间库接不了串口包,属可接受限制(串口消费者本来就是具体应用)。 - net48 目标在 Linux CI 上可构建:加
Microsoft.NETFramework.ReferenceAssemblies(PrivateAssets,仅构建期)。 - 串口包的 ns2.0/netfx
StreamMemory 缺口与 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)测试原样全绿,一个不改。
新增:
- 等价性:显式
TcpTransportFactory构造 vs 旧构造,双连接对同一裸 TCP 回环对端,状态序列/消息收发/RemoteIpAddress/metrics 标签一致。 - Fake 传输(内存双工字节管道 + 故障注入:读/写抛异常、EOF 注入、部分写、握手挂起):在无 socket 下测状态机——连接失败退避重试、EOF→Retry、空闲超时、
ShutdownAsync失败不阻断拆线、僵尸接受循环复现 bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 场景(fake listener 上两代 accept 竞速,断言无泄漏监听器)。这套测试三个 TFM 全跑。 - TLS e2e(net8/net10):运行时
CertificateRequest生成一次性自签证书(无密钥进仓)做回环——收发往返、杀内层 socket 触发重连、证书校验拒绝路径、握手挂起→Abort、RawBytesReceived收到明文断言。服务端用TlsTransportFactory被动模式全覆盖状态机。 - TLS on net48:运行时 e2e 跳过(
CertificateRequest无 netfx 实现;进仓 PFX 或 CI 生成证书两案成本/整洁度不佳——请确认,第十节 ④);ns2.0 的 Stream Memory shim 用MemoryStream单测覆盖正确性,net48 兼容由既有矩阵构建保证。 - 串口:选项→
SerialPort属性映射、无监听NotSupportedException、端口打开失败→Retry 状态机(内部缝隙注入 fake Stream 模拟 BaseStream,SerialTransportFactory预留 internal 构造缝隙)。真实 COM 不进 CI:com0com 本地手动验证写成 docs 手册(两台设备互测的 checklist)。 - soak/混沌:每夜 soak 增加 TLS 回环变体(复用
Soak_ReconnectRacing_LongRun与随机故障混沌的全部动作);fake 传输变体可选(状态机并发正确性验收面)。 - 基准:
bench/EndToEndBenchmarks改造前后各跑一轮,TCP 吞吐/延迟差异须在噪声内(每次 IO 多一次虚调用,量级可忽略,以数据说话)。
九、需要评审确认的决策点(对应 #39 的"请确认"环节)
ITransportConnection形状:小接口(推荐,第二节四缺口论证)vs 裸Stream(更少概念但丢部分写可见性/Abort/优雅关)。特别确认:WriteAsync返回部分写出计数这个形状。- TCP-only 选项在自定义传输下文档化忽略(推荐)vs 构造时抛异常。
- 串口包 TFM:
net48;net8.0;net10.0(推荐,第六节论证)vs 仅net8.0;net10.0(受众收缩、net48 工业场景损失大)。 - net48 的 TLS e2e:跳过运行时测试(推荐)vs 进仓一次性测试证书 vs CI Windows 作业生成证书注入。
IpAddress/Port/RemoteIpAddress在非 TCP 传输的取值:接口不动;TCP/TLS 工厂构造时照常填充(工厂暴露地址信息,连接层识别采用),其它传输IPAddress.None/0/null+ 文档。可接受?- TLS 下
RawBytes*= 明文、密文不可见:HEX 日志口径确认。 - 测试并入现有测试项目(推荐,无新必需检查名)vs 独立串口测试工程(需同步改分支保护必需检查名)。
- 版本号 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
CSJ608 commentedon Aug 29, 2026
评审结论
整体方向认可:把传输来源抽为工厂、把已连接字节通道收敛为小型
ITransportConnection,比直接暴露裸Stream更能保住现有 TCP 的部分写、强制中止和监听生命周期语义。但当前设计稿还有几处会影响实现正确性或公共契约的缺口,建议先修订后再进入 PR-A。
定稿前需要补齐
串口被动模式目前不能按草图 fail-fast。
当前
StartAsync会捕获连接/监听阶段的异常并持续重试。如果只让CreateListener()抛NotSupportedException,实际行为会是无限重试,而不是“构造时抛”。建议引入可选能力接口(如ITransportListenerFactory),或在工厂上提供明确的被动模式能力元数据;StreamConnection构造时据此校验,监听器仍在Start阶段创建。ShutdownAsync与当前同步拆线模型不兼容,需要先定义完整时序。目前
StopSessionCore在_sessionGate内同步执行,并最多等待会话任务 2 秒;DisposeAsync也实际同步完成。若先等待任务、后中止传输,TLS/串口的未决读取可能让每次重连白等 2 秒;若在锁内同步等待 TLSclose_notify,又会扩大阻塞/死锁风险。建议明确区分:
Reconnect、生命周期取消:Abort()先解除未决 IO,再等待任务退出,最后Dispose();不尝试 TLS 优雅关闭。DisposeAsync:可尝试有界的优雅关闭,超时或失败立即回退Abort();不能无限等待。如果首版不准备把核心拆线状态机整体异步化,则 TLS
close_notify不应作为硬性保证。地址兼容承诺缺少对应的抽象。
现有公共 API 是强类型的
IPAddress IpAddress、int Port、string? RemoteIpAddress,而草图只有Description/RemoteDescription字符串,无法可靠填充,也不应通过解析描述字符串或识别TcpTransportFactory具体类型实现。建议增加可选 endpoint 元数据能力,例如
IPEndPoint? ConfiguredEndPoint、EndPoint? RemoteEndPoint;TCP/TLS 实现该能力,串口不实现。若不增加能力接口,则必须明确所有自定义传输构造路径的哨兵值,并接受 TLS 地址信息也无法通过通用抽象获得。TLS 的 TFM/API 矩阵需要修正。
netstandard2.0和 .NET Framework 4.8 的引用 API 中没有SslStream.ShutdownAsync、SslClientAuthenticationOptions/SslServerAuthenticationOptions及现代的带 CancellationToken 认证重载。TLS 可以支持这些目标,但不能按统一的现代 API 路径实现。设计稿应明确:旧目标使用传统认证重载,并通过关闭底层连接中止握手;net8+ 使用现代 options + cancellation API。
ShutdownAsync在旧目标上只能降级为关闭/释放语义,不能承诺显式发送close_notify。TLS“通用装饰器”与当前 API 草图互相矛盾。
TlsTransportFactory.Inner被写死为TcpTransportFactory,这实际是 TLS-over-TCP,而不是可装饰任意 Stream 型传输。首版建议诚实限定为 TLS-over-TCP,避免为了未来场景扩大抽象;以后出现 NamedPipe/Unix Socket 的真实需求再泛化。另外,若要支持 mTLS,
RequireClientCertificate只覆盖服务端要求客户端证书,客户端侧仍需要证书集合/选择回调;否则首版应明确只支持单向 TLS,或补齐客户端证书配置。传输接口必须补充第三方实现契约与防御。
至少写清:
Dispose必须解除未决 accept;Abort必须及时解除未决读写并且幂等;1..buffer.Length;ReadAsync返回 0 只表示 EOF。核心层也应防御
WriteAsync对非空输入返回 0 或越界计数,直接判定传输故障,避免第三方实现令发送循环永久空转。测试与 TFM 建议
不建议跳过 net48 TLS e2e。 net48 正好消费主包的 netstandard2.0 资产,是兼容路径风险最高的目标。建议提交一张明确标注“仅测试用途”的自签名 PFX;测试私钥不是生产秘密。至少覆盖握手、双向收发、校验拒绝和断线重连。
串口 TFM 先做最小验证再定。
System.IO.Ports 10.x确实发布 netstandard2.0、net462 和现代 .NET 资产,但运行时会按最终应用 TFM/RID 选择实现。建议先验证“netstandard2.0 类库 -> net8 Linux 应用”的 restore、加载、打开伪终端行为,再决定是否排除StreamFrame.Serial的 netstandard2.0 资产。当前“ns2.0 运行时仅 Windows”的表述过于绝对。串口真实 IO 不必依赖实体 COM:Linux CI 可以考虑用 PTY 做一个最小 e2e;Windows 的 com0com 继续作为手工验证。属性映射和 fake Stream 测试不能覆盖原生驱动资产选择、打开/关闭及拔线异常路径。
Fake transport 测试应额外覆盖:
ConnectAsync成功后、赋给_connection前发生取消/代次失效时,新连接必须被释放;TLS 服务端握手失败时,已 accept 的内层连接必须释放,然后继续接受下一个客户端。对原 8 个决策点的建议
ITransportConnection,保留WriteAsync的部分写计数;不退回裸Stream。TcpTransportOptions,避免同一参数有两个权威来源。StreamFrame.Serial保留 net48;是否增加 netstandard2.0 待最小验证结果决定。IPAddress.None / 0 / null并文档化。RawBytes*定义为 framing 看到的明文字节,密文不暴露;名称虽然沿用,但 XML 文档要从“Socket 原始字节”改成“传输交给 framing 的字节”。建议负责人确认的三个方向
close_notify是否为首版硬性保证?建议否:故障/重连优先可靠中止;仅显式异步释放尝试有界优雅关闭。完成上述修订后,我认为可以批准进入 PR-A。PR-A 的评审重点应放在:监听器代次所有权、连接建立后取消时的资源释放、
Abort的时序,以及旧 TCP 构造路径的字节与异常等价性。CSJ608 commentedon Aug 29, 2026
回执:设计稿 v2 处置记录(评审意见逐条核对后)
评审指出的六个缺口全部成立,其中五条照单修订;第 2 条(拆除时序)结论采纳、药方修正(理由见下)。维护者已定稿:设计冻结,实施暂缓、待排期后按 PR-A/B/C 开工。
逐条处置
串口被动模式 fail-fast 缺机制 —— 采纳。 已核实
StartAsync的通用 catch(StreamConnection.cs:244)会把NotSupportedException吞进无限重试。修订:CreateListener从ITransportFactory移除,拆出能力接口ITransportListenerFactory : ITransportFactory { ITransportListener CreateListener(); }(与本仓库IStreamingFramer/IFrameDiscardReporting/ISessionAwareStreamConnection的能力接口先例同构);StreamConnection构造时校验isActive=false⇒ 工厂必须实现该接口,否则构造即抛;监听器仍在 Start 阶段惰性创建(与现状InitServer时机一致)。拆除时序 —— 病因采纳,修正方案如下。 承认:串口/netfx-TLS 的未决读不认 ct,照搬现状"等任务 2 秒 → 再关传输"会每次重连白等满 2 秒。但评审建议的"故障路径
Abort()先行"不能全局套用:今天故障拆线是 cancel → 任务退出 →Shutdown(Both)(发 FIN)→ Dispose,改 Abort-first 后 TCP 对端看到的是 RST/无 FIN,直接违背 PR-A"旧 TCP 路径字节与异常等价"的硬约束。修订方案:NetStandard20Shims.WithCancellation已对 netfx socket 这么做)升格为ITransportConnection.ReadAsync的 ct 契约:ns2.0/netfx/串口的 shim 统一用 abandonment 包装,任务在 ct 触发时立即以 OCE 退出;ShutdownAsync→Dispose)——TCP 逐字节等价,串口/netfx-TLS 任务秒退,2 秒不再白等;DisposeAsync做有界优雅关闭尝试(真 close_notify 仅 net8+ 可用),超时/失败回退Abort();close_notify 首版不作硬性保证(采纳方向确认 ①);ObserveLater模式观察,收口这一隐患。地址信息缺抽象 —— 采纳。 增加可选能力:工厂侧
ITransportEndPointInfo(配置端点IPEndPoint?,主动=远端/被动=监听,对应IpAddress/Port语义)、连接侧RemoteEndPoint能力;TCP 实现前者、TLS 经Inner透传,串口均不实现(IPAddress.None/0/null,文档化)。RemoteIpAddress改读连接的能力接口(替代对_socket.RemoteEndPoint的直读)。不解析描述字符串、不识别具体工厂类型。TLS 的 TFM/API 矩阵 —— 采纳。 定稿两套握手实现:net8+ 用
SslClient/ServerAuthenticationOptions+ 带 ct 重载;ns2.0/net48 用经典AuthenticateAsClient/Server重载 +WaitAsync(ct)包装 + 取消时关闭底层连接中止握手。ShutdownAsync在旧目标降级为 Dispose 语义(与第 2 条的"不作硬性保证"一致)。TLS 定位 —— 采纳。 首版诚实定版 TLS-over-TCP:
TlsTransportFactory.Inner类型即为TcpTransportFactory;待 NamedPipe/Unix 等真实内层需求出现再泛化。mTLS 补齐而非收缩:TlsTransportOptions增加ClientCertificates(客户端证书集合)与LocalCertificateSelectionCallback——两代握手 API 均有对应参数,成本低于砍成单向 TLS。第三方实现契约与防御 —— 采纳。 六条契约全部写入接口 XML 文档(Dispose 解除未决 accept;Abort 及时解除未决读写且幂等;非空写成功返回
1..buffer.Length;单 reader + 单 writer 并发模型;ReadAsync返回 0 仅指 EOF)。核心层防御:WriteAsync/ReadAsync对非空缓冲返回 0 或越界计数 → 判定传输故障(防发送循环buffer = buffer[n..]永久空转)。测试与 TFM 处置
SerialPort开 /dev/pts),CI 先continue-on-error起步、验证稳定后再转正;Windows com0com 保持手工验证。ConnectAsync成功后、赋给连接字段前发生取消/代次失效 → 新连接必须释放;② TLS 服务端握手失败 → 已 accept 的内层连接必须释放、继续接受下一客户端(bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 与ConnectAsync失败防泄漏教训在新接缝上的重演)。八个决策点定稿
ITransportConnection+WriteAsync部分写计数,保留;不回裸StreamTcpTransportOptions"。不加[Obsolete]——会让存量TreatWarningsAsErrors用户升级即编译失败,属变相破坏性变更StreamFrame.Serial保留 net48;ns2.0 待 spikeNone/0/null文档化RawBytes*口径改为"传输交给 framing 的字节"(TLS 下为明文),XML 文档同步改写;密文不暴露状态
Abort时序(按第 2 条修正后的模型)、旧 TCP 构造路径的字节与异常等价性。