Kotlin/Native 的 WebSocket(RFC 6455)协议库,建在
com.netonstream:io之上。 坐标com.netonstream:websocket,包neton.websocket。仓库websocket。 状态:v1 已实现(permessage-deflate 除外,参考同样未实现),实施记录见 §11;首个发布版本 0.1.0(依赖 io 0.2.0、http 0.1.1)。
- 建设方法:见 neton-io SPEC §28.14。首版以参考实现为准,做到能力对等;API 与并发模型按 neton.io 与 Kotlin 协程落地,不逐字翻译 Rust。
- 参考实现(只读学习副本,固定版本):
~/projects/reference/rust/tungstenite-0.30.0:协议核心。~/projects/reference/rust/tokio-tungstenite-0.30.0:异步接线。- 版本与提交号见
~/projects/reference/rust/README.md。
- 能力盘点:来自对上述源码的逐项阅读(2026-09-27)。下文
T/…指 tungstenite 源码根,TT/…指 tokio-tungstenite 源码根。 - 首版范围:参考实现的全部能力,每项标注为以下之一:
- ✅ 对等:行为与参考实现相同。
- ⚖️ 有意不同:附理由;默认取更安全或更符合 RFC 的一方。
- ⛔ 不适用:附理由。
- 不在本库:
- TLS:TLS 以
IoStream包装的形式由独立模块提供,本库只接受任意IoStream。 - HTTP / SOCKS 代理:参考实现同样没有。
- permessage-deflate(RFC 7692):参考 0.30 未实现,Autobahn 12.x / 13.x 的 216 个用例标记为 UNIMPLEMENTED;作为后续版本另起章节。
- TLS:TLS 以
neton.websocket(本库)
├── 协议核心:帧编解码、掩码、消息重组、UTF-8 校验、关闭状态机、握手报文的生成与校验(不做 I/O、不读系统时间)
└── 协程驱动:在 IoStream 上运行协议核心;client / server 入口;连接与握手
↓
com.netonstream:http(通用 HTTP 类型与 HTTP/1.1 头部解析;握手、以及从 HTTP 服务端 Upgrade 接管连接)
↓
com.netonstream:io(IoStream、Buffer / Bytes、connect / listen、反应器)
- 协议核心不依赖 I/O:输入字节,输出帧、消息、要发送的字节与事件,可用确定性测试覆盖,对应参考实现中
WebSocketContext以流为参数的设计(T/src/protocol/mod.rs:362-782)。 - 握手用
http库:参考实现用httparse+httpcrate(T/src/handshake/headers.rs)。本库使用com.netonstream:http的通用类型(neton.http的 Request / Response / HeaderMap),以及它的 HTTP/1.1 头部解析;因此本库在http首版之后实现。 - 从 HTTP 服务端接管连接:参考示例
TT/examples/server-custom-accept.rs先在 hyper 中完成 Upgrade,再用WebSocketStream::from_raw_socket;本库提供同样的"已握手流 → WebSocket"入口,配合http库的 Upgrade 能力。
| 参考 | 本库 | 标注 |
|---|---|---|
WebSocket<Stream>(T/src/protocol/mod.rs:162-358) |
WebSocket(持有 IoStream) |
✅ 能力对等;API 见 §6 |
WebSocketContext(流作为参数) |
协议核心 WebSocketCore(不做 I/O) |
✅ |
Role{Server, Client} |
Role |
✅ |
Message{Text(Utf8Bytes), Binary(Bytes), Ping, Pong, Close(Option<CloseFrame>), Frame(Frame)}(message.rs:157) |
sealed interface Message,同样六种 |
✅ Frame 只用于发送,read 从不产生 |
Frame{header, payload}、FrameHeader{is_final, rsv1..3, opcode, mask},MAX_SIZE = 14 |
同名 | ✅ |
OpCode:Data{Continue=0, Text=1, Binary=2, Reserved(3..7)},Control{Close=8, Ping=9, Pong=10, Reserved(11..15)} |
同名 | ✅ |
CloseCode:1000–1013、1015,以及 Bad / Reserved / Iana / Library 区间;is_allowed() 对 Bad、Reserved、1005、1006、1015 返回 false(coding.rs:119-259) |
同名 | ✅ 区间与判定逐一对等 |
CloseFrame{code, reason: Utf8Bytes} |
同名 | ✅ |
Utf8Bytes(保证 UTF-8 的 Bytes,零拷贝校验) |
Utf8Bytes(包装 neton-io Bytes) |
✅ |
FrameSocket(帧级读写,128 KiB 读缓冲,总是去掩码、接受未掩码帧) |
同名 | ✅ |
MidHandshake / HandshakeError{Interrupted, Failure}(可中断、可恢复的握手) |
挂起函数完成握手;无 Interrupted |
⚖️ 协程挂起取代"WouldBlock 后重入",恢复能力由挂起本身提供 |
MaybeTlsStream / Connector / client_tls* |
无 | ⛔ TLS 由 IoStream 包装提供 |
- 防攻击上限(
AttackCheck,T/src/handshake/machine.rs:148-190,写死):- 头部总字节 ≤ 65536。
- 读取次数 ≤ 512。
- 读满 64 次后,平均每次读取 ≥ 128 字节。
- 违反任一条 →
AttackAttempt。 - 本库 ✅ 对等,改为可配置、默认值相同;读取次数与平均字节数的判定照搬。
- 头部个数上限:124(
MAX_HEADERS,超过 →TooManyHeaders)✅。 - HTTP 版本:解析后必须是 HTTP/1.1,低于 1.1 →
WrongHttpVersion✅。 - 性能:参考实现每轮重新解析整个缓冲(
machine.rs:50的 TODO)。本库增量解析 ⚖️,行为不变。 - Accept 键:SHA-1(key +
258EAFA5-E914-47DA-95CA-C5AB0DC85B11) 后 base64。本库自带 SHA-1 与 base64 实现,并用 RFC 向量测试 ✅。
IntoClientRequest:字符串 / URI / 已构造的请求 / 构建器 ✅。- URI 必须有 authority;去掉
user:pass@。 - 无主机 →
NoHostName;空主机 →EmptyHostName。 - 方案只能是
ws/wss,否则 →UnsupportedUrlScheme。
- URI 必须有 authority;去掉
Sec-WebSocket-Key:16 字节随机数经 base64 编码。随机源用平台 CSPRNG(arc4random_buf/getrandom/BCryptGenRandom)⚖️。参考实现用rand::random;RFC 要求随机值不可预测。- 请求报文(
generate_request,client.rs:112-199)✅:- 请求行
GET {path_and_query} HTTP/1.1,缺路径 →NoPathOrQuery。 - 五个必需头部按固定顺序、规范大小写写出:
Host、Connection: Upgrade、Upgrade: websocket、Sec-WebSocket-Version: 13、Sec-WebSocket-Key;缺任一 →InvalidHeader。 - 其余头部原样写出,允许非 ASCII / Latin-1 值;与必需头部重复 →
InvalidHeader。 sec-websocket-protocol改写为Sec-WebSocket-Protocol,origin改写为Origin。
- 请求行
- 子协议:
ClientRequestBuilder.withSubProtocol,多个值以", "连接 ✅。 - 响应校验(
verify_response,client.rs:220-295):- 状态码非 101 →
Http(response),响应体是头部之后已读到的字节 ✅。 Upgrade头与websocket做整值、不区分大小写比较 ✅。Connection头:参考实现按整值比较,本库按 RFC 7230 逗号分隔的 token 列表判断含Upgrade⚖️。理由:Connection: keep-alive, Upgrade是合法响应;服务端一侧参考实现本身就按 token 解析。Sec-WebSocket-Accept必须精确匹配 ✅。Sec-WebSocket-Extensions:参考实现未校验(TODO)。本库在未协商任何扩展时,服务端返回扩展即报错 ⚖️(RFC 6455 §4.1 要求)。- 子协议三种错误 ✅:
- 未请求却返回 →
ServerSentSubProtocolNoneRequested。 - 请求了却未返回 →
NoSubProtocol。 - 返回的不在请求列表中 →
InvalidSubProtocol。
- 未请求却返回 →
- 状态码非 101 →
- 尾随字节:响应头部之后已读到的字节交给
WebSocket,对应from_partially_read✅。 connect:DNS 解析与逐地址尝试 ✅,全部失败 →UnableToConnect;IPv6 方括号去除 ✅;默认端口 80 / 443 ✅。- 重定向:同步
connect跟随 3xx(最多 3 次),异步connect_async不跟随。本库提供maxRedirects参数,默认 0,与异步参考实现一致 ⚖️。 TCP_NODELAY:同步版置位,异步版默认不置位。本库沿用 neton-io 的SocketOptions默认值noDelay = true⚖️。wss:需要调用方提供 TLS 包装的连接工厂;未提供时 →TlsNotAvailable,对应参考的TlsFeatureNotEnabled✅。
- 重定向:同步
- 校验顺序(
create_parts,server.rs:37-87)✅:- 方法 GET,否则
WrongHttpMethod。 - 版本 ≥ 1.1,否则
WrongHttpVersion。 Connection按空格与逗号拆分后,不区分大小写地含Upgrade,否则MissingConnectionUpgradeHeader。Upgrade为websocket,否则MissingUpgradeWebSocketHeader。Sec-WebSocket-Version为13,否则MissingSecWebSocketVersionHeader。Sec-WebSocket-Key存在,否则MissingSecWebSocketKey。- key 长 24 且 base64 解码为 16 字节,否则
InvalidSecWebSocketKey。
- 方法 GET,否则
Host与Origin不检查(参考同样)✅;需要时由回调检查。- 成功响应:
101,带Connection: Upgrade、Upgrade: websocket、Sec-WebSocket-Accept✅。 - 回调:
onRequest(request, response) -> response | errorResponse✅。- 子协议选择与附加头部由回调完成;参考实现不做自动协商,本库同样。
- 回调返回 2xx 错误响应 →
CustomResponseSuccessful。 - 返回其他错误响应 → 写出(含可选字符串体)并 flush,然后 →
Http(response)。
- 请求头之后若已有字节 →
JunkAfterRequest✅。 - 入口:
accept/acceptWithConfig/acceptHdr/acceptHdrWithConfig✅。另有"由 HTTP 服务端 Upgrade 后接管"的入口(§1)。
- 头部:FIN / RSV1–3 / opcode / MASK / 7 位长度(126 → 16 位,127 → 64 位,大端)✅;数据不全时不消费 ✅;保留 opcode 在头部解析时 →
InvalidOpcode✅。 - 64 位长度的最高位与最短编码:参考不校验。本库对最高位为 1 →
ProtocolError(RFC 6455 §5.2 要求)⚖️。非最短编码与参考一致、不拒绝 ✅(RFC 未要求拒绝)。 - 长度检查在读负载之前:与
maxFrameSize比较(在 64 位上比较,32 位平台安全),超出 →MessageTooLong{size, maxSize}✅;随后一次性预留整个负载 ✅。 - 单次读取上限:
inBufMaxRead = max(readBufferSize, 14)✅。 - 读到 EOF → 由状态机决定结果(§5)✅。
- 掩码规则:
- 服务端收到带掩码的帧原地去掩码;收到未掩码帧 →
UnmaskedFrameFromClient,除非acceptUnmaskedFrames。✅ - 客户端收到带掩码的帧 →
MaskedFrameFromServer✅。
- 服务端收到带掩码的帧原地去掩码;收到未掩码帧 →
- 校验顺序(
read_message_frame,protocol/mod.rs:610-714)逐项对等 ✅:- EOF。
!canRead→ReceivedAfterClosing。- RSV 位 →
NonZeroReservedBits。 - 客户端收到带掩码帧。
- 控制帧:FIN = 0 →
FragmentedControlFrame;负载 > 125 →ControlFrameTooBig。- 参考在读完负载后才检查,且只受
maxFrameSize约束。本库在头部解析后即检查 ⚖️,避免为超长控制帧读入负载;错误类型相同。
- 参考在读完负载后才检查,且只受
- 数据帧:无进行中消息时收到 Continue →
UnexpectedContinueFrame;有进行中消息时收到 Text / Binary →ExpectedFragment。
- 关闭帧负载:长 0 → 无关闭帧;长 1 →
InvalidCloseSequence;否则前 2 字节为码,其余为原因,原因非 UTF-8 →Utf8错误 ✅。
- 大小检查:
IncompleteMessage.extend做溢出安全的maxMessageSize检查 ✅;单帧消息同样检查 ✅。 - 单帧文本:原地校验、零拷贝得到
Utf8Bytes✅。 - 分片文本:逐帧增量校验,跨帧的不完整码点暂存最多 4 字节;结束时仍有残留 → 错误 ✅。
- 快速失败的粒度:参考在整帧到达后才校验,Autobahn 6.4.3 / 6.4.4 记为 NON-STRICT。本库校验粒度与参考相同 ✅,目标结果与参考一致;逐字节快速失败作为后续优化,届时这两项可升为 OK。
- 分片二进制:拼接到一块缓冲 ✅。复制的次数作为性能项测量。
- UTF-8 错误的类型:
Utf8,不是ProtocolError,与参考一致 ✅。
- 写缓冲:帧编码进同一输出缓冲,掩码在输出缓冲内原地施加(一次拷贝,无临时缓冲)✅。
- 写出时机:输出缓冲超过
writeBufferSize才写到流上 ✅;写出不等于 flush ✅。 - 写缓冲已满:超过
maxWriteBufferSize→WriteBufferFull(message),把消息交还调用方 ✅。- 在协程模型下,本库的
send会挂起等待而不是失败,WriteBufferFull只在非挂起的trySend中出现 ⚖️。理由:挂起即背压(neton-io §28.6)。
- 在协程模型下,本库的
- 掩码:客户端每帧随机掩码 ✅(随机源同 §3.2)。掩码按 8 字节字处理并考虑对齐 ⚖️(参考为 4 字节字),属于性能差异,结果以逐偏移量的对照测试保证一致。
- 发送时不自动分片 ✅;分片通过
Message.Frame发送 ✅。 - 发送侧校验(参考没有)⚖️:Ping / Pong 负载 > 125、关闭原因 > 123 字节、出站消息超过
maxMessageSize均报错。理由:参考的文档声称会报 Capacity 错误,但代码未实现;RFC 要求控制帧 ≤ 125。
| 字段 | 默认 | 语义 | 标注 |
|---|---|---|---|
readBufferSize |
128 KiB | 读缓冲容量,也是单次读取上限 | ✅(缓冲来自 neton-io 池,读空后归还) |
writeBufferSize |
128 KiB | 输出缓冲超过此值才写到流;0 表示每帧立即写 | ✅ |
maxWriteBufferSize |
无上限 | 超过 → WriteBufferFull;必须大于 writeBufferSize |
⚖️ 默认改为 writeBufferSize 的 4 倍(资源有界,neton-io §28.1);参考在不满足约束时 panic,本库构造时抛 IllegalArgumentException |
maxMessageSize |
64 MiB | 入站重组消息上限 | ✅ |
maxFrameSize |
16 MiB | 入站帧负载上限(读负载前检查) | ✅ |
acceptUnmaskedFrames |
false | 服务端容忍未掩码的客户端帧 | ✅ |
setConfig 重新校验并重新应用缓冲上限 ✅。
- 状态:
Active→ClosedByUs/ClosedByPeer→CloseAcknowledged→Terminated(protocol/mod.rs:795-828)✅。canRead:Active、ClosedByUs。canWrite:只有 Active。
- 收到 Close(
do_close,mod.rs:718-752)✅:- Active 时:进入 ClosedByPeer;关闭码不被允许时改为
{1002, "Protocol violation"};回显该帧;把Close交给调用方。 - ClosedByUs 时:进入 CloseAcknowledged,交给调用方。
- 其他状态:忽略。
- Active 时:进入 ClosedByPeer;关闭码不被允许时改为
- 终止的不对称(RFC 6455 TIME_WAIT 规则)✅:
- 服务端在回复写出后置 Terminated,并返回"连接已正常关闭",由服务端先断开 TCP。
- 客户端要等到 TCP EOF 才得到该结果。
- 已关闭后连接被重置,也视为正常关闭。
- 两种结束的区分 ✅:
- 正常结束 →
ConnectionClosed(本库:receive()返回 null)。 - Terminated 之后再读写 →
AlreadyClosed(编程错误)。
- 正常结束 →
- 状态相关的错误 ✅:
- 非 Active 时写 →
SendAfterClosing。 - Active 或 ClosedByPeer 等状态下 EOF →
ResetWithoutClosingHandshake。
- 非 Active 时写 →
close()✅:只在 Active 时发送并进入 ClosedByUs,并总会 flush。send(Message.Close)等价于close。- 协议错误不自动发送关闭帧:参考只返回错误,由调用方断开;Autobahn 接受直接断开 TCP。本库默认与参考一致 ✅,另提供配置
sendCloseOnProtocolError(默认 false),开启时先发送 1002 / 1007 / 1009 再关闭。 - 自动 pong ✅:
- 收到 Ping 时,若为 Active,排入一个回复槽位(与 Ping 共享同一负载),并把 Ping 交给调用方。
- 回复槽位只在为空或为 Pong 时被替换,因此待发的 Close 回复优先,新的 pong 覆盖旧的。
- 用户发送的 Pong 同样经过该槽位。
- 我方发出 Close 后不再回 pong。
- 读路径也会写,且写侧背压不得阻塞读侧推进:
- 参考的做法:
read先尝试 flush 待发的回复;遇到 WouldBlock 就记下unflushedAdditional并继续读,写与读由两个 waker 代理协调(TT/src/compat.rs:21-121)。 所以对端暂时不读、却持续发送时,参考仍在消费入站数据。 - 草案 v0 规定"写通道空闲时由读协程直接写出"。这会在对端不读时让读协程停在写操作上、不再消费入站数据,与参考不等价,撤回。
- 本库:每个
WebSocket有一个写驱动(连接作用域内的一个协程),独占流的写方向(与 neton-io §28.6"每个流同一时刻至多一个写"一致)。- 读协程产生的自动回复只放入回复槽位(槽位规则见上,至多一个待发回复),唤醒写驱动后立即继续读,从不等待写出。
- 用户的
feed/send把帧放入连接级输出缓冲(§6"帧的接受与所有权"),由写驱动写出。 - 写驱动的待发数据有界:输出缓冲 ≤
maxWriteBufferSize,外加至多一个回复。
- 对端既不读也不停发时,读侧的推进只受入站上限约束(
maxFrameSize/maxMessageSize/ 读缓冲),不受出站背压约束;回复槽位被新 Pong 覆盖(同参考),不会无限堆积。 - 测试(双向背压):
- 对端不读、持续发送 Ping:本端的
receive持续返回 Ping,回复槽位只保留最新一个 Pong。 - 对端不读时本端
close:Close 帧在槽位中优先,读侧继续推进直到收到对端 Close 或 EOF。 - 以上情形下取消
receive/send:连接状态一致,后续操作正常,或按 §6 关闭。 - 移植的参考
auto_pong_flush.rs。
- 对端不读、持续发送 Ping:本端的
- 参考的做法:
- flush 语义 ✅:
write(本库feed)只缓冲,flush写出并 flush 流,send=feed+flush。
- 连接对象
class WebSocket(持有IoStream;所属反应器上使用,遵守 neton-io §28.6 的线程规则):suspend fun receive(): Message?:null 表示正常结束;读完一条消息才返回,自动处理 ping / close 回复。suspend fun send(message)、suspend fun feed(message)、suspend fun flush()、fun trySend(message): Boolean。suspend fun close(frame: CloseFrame? = null)。val config、fun setConfig { }、val role、val canRead、val canWrite。receive的取消:帧中途被取消时,已读入的字节留在读缓冲中,下次receive继续解析,对应参考poll_next的"读可取消"。- 帧的接受与所有权(
feed/send的取消):- 接受:一个帧被编码并整体追加进连接级输出缓冲(在所属反应器上一次完成,不可分割),即为"已接受"。从此它归连接所有,由写驱动写出,
与调用方协程无关;
feed在接受后返回。 - 接受之前:输出缓冲已满(达到
maxWriteBufferSize)时,feed挂起等待空间。在此期间被取消,帧未被接受,没有任何字节进入缓冲或网络, 调用方仍拥有该消息。 - 接受之后:调用方被取消(包括在
flush中等待时)不影响已接受的帧,写驱动继续写完。网络上可能已发出半帧,取消无法撤回;由于写驱动 持有剩余字节与写入位置,后续帧一定排在它之后,连接保持可用。 - 写驱动自身被迫结束(流出错、连接关闭):写到一半的帧无法补全,连接进入 Terminated,此后读写抛
AlreadyClosed;不会出现"半帧之后接着写新帧"。 - 测试:用 neton-io
memoryStreamPair(capacity = 1)让每个字节都可能挂起,在写出过程中的每个字节位置取消调用方,对端收到的始终是完整、按序的帧, 连接随后仍可正常收发;在每个字节位置让流出错,本端进入 Terminated,对端从未收到"半帧后接新帧"。
- 接受:一个帧被编码并整体追加进连接级输出缓冲(在所属反应器上一次完成,不可分割),即为"已接受"。从此它归连接所有,由写驱动写出,
与调用方协程无关;
- 读写分离:
fun split(): Pair<WebSocketReader, WebSocketWriter>。参考借助futures::split(BiLock)实现;本库原生提供,读与写可由两个协程分别使用,二者共享 §5 的写通道。 - 入口:
- 客户端:
connect(request, config)、client(request, stream, config)。 - 服务端:
accept(stream, config)、accept(stream, config) { request, response -> … }。 - 已握手的流:
WebSocket.fromRawStream(stream, role, config, prefix: Bytes? = null),对应from_raw_socket/from_partially_read。
- 客户端:
- 缓冲:读缓冲使用 neton-io
Buffer(池化,读空后归还);负载以 neton-ioBytes切片交出(零拷贝,对应参考的BytesMut.split_to().freeze())。- 零拷贝与缓冲池的关系:按 neton-io §23.7 现行实现,一个数组一旦被
Bytes切片共享,就不再归还缓冲池,之后由 GC 回收(写时复制保证切片内容 不被改写)。因此"零拷贝交出负载"与"持续复用同一块池化数组"不能同时成立:交出了切片的读缓冲会换用新数组。每消息的分配与复制以 callgrind 实测, 取两种交付方式(零拷贝切片 / 复制进池化缓冲)中的更优者作为默认,并记录数据。
- 零拷贝与缓冲池的关系:按 neton-io §23.7 现行实现,一个数组一旦被
- 错误类型:
WebSocketException层级,与参考的Error/ProtocolError/CapacityError/SubProtocolError/UrlError各变体一一对应,保证能力对等;ConnectionClosed以receive()返回 null 表达,不作为异常抛出。
- 单元测试(逐条移植):
protocol/mod.rs:receive_messages、两个 size_limiting 用例。frame/mod.rs:read / write / from_partially_read / parse_overflow / size_limit_hit / 32 位长度溢出。frame/frame.rs:parse / format / display。coding.rs:opcode 与关闭码转换。mask.rs:对照实现,偏移 0–7、长度 0–32。utf8.rs。message.rs。handshake:RFC 向量dGhlIHNhbXBsZSBub25jZQ==→s3pPLMBiTxaQ9kYGzzhZRbK+xOo=、客户端请求格式(主机、端口、userinfo)、响应解析、非 ASCII 头部、服务端四种非法 key。headers.rs。
- 集成测试(移植
T/tests/,每个文件一一对应):auto_pong_flush、write(writeBufferSize = 600下的合并与 flush 次数)、connection_reset(含以 SO_LINGER 0 发 RST 的"恶意"服务端)。no_send_after_close、receive_after_init_close、handshake(六种子协议协商)、client_headers。- 从
TT/tests/移植:communication、split_communication、handshakes。 - 以上都用 neton-io
memoryStreamPair与真实 TCP 各跑一遍。
- 有意不同项各有测试:
Connection头按 token 列表判断。- 64 位长度最高位为 1 时拒绝。
- 超长控制帧在读负载前拒绝。
- 发送侧的上限校验。
maxWriteBufferSize的默认值。sendCloseOnProtocolError。
- 模糊测试:
parse_frame_header、read_message_client、read_message_server三个目标;以参考的种子语料(15 / 564 / 244 个文件,T/fuzz/seeds/)作为输入,再加随机变异;要求不崩溃、不挂起、错误类型合法。 - Autobahn(
crossbario/autobahn-testsuite,客户端与服务端两种模式):- 结果与参考的
T/autobahn/expected-results.json逐项比较:OK 296、UNIMPLEMENTED 216(压缩)、NON-STRICT 2、INFORMATIONAL 3。 - 本库每项至少达到参考的结果;任何一项差于参考即不通过。
- 运行环境:153 或 colima 中的 docker,环境待确认。
- 结果与参考的
- 基准:移植
T/benches/:read:10 万条小消息;服务端需去掩码,客户端不需要。write:10 万条小消息后 flush;客户端需加掩码。e2e:TCP 往返,512 B 到 1 GiB,每档乘 8,关闭上限检查。- 另加:多连接的回显吞吐与 p99(
echo-client-mass的 WebSocket 模式)。
- 对照对象:tungstenite / tokio-tungstenite 0.30.0,以同等配置运行(相同缓冲大小、相同上限、均不压缩)。在 153 上按 neton-io §28.4 的规程与验收指标进行(吞吐、每消息 CPU、每消息分配数(callgrind)、p99、每连接公平性)。
- 首版的性能要求:不低于参考实现,有差距时记录原因。协议核心的热路径(帧解析、去掩码、单帧消息交付)以零分配为目标。
| 需要 | 现状 | 处理 |
|---|---|---|
| 字节流、半关闭、超时、取消语义 | IoStream + neton-io §28.6 |
已有,§28.6 落地后按能力声明使用 |
| 零拷贝负载切片 | Bytes / Buffer.readSlice |
已有 |
| 随机数(掩码、key) | 无 | 本库自带平台 CSPRNG 的 expect / actual;若 http、quic 也需要,再评估移入公共模块 |
| SHA-1、base64 | 无 | 本库自带(小,且只用于握手) |
| 非阻塞写("能写多少写多少,写不了立即返回") | IoStream.write 写完全部或挂起 |
不需要:写驱动独占写方向,读侧不写(§5) |
| 连接工厂(明文 / TLS) | connect |
wss 由调用方传入 TLS 包装的工厂 |
- 协议核心:帧、掩码、UTF-8、重组、关闭状态机、配置;单元测试与模糊测试。
- 握手:客户端与服务端;以
http库的类型与头部解析实现;握手测试。 - 协程驱动:
WebSocket/split/ 入口;集成测试(内存流与 TCP)。 - Autobahn 客户端与服务端。
- 性能对照(153)。
每一步单独验证、单独提交,结果记入本 SPEC。
- 代码:
neton.websocket(WebSocketCore、Message、Utf8、WebSocketConfig、Error)与neton.websocket.frame(Coding、Mask、Frame、FrameCodec/FrameSocket),sans-I/O:读侧把收到的字节追加到core.input,read()返回消息或 null(需要更多输入);写侧只排队,写驱动调用bufferReply()→ 写出output→ flush →flushed()。 - 测试:87 个,macosArm64 全过;linuxX64、mingwX64、androidNativeArm32、androidNativeX86 编译通过。
- 参考
src/单元测试全部移植,error.rs的 3 个(Rust 内存大小断言)不适用 ⛔。 - 参考
tests/:auto_pong_flush、write、connection_reset(3)、no_send_after_close、receive_after_init_close用测试内的同步驱动移植;握手、TLS 相关的留给步骤 2,TCP / 内存流的集成运行留给步骤 3(§6)。 - 另有
DeviationTest(每个 ⚖️ 一个以上)与WebSocketCoreTest(29 个:解析检查顺序、跨分片重组与 UTF-8、关闭状态机、Ping 洪泛只保留最新 Pong、Close 优先于 Pong、客户端掩码)。
- 参考
- 实现中新增的决定(相对 §4、§5 的补充):
- 单帧超过
maxWriteBufferSize:输出缓冲为空时接受 ⚖️。否则有界默认值(4 ×writeBufferSize= 512 KiB)会使大于 512 KiB 的消息永远无法发送,而入站允许 64 MiB。输出缓冲非空时照常WriteBufferFull。 - 我方 Close 经回复槽位发送(在已排队的帧之后写出),覆盖待发的 Pong,且不会
WriteBufferFull;与 §5"槽位中 Close 优先""我方发出 Close 后不再回 pong"一致,与参考代码的路径不同(参考在 Close 之后仍可能写出更早的 Pong,RFC 6455 不允许 Close 之后再发帧)。 writeBufferSize = 0时maxWriteBufferSize默认 512 KiB(4 × 0 不是合法配置)。WriteBufferFull交还调用方原来的Message;长度错误报告完整的 64 位长度;WebSocketState公开供驱动使用。
- 单帧超过
- 热路径分配:帧头解析进复用字段;解掩码与单帧 UTF-8 校验在输入缓冲内原地完成;负载以零拷贝
Bytes切片交出,每条消息只分配切片与外层对象。以 callgrind 实测留待步骤 5。
- 代码:
handshake/(Sha1、Base64(严格解码,同data_encoding)、Handshake(deriveAcceptKey、MAX_HEADERS = 124、HandshakeLimits/AttackCheck、头部读取、解析错误映射)、ClientHandshake、ServerHandshake),入口Client.kt(clientHandshake、connect,同时覆盖参考的connect与connect_async)、Server.kt(accept/acceptWithConfig/acceptHdr/acceptHdrWithConfig)。基于com.netonstream:http的类型与 httparse 移植。 - 测试:175 个(步骤 1 的 87 + 88),macosArm64 全过(另三次
--rerun-tasks全过,TCP 用例稳定);linuxX64、mingwX64 编译通过。- 参考:
handshake/mod.rs1、headers.rs3、client.rs8、server.rs7 全部移植;tests/handshake.rs6 → 12(内存流与 TCP 各一遍)、client_headers.rs1 → 2、url_feature.rs1、wss_fails_when_no_tls.rs1;tokio-tungstenitehandshakes.rs1 → 2。 - 另加:
MachineTest9、HandshakeChecksTest19(两侧校验顺序、请求生成、回调结果)、HandshakeDeviationTest12(每个 ⚖️ 一个以上)、流程 6 个(非 101 响应带体、拒绝、剩余字节交给核心、请求后的垃圾字节、握手中 EOF、端口关闭 →UnableToConnect)。
- 参考:
- ⚖️ 与 §3 一致的:挂起代替
Interrupted/MidHandshake;防攻击上限可配置(默认同参考);增量解析(头部结束后解析一次;未完成头部内的错误延后到头部结束、EOF 或触发上限时报告,上限情形先报告更早的错误,与参考一致);generateKey用平台 CSPRNG;客户端按 token 列表读Connection;未请求的扩展被拒绝(ExtensionNotRequested);maxRedirects默认 0;TCP_NODELAY开启。 - 实现中新增的决定:
- DNS 失败报告为
UnableToConnect(neton-io 的connect不区分解析失败与连接被拒);向 neton-io 提出:公开解析接口,以及取得监听端口的接口。 - 继承自 http 的默认解析配置:单独的 LF 行结束被拒绝(http SPEC §3.9)。
- 调用方提供的流由调用方拥有:
clientHandshake与accept*失败时不关闭它;connect关闭自己打开的流。 - 参考的
Tls错误变体不移植(TLS 由调用方提供,⛔);EmptyHostName实际不可达(user@的 authority 本身是非法 URI,与 Rusthttpcrate 相同),保留该检查。
- DNS 失败报告为
- 客户端握手的结果中,响应之后的剩余字节已经装入
WebSocketCore,leftover只供查看,步骤 3 不得重复送入。
- 代码:
WebSocket.kt(receive()正常结束返回 null、feed/flush/send/trySend/close/config/setConfig/canRead/canWrite/abort()、split()返回读写两半、fromRawStream、fromUpgraded、verifyUpgradeResponse),ConnectionReset.native.kt;WebSocketCore增加tryWrite/takeOutput/outputWritten。 - 写驱动(§5):每个连接一个长期写协程,独占流的写方向,运行在创建者的调度器上、拥有自己的 Job;
receive()只把自动回复放进回复槽位并唤醒驱动,从不等待写出;驱动以交换缓冲的方式取走待写数据,写出期间仍可排队,正在写出的字节仍计入maxWriteBufferSize;发送一条消息不启动协程,feed只在超过writeBufferSize时让出(与参考write.rs的写出与 flush 次数一致)。 - 同一时刻至多一个
receive与一个写操作,第二个并发调用抛IllegalStateException(同IoStream)。握手成功后流归WebSocket所有并在连接结束时关闭;握手失败时流仍归调用方。 - 入口的签名改变:
connect/ 新增client返回Pair<WebSocket, ClientResponse>;accept*返回WebSocket,另有accept(stream, config) { request, response -> };serverHandshake与clientHandshake保留为核心级握手。 - 测试:214 个(步骤 1–2 的 175 + 39),连续三次全过;linuxX64、mingwX64 编译通过。
- 参考:
connection_reset.rs3 → 6(内存流与 TCP,"恶意服务端"用SO_LINGER 0)、no_send_after_close.rs1 → 2、receive_after_init_close.rs1 → 2、write.rs1、auto_pong_flush.rs1;tokio-tungstenitecommunication.rs2 → 4(整体与拆分,内存流与 TCP);握手相关的测试改走client/connect/accept*。 DriverTest14:对端不读时的 Ping 洪泛(每个 Ping 都收到,只发出少量越来越新的 Pong)、对端不读时我方关闭(Close 优先,其后无 Pong)、在帧的每个切分位置取消receive不丢数据、取消的feed不排队、trySend报告缓冲已满、单字节管道上在每个字节位置取消send、在每个字节位置流出错(对端只收到帧的前缀)、协议错误(开关sendCloseOnProtocolError)、对端关闭后的写错误仍为正常结束、abort、单个 receive、两个协程分别使用拆分的两半、TCP 上 3 MiB 消息。UpgradeTest9:http 服务端升级交给fromUpgraded(内存流与 TCP)、头部之后已读字节随流交出、非法升级请求得 400、http 客户端升级(内存流与 TCP)、http 客户端对 http 服务端(内存流与 TCP)、verifyUpgradeResponse的校验。
- 参考:
- ⚖️:
feed在输出缓冲满时挂起而非WriteBufferFull,trySend返回 false(§4.3 已定)。receive遇到协议、UTF-8 或大小错误时结束连接并关闭流(开启sendCloseOnProtocolError时先发失败关闭帧);参考返回错误、由调用方丢弃连接。依据 RFC 6455 §7.1.7"失败连接";这修订了 §5"协议错误由调用方断开"的做法:有了写驱动,断开由库完成,默认仍不发送关闭帧。- 收到对端 Close 之后的任何写错误都算正常结束(不只是重置):我方回显是否因重置、管道断开而失败取决于时序。
abort()代替 Rust 的 drop。auto_pong_flush移植中 Pong 以 1 次 flush 写出(参考 3 次):阻塞的 flush 在驱动中等待,而不是失败后重试。- 已报告结束后再
receive()抛AlreadyClosed(§5),tokio-tungstenite 则一直返回None。
- 向 neton-io 提出:可移植的"连接被重置"判断(如
IoException.isConnectionReset,本库暂以expect/actual比较ECONNRESET与 Winsock 10054);取得监听端口的接口(测试目前随机选端口)。 - Linux 验收(153,Rocky 9.8):214 个测试在 epoll 与 io_uring 上全过。
- 驱动:
websocket-bench的autobahnServer/autobahnClient(tungsteniteexamples/autobahn-*.rs的等价物),脚本与配置在bench/autobahn/。 - 结果(服务端与客户端两个方向,各 517 个用例):行为 OK 296、NON-STRICT 2、INFORMATIONAL 3,关闭 OK 514、INFORMATIONAL 3,无失败; UNIMPLEMENTED 216 为 permessage-deflate(12.x、13.x),参考同样未实现。
- 首轮发现并修正:
- 2.10 / 5.6 / 5.19(每个 Ping 一个 Pong、Pong 先于随后消息的回显):读者在写驱动运行前连续解出多个 Ping,旧 Pong 被覆盖、回显先于 Pong。
按 tungstenite
read开头的_write(None),receive()在读下一条消息前把待发回复移入输出(只缓冲,不等待写出);回复只在输出已满时被替换。 §5 的洪泛测试随之改为断言"至多每个 Ping 一个 Pong、按序、最后一个必答、待发数据不超过写缓冲上限"(参考在对端不读时同样把 Pong 排在 输出中,直到写缓冲满)。 - 写驱动只在自己移动回复时 flush:读者移入的回复不会被写出(服务端关闭握手卡住)。改为核心记录"自上次 flush 以来有回复入队",驱动据此 flush。
- 7.1.2–7.1.5(关闭之后再收到帧,要求干净关闭):读失败时连接立即关闭,已入队的关闭回显来不及写出。改为:已有到期回复时先 flush 再结束,
且
receive()在抛出错误前等待这次最终 flush(至多 1 s,防对端不读)——参考在失败的那次read开头已写出回复。
- 2.10 / 5.6 / 5.19(每个 Ping 一个 Pong、Pong 先于随后消息的回显):读者在写驱动运行前连续解出多个 Ping,旧 Pong 被覆盖、回显先于 Pong。
按 tungstenite
- 工具:
bench/ws/ws-bench(Rust:tokio-tungstenite 0.30.0 回显服务端 + 闭环负载客户端,两边服务端用同一负载端)、bench/ws/cg-ws.sh(每消息指令数); 本库服务端为websocket-bench的autobahnServer(回显,单反应器)。 - 首轮:本库约 50k 条/s、p50 1.0 ms,tokio-tungstenite 103–116k 条/s。callgrind 显示每条消息一次
Buffer.allocate、GC 频繁:负载以零复制切片交出 使读缓冲变为共享,下一次读就要换一个整个读缓冲大小(128 KiB)的新数组。修正:不超过 32 KiB 的负载复制出来(一次合适大小的分配),读缓冲保留 其数组;更大的仍为切片。 - 修正后:本库 130.5k / 131.7k 条/s,p50 327 / 322 µs、p99 550 / 564 µs、p99.9 7.3 ms;tokio-tungstenite 129.8k / 131.1k 条/s,p50 368 / 364 µs、 p99 647 / 664 µs、p99.9 806 / 820 µs。限定于该回显场景(64 字节二进制、50 连接闭环、单核):吞吐持平、p50 / p99 更低;其他消息大小、 文本 / 分片、扇出、大量连接尚未对照。p99.9 的偏高推测与 http SPEC §11 同一原因(GC 线程与反应器共核),因果未确认。
- 协议测试通过(Autobahn、参考测试)、性能接近(上面的场景)与生产成熟度是三件不同的事;目前只能说明前两项在所测范围内成立。
- 每消息指令数(cachegrind):本库 15,296;tungstenite 的数值被其每次读把 128 KiB 读块清零的
memset主导,不宜直接比较。 - GC 线程优先级(neton-io
NETON_IO_GC_THREAD_NICE=19,机制见 http SPEC §11 与 neton-io §26.8)下同一场景两轮:本库 149.3k / 146.7k 条/s、 p50 322 / 330 µs、p99 533 / 531 µs、p99.9 1.40 / 1.43 ms;tokio-tungstenite 131.6k / 127.6k、p50 364 / 380 µs、p99 654 / 653 µs、p99.9 795 / 859 µs。 仍限定于该回显场景;p99.9 仍高于参考。
- 模糊测试(§7 要求的三个目标,此前未写):
nativeTest的FuzzTest,确定性运行,随测试套件执行。- 目标:
parse_frame_header对应FrameHeader.parse(连续解析,跳过已到达的负载);read_message_client/read_message_server对应WebSocketCore(Role.Client / Role.Server).read(),输入整段喂入一次、再按随机块(不超过maxReadSize)喂入一次,输入结束时调用receivedEof。 - 输入:参考语料(
NETON_WS_FUZZ_SEEDS,缺省为$HOME下的参考检出)的每个种子,加按固定随机种子生成的变异(翻转位、改字节、截断、 复制片段、插入随机字节,各 1–4 处;帧头目标每种子 64 个,读消息目标各 32 个),另加 2000 个与语料无关的随机输入(随机字节串,或字段、 长度、掩码随机的帧序列),无语料时也会运行并打印提示。 - 判定:只允许
WebSocketException(其他异常即失败);每条消息至少消耗一个帧头,读出的消息数超过输入字节数 + 16 即判为挂起;输入结束后read不得返回 null(只能结束或失败)。 - 结果(macOS arm64):帧头 15 个种子、2975 个输入;客户端 564 个种子、20612 个输入,解出 20064 条消息,结束方式 Protocol 38090、Utf8 1534、 ConnectionClosed 1524、Capacity 76;服务端 244 个种子、10052 个输入,解出 708 条消息,Protocol 18090、Utf8 1300、Capacity 456、 ConnectionClosed 258。全部通过,无崩溃、无挂起、无非法异常类型。三个目标共约 4 s。
- 目标:
- 依赖与发布配置:改为 io 0.2.0、http 0.1.1;版本 0.1.0;POM、签名、javadoc jar 与本地暂存仓库,与 http 相同(
websocket-bench不发布)。 测试 218 个(此前 215 +FuzzTest3 个),macOS arm64 全部通过。
⚖️ 参考 tungstenite 0.30(及至今 crates.io 上的全部版本)没有实现;本库按 RFC 7692 实现,以 RFC 附录 §7.2.3 的字节级示例与 Autobahn 第 12、13 节
(此前 216 个 UNIMPLEMENTED)验收。默认关闭:WebSocketConfig.compression = PerMessageDeflateConfig(...) 时客户端发出提议、服务端接受。
- 压缩库:Kotlin/Native 自带的
platform.zlib(各目标都有;mingw 静态链接 sysroot 的 libz.a),原始 DEFLATE、同步刷新。只用 32 位的avail_in/avail_out:uLong(deflateBound、total_*)在 Windows 上为 32 位、其他平台 64 位,共享源集无法编译;压缩上界自算 (len + len/1024 + 64,大于 zlib 的上界)。 - 协商(§5、§7.1):四个参数
server/client_no_context_takeover、server/client_max_window_bits;参数名不认识、重复、取值非法(9–15 以外、 前导零、带引号以外的写法)的提议整条拒绝,服务端取第一条可接受的提议。zlib 不能以 8 位窗口压缩原始 DEFLATE:会让本方以 8 压缩的提议被 拒绝(RFC 允许服务端拒绝)、服务端回应要客户端以 8 压缩时握手失败;以 8 解压可以。服务端只有在提议含client_max_window_bits时才限制 客户端窗口。客户端按自己的提议严格校验回应(多出、超出、缺值即ProtocolError.InvalidExtensionParameters)。双方从服务端实际发出的回应 读出参数(PerMessageDeflate.fromResponse),回调改动回应也一致。经 HTTP 升级的路径用negotiatePerMessageDeflate与fromUpgraded(deflate=)。 - 帧(§6):RSV1 只允许出现在压缩消息的首帧(文本 / 二进制),控制帧、续帧、未协商时仍为
NonZeroReservedBits。 - 接收:压缩消息逐帧解压进
IncompleteMessage,每块解压输出都经过消息大小上限与增量 UTF-8 校验,解压炸弹在上限处停止(测试:64 MiB 的 零压缩成约 64 KiB,在 1 MiB 上限处以MessageTooLong失败);末帧后补回00 00 ff ff;对端不接管上下文时每条消息后复位解压器。BFINAL 块 按 §7.2.3.4 处理。非法数据为ProtocolError.InvalidCompressedData。 - 发送:每条消息压缩成一帧、置 RSV1。先以压缩上界检查输出缓冲是否放得下,放不下时不触碰压缩器即返回(写驱动稍后重试同一条消息时
压缩上下文仍正确);压缩后去掉末尾
00 00 ff ff;本方不接管上下文时每条后复位。空消息为单字节00(§7.2.3.6;上一次刷新之后 zlib 不再 输出任何字节,此时直接写00——测试中发现)。 - 资源:压缩器(15 位窗口约 256 KiB)与解压器(约 44 KiB)在首次使用时分配,连接终止时释放,GC cleaner 兜底。
- 测试(
PerMessageDeflateTest,29 个):RFC 的 "Hello"、共享上下文的第二个 "Hello"(f2 00 11 00 00)、分片、BFINAL、空消息; 未压缩消息照常;各种大小(16 B–128 KiB、随机与可压缩,双向,接管与不接管);控制帧 / 续帧 / 未协商时的 RSV1;非法数据;解压后的 UTF-8; 解压炸弹;写缓冲放不下时压缩器不变;协商的接受、参数、拒绝与第一条可接受;客户端校验;握手两端都开、只开一端。全量 247 个。 - Autobahn(GitHub Actions,ubuntu-latest,Docker 运行 crossbario/autobahn-testsuite;
bench/autobahn/run.sh遇到意外结果即非零退出, 报告作为制品上传):不开压缩时两个方向与 §11.4 相同(OK 296、NON-STRICT 2、INFORMATIONAL 3、UNIMPLEMENTED 216);开压缩时两个方向均为 OK 512、NON-STRICT 2、INFORMATIONAL 3,关闭行为 OK 514、INFORMATIONAL 3,无失败。第一轮开压缩时服务端方向在 12.2.9 中断、未生成报告 (服务端日志无错误);加上"服务端提前退出"的诊断后重跑(全部及单独 12.2.*)均通过,原因未能确定。 - CI(本仓库此前没有 CI):测试在 macOS、Linux(epoll、io_uring)、Windows(IOCP、WSAPoll)上运行;Windows IOCP 的
DriverIntegrationTest.testNoSendAfterCloseTcp失败:对端断开后 IOCP 的读以 WSAECONNABORTED 结束,io 0.3.0 只把 WSAECONNRESET 算作连接重置。 已在 io 修正(main b9a425e),需随 io 的下一个版本生效。
- 缺口:参考(tungstenite)没有任何时间上限:连上后不发请求的客户端、不回响应的服务端、收到 Close 后既不回应也不断开 TCP 的对端, 都让连接一直挂着。stack 工程标准 §7.2 要求资源有界。
- 握手:
HandshakeLimits(timeoutMillis = 10_000),serverHandshake/clientHandshake(以及基于它们的accept*、client*、connect*) 整个握手在时限内完成,否则WebSocketException.Timeout("handshake", ms);0 不设限。失败时流的归属不变(服务端:调用方保留流)。 - 关闭握手:
WebSocketConfig(closeTimeoutMillis = 10_000):从第一个发出或收到的 Close 帧起,连接在时限内结束,对端不回应或不断开 TCP 时以WebSocketException.Timeout("closing handshake", ms)结束;0 不设限。计时器在连接的调度器上,连接结束时取消。 - 空闲:
WebSocketConfig(idleTimeoutMillis = 0):大于 0 时,超过该时长没有读或写即以Timeout("idle", ms)结束;默认关闭(保活 ping 属于应用)。 - 新增的默认值(握手 10 s、关闭握手 10 s)是行为变化,发版按次版本处理;新字段在末尾、带默认值,源码兼容。
- 测试(
TimeoutTest):不发请求的客户端 → 服务端 200 ms 放弃;不回应的服务端 → 客户端 200 ms 放弃;不回应 Close 的对端 → 200 ms 后 以 Timeout 结束;正常完成的关闭握手不受影响;每 100 ms 一条消息保持连接、随后静默 300 ms 以 idle 结束(共收到 10 条);默认值。去掉关闭 时限后对应测试挂到外层 20 s 超时、失败。macOS 全量 261 个通过。
- 发现:tls 仓库的 wss 跨层组合测试(websocket 经 TLS、TCP 与反应器,tls SPEC §10)中,关闭握手正常完成后客户端仍以
WebSocketException.Io(TlsTruncatedException)结束,而不是正常结束。两处原因:- 服务端完成关闭握手后由
end()直接stream.close()——TlsStream.close()是立即关闭、不发 close_notify,对端 TLS 看到截断。 - 读侧沿用 tungstenite
check_connection_reset:对端关闭帧到达后只有"连接重置"算正常结束,TLS 截断等其他读错误仍报 Io。
- 服务端完成关闭握手后由
- 修复:
- ⚖️ 写驱动在服务端关闭握手完成处(RFC 6455 §7.1.1:由服务端关闭底层连接)先
shutdownOutput()(仅当流声明HalfClose;TLS 上即 close_notify,TCP 上即 FIN),再关闭。驱动本是唯一写者,不与其他写并发;对端不读导致挂起时由关闭时限(§11.8)兜底。参考把这一步 留给丢弃流的一方。 - ⚖️
mapIoError:对端关闭帧到达后任何读错误都是正常结束,与写侧已有规则(writeFailure)对称——消息流已由关闭帧完整界定, 对端以重置还是不带 close_notify 的 TLS 结束传输不属于本协议的问题。isConnectionReset(expect/actual)因此不再需要,已删除。
- ⚖️ 写驱动在服务端关闭握手完成处(RFC 6455 §7.1.1:由服务端关闭底层连接)先
- 测试:
WebSocketCoreTest.aReadErrorAfterPeerCloseIsNormal(替代resetAfterPeerCloseIsNormal);tls-websocketCompositionTest.aServerEndsTheTlsStreamWithCloseNotify(原始 TLS 客户端手工握手并发关闭帧,必须读到 EOF 而非截断;去掉修复 1 时以TlsTruncatedException失败)与aCloseReachesThePeerWithItsCode(两处修复都去掉时失败)。均以--include-build ../websocket验证; macOS 全量 261 个通过。
- 现象:CI 的 permessage-deflate Autobahn 作业偶发失败,此前记为未解释(运行 37706619816 在 12.2.9 中止)。
- 取证:
run.sh在报告缺失时打印退出状态、日志尾、内核 OOM 记录与内存(889125c),并在临时分支上并行重复:- 第一组 10 轮(运行 38075521906):9 轮通过;第 8 轮的模糊测试客户端以 137 退出,内核记录
Out of memory: Killed process (wstest) anon-rss:15613912kB——16 GB 的 runner 被 wstest 自身(Python,容器内)用到 15.6 GB,死在 13.2.16。本库服务端不是被杀的进程。 - main 上 65dda80 的 CI(运行 38077390805):模糊测试客户端启动后 28 分钟无任何输出,步骤以 143 结束,无诊断——与内存耗尽后系统卡死 相符;同一代码在临时分支上 10 轮全部通过。
- 第二组(运行 38077995942,含 65dda80):8 轮分批 + 2 轮单进程,全部通过。单进程(全部用例)峰值 RSS 两轮都是 3.8 GB;分批时单个 小节(如 12.2.*)的峰值在 0.9–5.5 GB 之间,且按轮整体偏高或偏低(第 4 轮每批约 5 GB,第 1 轮约 1–1.8 GB)。wstest 的内存不只随用例 累积,同一小节在不同主机上相差数倍;15.6 GB 约为三个高位小节的叠加。原因在 wstest 内部(本库无法控制),未进一步追究。
- 第一组 10 轮(运行 38075521906):9 轮通过;第 8 轮的模糊测试客户端以 137 退出,内核记录
- 处理:
run.sh对服务端方向的模糊测试分批运行(1–11 节一批,12、13 节每个小节一批,各为新进程),合并各批报告,打印每批峰值; 每批有看门狗:wstest 超过 12 GiB 或运行超过 20 分钟即停止该容器并留下诊断,不再让整台 runner 卡死。96 个批次中观测到的最大峰值 5.5 GB。客户端方向(wstest 作服务端)未见内存问题,不变。 - 本节的结论是 I 级(基于 CI 上的重复运行);main 上之后的运行继续记录每批峰值。