背景与目标
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.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)测试原样全绿 ,一个不改。
新增:
等价性 :显式 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 评审教训:线性化点、双检、提交点)。
背景与目标
StreamFrame v2.5.0 的插件化已覆盖帧定界(
IFramer)与编解码(ICodec<TMessage>),但传输仍硬绑 TCP socket:StreamConnection直接持有Socket。本 issue 把"传输"抽为可插拔层,使:SslStream,包装在 TCP 之上)与串口(System.IO.Ports,工业 SECS-I 场景)可作为传输驱动接入;非目标:不引入任何 SECS/HSMS 专用概念;不内置证书管理/续期、串口枚举发现;不做 UDP/WebSocket/蓝牙(抽象不为它们预留形状,但也不刻意堵死)。
一、现状盘点:socket 在 StreamConnection 里的全部触点
通读
src/StreamFrame/Connection/StreamConnection.cs(1157 行)后,socket 依赖共 7 处:_socket/_serverITransportConnection?/ITransportListener?ConnectAsyncTcpTransportFactory(代码原样迁移)AcceptAsync+InitServer_acceptLock+_acceptLoopId代次门控(#47 阶段二)、SO_REUSEADDR+ Bind + Listen、accept 重试、单客户端关闭监听TcpTransportListenerConfigureSocketSIO_KEEPALIVE_VALS分支)ReceiveLoopAsync/ReceiveWithIdleTimeoutAsyncwriter.GetMemory(SocketReceiveBufferSize)+socket.ReceiveAsync(memory)+ 空闲超时包装;0 字节 = FIN → 重连ITransportConnection.ReadAsync,其余不动SendRawAsync_sendLock下循环socket.SendAsync,按实际写出分片回调RawBytesSentITransportConnection.WriteAsync(返回本次写出计数),循环与钩子不动StopSessionCore/Shutdown)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 的公共语义(约束禁止)。Stream没有SocketTaskExtensions那样的 Memory 重载等价物,net48 上 TLS/串口的读写需要 byte[] 路径的内部 shim。推荐:折中形状——"连接工厂 + 已建连接的极小自有接口"(Stream-centric 精神 + 承载 Stream 表达不了的语义)。TCP 实现就是把现有代码原样搬进去(字节级等价重构);TLS/串口是薄适配层。
三、公共 API 草图(主包,根命名空间)
StreamConnection新增构造重载(现有构造函数与IStreamConnection/ISessionAwareStreamConnection一字不动;旧构造内部组装TcpTransportFactory,字段映射见第四节):ValueTask<int>在 ns2.0 经System.IO.Pipelines传递的System.Threading.Tasks.Extensions获得(实现时复核依赖审计,不新增直接依赖)。四、选项归属与 TCP-only 选项的处置
StreamConnectionOptions成员TcpKeepAlive/KeepAliveTimeMs/KeepAliveIntervalMsTcpTransportOptionsSocketReceiveBufferSizeReceiveIdleTimeoutMs/IncompleteFrameTimeoutMsAcceptFirstClientOnlyITransportListener语义成立:Dispose = 停止监听)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重试)。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 不支持。System.IO.Ports.SerialPort(System.dll,2.0 起就有);net8/net10 目标引System.IO.Ports10.x(Windows + Linux)。不做 ns2.0 目标——避免发出一个跨平台名不副实的资产。ns2.0-only 的中间库接不了串口包,属可接受限制(串口消费者本来就是具体应用)。Microsoft.NETFramework.ReferenceAssemblies(PrivateAssets,仅构建期)。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 审计)
CommunicationStateChanging:分配/归零先于 Connected/Retry 发布,CurrentSessionId语义)_acceptLoopId代次门控(#47 阶段二)_server所有权)_listener;InitServer的 bind/listen/SO_REUSEADDR搬进TcpTransportListener,bind 失败从CreateListener()抛出、走既有异常路径(StartAsynccatch → 退避重试)——与现状InitServer抛出路径等价_server未赋值"语义)IncompleteFrameTimeoutMs,T8 类)FrameDecoder解码层ReceiveWithIdleTimeoutAsync)socket.ReceiveAsync)ITransportConnection.ReadAsync;OCE→SessionFaultException与"Windows 取消折算 0 字节"防御原样保留(防御性,对 Stream 语义无害)_sendLock)/ 会话绑定条目三态 CAS / 提交点=认领 / "绝不跨会话重放"SendRawAsync只换底层调用)WriteAsync返回计数保住"循环续写"结构RawBytesReceived/RawBytesSent钩子SslStream.WriteAsync无部分计数)StopSessionCore/Shutdown的Shutdown(Both)+Dispose)ShutdownAsync()(尽力,catch+log 与现状一致)+Dispose();TCP 实现逐字节等价;串口Abort走防挂死路径Abort必须 Discard + 后台线程 + 超时兜底(实现项)WaitForConnectedAsync/GetMessages通道生命周期endpoint标签ConnectionMetricsfactory.Description;内建 TCP 路径生成与现状完全相同的字符串("{ip}:{port}"),现有仪表盘不受影响StartSessionSocketReceiveBufferSize)结论:约束 1 的全部保证都是传输无关的,前提是上述三处搬运(监听器创建路径、读调用、写调用)保持异常路径等价——这将是 PR-A 评审的重点清单。
八、测试策略(问题 6)
回归底线:现有 98×3(net8.0 / net10.0 / net48)测试原样全绿,一个不改。
新增:
TcpTransportFactory构造 vs 旧构造,双连接对同一裸 TCP 回环对端,状态序列/消息收发/RemoteIpAddress/metrics 标签一致。ShutdownAsync失败不阻断拆线、僵尸接受循环复现 bug: 用户显式 Reconnect() 与自动重连竞速可长期楔死被动端监听(重绑失败 + 接受循环持有锁重试) #47 场景(fake listener 上两代 accept 竞速,断言无泄漏监听器)。这套测试三个 TFM 全跑。CertificateRequest生成一次性自签证书(无密钥进仓)做回环——收发往返、杀内层 socket 触发重连、证书校验拒绝路径、握手挂起→Abort、RawBytesReceived收到明文断言。服务端用TlsTransportFactory被动模式全覆盖状态机。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_ReconnectRacing_LongRun与随机故障混沌的全部动作);fake 传输变体可选(状态机并发正确性验收面)。bench/EndToEndBenchmarks改造前后各跑一轮,TCP 吞吐/延迟差异须在噪声内(每次 IO 多一次虚调用,量级可忽略,以数据说话)。九、需要评审确认的决策点(对应 #39 的"请确认"环节)
ITransportConnection形状:小接口(推荐,第二节四缺口论证)vs 裸Stream(更少概念但丢部分写可见性/Abort/优雅关)。特别确认:WriteAsync返回部分写出计数这个形状。net48;net8.0;net10.0(推荐,第六节论证)vs 仅net8.0;net10.0(受众收缩、net48 工业场景损失大)。IpAddress/Port/RemoteIpAddress在非 TCP 传输的取值:接口不动;TCP/TLS 工厂构造时照常填充(工厂暴露地址信息,连接层识别采用),其它传输IPAddress.None/0/null+ 文档。可接受?RawBytes*= 明文、密文不可见:HEX 日志口径确认。十、实施切分
定稿后按三个 PR、每个独立可合(都走 AGENTS.md 标准流程):
TcpTransportFactory/TcpTransportListener/TcpTransportConnection(代码原样迁移)+StreamConnection改造(字段/四条调用路径/新构造重载)+ Fake 传输 + 等价性测试。TlsTransportFactory+ TLS 测试 + soak 变体 + AotSmoke TLS 路径 + README/DESIGN 传输章节。src/StreamFrame.Serial+ CI 适配(无新检查名)+ docs 手册 +release.yml增加第三个 csproj 的版本一致性校验与打包。CHANGELOG
Unreleased按新增/变更分类记录;API 兼容性以"现有构造函数与两个接口签名不动"为硬约束自查。验收标准
参考:#39(能力接口设计先例与评审流程)、#47(代次门控——本设计必须保住其语义)、PR #42(#39 的实现)、CHANGELOG 2.3.1(v2.3.0 评审教训:线性化点、双检、提交点)。