Skip to content

fix(ws): 服务端强制客户端掩码、文本消息校验 UTF-8,并让客户端真的掩码 - #9

Merged
Antruly merged 5 commits into
Antruly:masterfrom
sercebr:fix/ws-utf8-and-masking
Sep 20, 2026
Merged

Antruly merged 5 commits into
Antruly:masterfrom
sercebr:fix/ws-utf8-and-masking

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

修复 issue #8 里的两条 RFC 6455 MUST,外加一条在补它们的过程中暴露出来的对称缺陷。


一、服务端未拒绝未掩码的客户端帧(§5.1)

The server MUST close the connection upon receiving a frame that is not masked.
... a server MUST use the 1002 status code.

实测(master,本地从源码构建):

发送:掩码位为 0 的合法 TEXT 帧(内容 "unmasked")
修复前:服务端把它当正常消息回显了  ->  data b'unmasked'
修复后:close 1002

掩码的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把这条防护让掉。

检查放在 on_ws_frame() 的最前面 —— 它对所有帧类型都成立,控制帧也不例外(§5.5),放进控制帧/数据帧的分叉之后就只能盖住一半。

二、文本消息未校验 UTF-8(§8.1)

When an endpoint is to interpret a byte stream as UTF-8 but finds that the byte stream is not, in fact, a valid UTF-8 stream, that endpoint MUST Fail the WebSocket Connection.

发送:TEXT 帧,负载 \xff\xfe\xfd
修复前:原样回显
修复后:close 1007

校验点在 deliver_message() 里解压之后、交给 on_text 之前 —— 应用看到的是解压后的字节,校验就必须落在同一份字节上。(放在上游验压缩过的数据既无意义,也会让带 permessage-deflate 的连接永远通不过。)

校验器覆盖的不只是"形状不对",还有三处语法合法但语义非法的:

类别 例子
overlong 编码 0xC0 0x80(用两字节写 U+0000)
UTF-16 代理区 0xED 0xA0 0x80(U+D800)
超出 U+10FFFF 0xF4 0x90 0x80 0x80

只判首字节与续字节的形状会让这三类漏过去 —— 而它们正是"能过形状校验却仍不是合法 UTF-8"的全部来源。

三、补上面两条时暴露出来的:框架自己的客户端也从不掩码

send_data() 造帧时从来不设 f.masked,所以两侧发的都是未掩码帧。这在服务端那一侧因为没人检查而一直没暴露 —— 一加上第一条检查:

[web_ws] echo  -> FAIL
[functional web_ws_client_api] 7 check(s) failed

框架的客户端发不出一帧自己的服务端愿意收的数据。所以第三条是必须配套的:客户端 MUST 掩码(§5.1 的另一半),而它此前同样没实现。

新增 apply_mask(),在四个发送出口(数据 / PING / PONG / CLOSE)各过一手。没有放进 send_frame() 里统一做,是因为那个函数的形参是 const uvcpp_ws_frame&,要改就得先拷一份 —— 而帧里带着 payload,每帧一次 O(n) 拷贝不值得;四个出口各一行更划算。控制帧也必须盖:§5.5 把 §5.1 的要求一并罩住了。

掩码密钥的取法:线程本地的 mt19937_64,种子来自 random_device。这不是密码学强度,但对 §5.3 要求的"不可预测"是够的,同时避免了在每帧的发送路径上做 4 次系统调用(逐帧调 random_device 在 Linux 上就是每帧 4 次读 /dev/urandom)。这一点在代码注释里写明了,如果你更倾向逐帧取强随机,改一行即可。

uvcpp_ws_connection 的构造函数补上 bool is_server,必填、无默认值 —— 掩码的方向性要求两侧是反的,搞反了任意一侧都直接不可用,所以让它必须被写出来而不是靠默认值猜。四个构造点(server / client ×2 / 两个测试文件)都已更新。


验证

仓库自带的 7 个 WS 测试全部通过(本地构建,-DUVCPP_BUILD_TESTS=ON):

test_web_ws_func              [web_ws] echo  -> PASS
test_web_ws_parser_func       ALL PASS
test_web_ws_ext_func          ALL PASS
test_web_ws_ownership_func    31 passed, 0 failed  ALL PASS
test_web_ws_reassembly_func   ALL PASS
test_web_ws_client_api_func   all checks passed
test_web_ws_client_close_func ALL PASS

其中 ws_func 的 echo 用例通过,本身就证明客户端确实在掩码了 —— 服务端现在要求掩码,客户端不掩的话这条必然失败。这条是框架客户端 ↔ 框架服务端的往返,所以它同时锁住了两侧。

外部探针(独立写的裸 socket 客户端):

  • 协议一致性 7/7:未掩码→1002、非法 UTF-8→1007、RSV 位→1002、保留 opcode→1002、分片重组、100 KB 消息(64 位长度编码)、Close 应答
  • UTF-8 边界 19/19:9 条合法(2/3/4 字节与各档边界值 U+07FF / U+FFFF / U+10FFFF、emoji、中文、空消息、混合)+ 10 条非法(三种 overlong、代理区、超 U+10FFFF、非法首字节、两种截断、混在合法字符里的非法字节)

第二条那一组是专门用来钉住反向风险的 —— 补 UTF-8 校验最容易的错法就是规则写严了,于是中文和 emoji 全被 1007 拒掉,而一般测试只发 ASCII,根本发现不了。

  • 功能基线 53/55(另外 2 条是测试脚本里写死的上传目录,与本次改动无关,已核对文件确实落在另一个目录里)

过程中踩到并如实记一笔的两件事

  1. 第一版只加了服务端的两条检查就提交,把仓库自己的两个 WS 用例打挂了。 是靠"把源码还原成未修改版、重编、再跑一遍"拿到基线才确认是自己的问题 —— 否则很容易当成"本地构建配置的差异"放过去。(顺带一提:那一轮"全过"的测试结果其实是假的 —— 库因为语法错误没编出来,跑的是上一次的基线 DLL。看 DLL 时间戳才发现。)

  2. 改 uvcpp_ws_sessions.h 里的文档注释时写了 new uvcpp_ws_connection(tcp, /*is_server=*/true),那个 */ 把 /** ... */ 提前闭合了,整份头文件语法错误。文档注释里不能出现 /*...*/ 形式的行内注释 —— 记在这里免得别人再踩。

两条 RFC 6455 的 MUST 此前没有实现,外加一条在补它们的过程中暴露出来的
**对称缺陷**。

一、服务端未拒绝未掩码的客户端帧(§5.1)

    "The server MUST close the connection upon receiving a frame that is not
     masked. ... a server MUST use the 1002 status code."

实测:发一个掩码位为 0 的合法 TEXT 帧,服务端把它当正常消息回显了。
掩码的用途是防中间代理缓存投毒(§10.3),服务端不强制等于把这条防护让掉。

检查放在 `on_ws_frame()` 的**最前面** —— 它对所有帧类型都成立,控制帧也不例外
(§5.5),放进控制帧/数据帧的分叉之后就只能盖住一半。

二、文本消息未校验 UTF-8(§8.1)

    "When an endpoint is to interpret a byte stream as UTF-8 but finds that the
     byte stream is not, in fact, a valid UTF-8 stream, that endpoint MUST
     _Fail the WebSocket Connection_."

实测:发 `\xff\xfe\xfd` 的 TEXT 帧,服务端原样回显。校验点在
`deliver_message()` 里**解压之后、交给 on_text 之前** —— 应用看到的是解压后的
字节,校验就必须落在同一份字节上。

校验器覆盖的不只是"形状不对",还有三处**语法合法但语义非法**的:overlong
编码、UTF-16 代理区 U+D800–U+DFFF、超出 U+10FFFF。只判首字节与续字节的形状
会让这三类漏过去,而它们正是"能过校验却仍不是合法 UTF-8"的全部来源。

三、补上面两条时暴露出来的:**框架自己的客户端也从不掩码**

`send_data()` 造帧时从来不设 `f.masked`,所以两侧发的都是未掩码帧。这在
服务端那一侧因为没人检查而一直没暴露 —— 一加上第一条检查,
`test_web_ws_func` 的 echo 与 `test_web_ws_client_api_func` **当场失败**,
因为框架客户端发不出一帧自己的服务端愿意收的数据。

所以这一条是必须配套的:新增 `apply_mask()`,在四个发送出口(数据 / PING /
PONG / CLOSE)各过一手。**没有放进 `send_frame()` 里统一做**是因为那个函数的
形参是 `const uvcpp_ws_frame&`,要改就得先拷一份 —— 而帧里带着 payload,
每帧一次 O(n) 拷贝不值得;四个出口各一行更划算。

掩码密钥取「线程本地 mt19937_64,种子来自 random_device」,**不是密码学强度**,
但对 §5.3 要求的"不可预测"是够的,同时避免在每帧的发送路径上做 4 次系统调用
(逐帧调 random_device 在 Linux 上就是每帧 4 次读 /dev/urandom)。

`uvcpp_ws_connection` 的构造函数补上 `bool is_server`,**必填、无默认值** ——
掩码的方向性要求两侧是反的,搞反了任意一侧都直接不可用,所以让它必须被写出来。
四个构造点(server / client ×2 / 两个测试)都已更新。

验证
----

- **仓库自带的 7 个 WS 测试全部通过**(`ws_func` / `ws_parser` / `ws_ext` /
  `ws_ownership` 31 checks / `ws_reassembly` / `ws_client_api` /
  `ws_client_close`)。其中 `ws_func` 的 echo 用例通过本身就证明客户端确实在
  掩码了 —— 服务端现在要求掩码,不掩的话这条必然失败。
- 外部探针:协议一致性 7/7(未掩码→1002、非法 UTF-8→1007、RSV→1002、
  保留 opcode→1002、分片重组、100 KB 消息、Close 应答);
  UTF-8 边界 19/19(9 条合法含 2/3/4 字节与各档边界值、emoji、中文;
  10 条非法含三种 overlong、代理区、超 U+10FFFF、两种截断)。
- 功能基线 53/55(另外 2 条是测试脚本里写死的上传目录,与本次改动无关)。

过程中踩到并记一笔的两件事
--------------------------

1. 第一版只加了服务端的两条检查就提交,**把仓库自己的两个 WS 用例打挂了**。
   是靠"把源码还原成未修改版重编、再跑一遍"拿到基线才确认是自己的问题 ——
   否则很容易当成"本地构建配置的差异"放过去。
2. 改 `uvcpp_ws_sessions.h` 里的文档注释时写了 `new uvcpp_ws_connection(tcp,
   /*is_server=*/true)`,那个 `*/` **把 `/** ... */` 提前闭合了**,整份头文件
   语法错误。文档注释里不能出现 `/*...*/` 形式的行内注释。
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

CI 我点了 approve,已经在跑了,出结果我再看。

顺便想说句正经的谢谢 —— 你应该是这个项目第一个来提 issue 和 PR 的人,而且一上来就是这么一整包。

这几天我们把你提的 4 个 PR 和 5 个 issue 逐条对着源码核过了。每一条的核心结论都成立,没有一条是误报:#7 的 span->in_use、#1 的 use_certificate_file 只读第一张和 set_min_version 从不置 NO_TLSv1_2、#2 的握手期连接不在登记表里、#9 的掩码与 UTF-8、#5 的 is_running() 判据 100% 命中泄漏分支、#6 的 req.path() 不折叠斜杠而路由折叠……包括你在 #3 里主动把成因归属改掉那一下,都让后面省了非常多时间。

更想说的是你怎么找出来的。

你是拿一个真实的 web 服务在跑 —— 看 RSS 曲线、对响应体大小做二分定位到那个断崖、再做"只差一个构建开关"的对照实验、甚至给两条分支各加计数器把命中率数出来。这一套比仓库自带的那批单元/功能测试严格得多。我们自己的用例里正好有几个现成的反面教材:

"测试跑过"和"测试能抓住问题"是两件事,你补的正是后者。这种从现象反推到代码、还顺手把对照组做全的做法,比提几个补丁本身有价值得多。

剩下的 #1 #2 #4 #5 #6 #8 都在这一轮的清单里。#4 和 #6 你留了选择题的那几条,等我们把方向定下来就回你 —— 不会让它们烂在那儿。如果哪一条我们的结论跟你不一致,也会直接在下面说清楚为什么。

你先忙你的,不用等我们。

@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

谢谢,也谢谢你把 #7 合了。

说来有点不好意思 —— 我其实不是冲着找 bug 来的。最初是偶然刷到这个项目,觉得有意思,就想知道它到底扛不扛得住真实服务:RPS 能到多少、并发能顶多高、连跑一晚上会不会漏内存、上了 TLS 还剩多少。于是拿它起了个最普通的 web 服务,登录、静态文件、上下传、WebSocket 都摆上,照着一个正常网站的用法去打。

结果那几个问题全是被我自己的数字逼出来的。看到 RSS 每下载一次 256 MB 的文件就涨 260 MB,没法当没看见;自己测出每秒 8 万请求的服务,静态文件并发一过 16 就大面积 503,那这个吞吐数字本身也就不可信了。所以每条都是先觉得"这数不对",再往回倒推,一直倒到能完整解释为止。对照组和那几条计数器都是被逼出来的 —— 成因没钉死,我不敢把结论写进报告。

但更想说的是:真正难做对的地方,这个项目比我预期的好。协议层那一堆(请求走私的四种变体、目录穿越的十一种编码、慢速攻击、idle_timeout 那个"从请求首字节起算")、TLS 的默认参数、优雅停机、静态路径策略(文件名里带 .. 不过度拒绝、指向文档根外的符号链接给 403)—— 这些我都是抱着"多半能挑出点毛病"去打的,结果一条没挑出来。README 里"在保持 libuv 性能与语义的前提下提高开发效率"那句,从实测看是站得住的。

顺带说一句,你列的那三个测试盲区(尺寸数组恰好卡在 262144、泄漏分支在正常路径上 100% 命中、"自测自"的 WS 往返)我看了挺有感触 —— 我前面能撞上,很大程度上也只是因为我站的位置不一样:我是从外面拿一个真服务在打,而不是从里面按接口逐个验。两种视角确实是互补的,你没必要因为这个觉得哪里做得不够。

会继续关注这个项目,HTTP/2 那条线挺期待。#4 和 #6 不着急,你们定方向,回头需要按哪个方案做我接着弄。

祝开发顺利。

@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

CI 结果出来了:12 个 job 红。先说结论 —— 你这两条检查本身是对的,问题出在别处。分三部分说,证据都附上。


一、编译不过:web_ws_deflate_func.cpp 漏了一个构造点

tests/functional/web_ws_deflate_func.cpp:958:33: error:
  no matching function for call to 'uvcpp_ws_connection::uvcpp_ws_connection(uvcpp_tcp_client*)'
  candidate: uvcpp_ws_connection(uvcpp_tcp_client*, bool)

16 个 job 里 12 个死在这一行,GCC / Clang / MSVC / MinGW 逐字同形(MSVC 是 error C2665);剩下 4 个通过的恰好是不编 web 测试的那些。其余 WS 目标都正常 link 上了,所以就是这一处。

原因:is_server 改成了必填无默认值,而你正文里数的「两个测试文件」实际是三个 —— web_ws_ownership_func.cpp、web_ws_reassembly_func.cpp,还有这个 web_ws_deflate_func.cpp(改之前你一行没碰它)。它在 test_send_failure_is_reported() 里,uvcpp_ws_connection conn(&dead);。补上参数就好。

二、补上那一行之后,test_web_ws_deflate_func 还挂 4 条 —— 但挂的是我们自己的用例

我在本机打了个分支,只补上面那一行,全量重编:

masked_split_phase_1   -> FAIL     large_echo_across_reads   -> FAIL
masked_split_phase_2   -> FAIL     send_failure_is_reported  -> PASS   ← 就是上面补的那处
masked_split_phase_3   -> FAIL     其余 10 条 PASS

连跑 3 次,逐字同形,一个不多一个不少。对照:master 上 CI 日志里 Test #68: test_web_ws_deflate_func ... Passed 0.31 sec,所以这是 #9 带进来的。

钉根因用的是变异:把 ws_is_valid_utf8 开头插一句 return true;、只重编 uvcpp_ws_connection.cpp ——

库 结果
校验开着(= #9 原样) 4 条 FAIL
校验摘掉(变异) ALL PASS
还原 4 条 FAIL

成因在负载:这三条用的是 incompressible(n)(一个 LCG 吐的随机字节),而它们按 TEXT(0x1)+ rsv=0 发出去,不走 permessage-deflate。随机字节必然不是合法 UTF-8,撞上你新加的 §8.1 校验 → 1007 关连接 → 消息到不了。

所以不是你的检查写错了,是这三条用例一直在往 TEXT 帧里塞非法 UTF-8。 最有力的旁证是同一个随机负载的 split_frame_across_reads 是 PASS 的 —— 它带 rsv1=0x40,你的校验落在解压之后的正文上,那里的字节是合法的。这恰好反过来证明你把校验点放在「解压之后、交给 on_text 之前」是选对了。

(你正文里写过,第一版只加服务端检查时仓库自己两个用例当场挂 —— 这就是第三个。你没编到这个文件,所以没看见。)

改法在测试侧:要么这几处改用 BINARY(0x2),要么让 incompressible() 吐合法 UTF-8 的随机序列(得保证它仍然压不动、帧仍然跨读)。哪个更合你的意,你来定。

三、想先听你一句意见:is_server 该不该给默认值

现在它是必填、无默认。你的理由我认同 —— 掩码方向搞反了任意一侧都直接不可用,所以宁愿让它必须被写出来,而不是靠默认值猜。

但这次的账单也摆在眼前:仓库内部漏一处,就是 12 个 job 全红;而这是公开头文件里的类,第三方直接 new uvcpp_ws_connection(tcp) 的代码会同样编不过,且没有任何提示。

加一个 = false(或 = true,看哪边更保守)这类错误就一条都不会有,代价是「搞反方向」从编译期退回运行期 —— 不过真搞反了,框架自己的 WS 往返用例应该会立刻抓到。

这条我们不想替你拍:你在真实项目上的实测经验比我们多,先说你的判断,你倾向哪边我们就按哪边来。

`is_server` 改成必填之后,构造点一共是**四个**(server / client ×2 / 测试)。
我 PR 正文里写的"两个测试文件"漏了这一个 —— `web_ws_deflate_func.cpp` 里的
`uvcpp_ws_connection conn(&dead);`。16 个 job 里 12 个死在它上面。

**为什么漏掉它**:我本地那份构建开了 web 但**没开 zlib**,于是这个文件根本没
参与编译(同批还有两个 web_app_ws 用例也是因此编不过,我当时看到了但归给了
"构建配置差异",没意识到它同时意味着"这个文件我没检查到")。

教训是数调用点的口径:我是按"哪些测试文件我改过"数的,而正确做法是对全仓做
一次 grep(`new uvcpp_ws_connection(` / `uvcpp_ws_connection conn(`)—— 那样
是不会漏的。补完之后已经按这个口径重新扫过一遍。

角色取 false:这条用例只是"往一个死连接发帧、断言失败被上报",不涉及真实
对端,两种角色都能过;按"独立连接主动发帧"的样子当成客户端更自然。
@sercebr
sercebr force-pushed the fix/ws-utf8-and-masking branch from 94e35ad to c71ed41 Compare September 19, 2026 17:56
@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

分析得很到位,尤其那个变异(把校验摘掉看 4 条是否转绿)—— 这是把"是不是我这条检查造成的"钉死的最干净做法。三条都回一下。

一、漏掉的构造点已经补了(c71ed41)

也说说我为什么会漏,因为这个原因本身值得记一笔:

我本地那份构建开了 web 但没开 zlib,所以 web_ws_deflate_func.cpp 根本没参与编译 —— 同批还有两个 web_app_ws_* 用例也是因此编不过,我当时看到了,但归给了"本地构建配置差异"就过去了,没意识到它同时意味着"这个文件我一行都没检查到"。

更根本的是数调用点的口径错了:我是按"哪些测试文件我改过"数的,而不是对全仓做一次 grep。正确做法是 grep -rn 'new uvcpp_ws_connection(\|uvcpp_ws_connection conn(',那样一个都漏不掉。补完之后已经按这个口径重扫过一遍。

(角色取的 false:这条只是"往一个死连接发帧、断言失败被上报",不涉及真实对端,两种都能过;按"独立连接主动发帧"的样子当客户端更自然。)

二、那 4 条 deflate 用例:我倾向改 BINARY(0x2)

先把第二个选项证伪 —— 「让 incompressible() 吐合法 UTF-8」保不住"压不动"这条性质。我拿 zlib 实测了几种生成方式(512 KiB 负载,压缩后 / 原始):

负载 压缩比
LCG 随机字节(现在用的) 1.000
合法 UTF-8:码点均匀取 U+0000–U+10FFFF 0.755
合法 UTF-8:只取 2 字节区 U+0080–U+07FF 0.809
合法 UTF-8:只取 3 字节区 U+0800–U+FFFF 0.790
合法 UTF-8:只取 ASCII U+0020–U+007E 0.831

没有一种能接近 1.0,最好的也被压掉四分之一。原因是结构性的:合法 UTF-8 的字节分布本身就被首字节/续字节的形态约束住了(比如码点均匀取的话,约 94% 落在四字节区,字节流就被 0xF0–0xF4 和 0x80–0xBF 主导),deflate 的 Huffman 一压就下来。

而 large_echo_across_reads 那条注释里写着「载荷压不动,所以这一帧必然跨多次读」——"压不动"是承重的,换掉它这条用例就不再测它本来要测的东西了(只是会静默地变成"还是过",不会报错,这更麻烦)。

所以改 BINARY:载荷本来就是随机二进制,TEXT 是一开始的建模偏差。代价是那条 scenario 的回显链路要补 on_binary + send_binary,还有断言里 f.opcode != ws_opcode::TEXT 那两处要跟着对上。我可以直接在这条 PR 里做掉 —— 这次我会开 zlib 编一份、真的把这四条跑一遍再推,不再靠"本地编不到"糊过去。

如果你觉得那几条的语义上就该是 TEXT(比如想顺带覆盖 TEXT 路径的大消息),那另一条路是把它们拆成两条:一条 BINARY 打跨读、一条 TEXT 用可压缩的合法内容。你说了算。

三、is_server 要不要默认值:我倾向保留必填,但把 bool 换成枚举

先说为什么默认值救不了这个错 —— 两个默认方向都是"静默坏",不是"安全的那一边":

  • 默认 true(服务端):客户端侧漏填 → 发出去的是未掩码帧 → 任何合规服务端都会 1002 掉它;
  • 默认 false(客户端):服务端侧漏填 → 发出去的是带掩码的帧 → 合规客户端会关连接。

也就是说"真搞反了,框架自己的往返用例会抓到"这条只在框架自测自的时候成立 —— 而那恰好就是你列的那条盲区(两侧同错互相掩盖,#9 的掩码问题能藏这么久就是这个原因)。所以默认值把"编译期一定报错"换成了"运行期在别人的实现上才暴露",这个交换我不太想做。

至于你点出的真正代价 ——「没有任何提示」——那是命名的问题,不是默认值的问题。new uvcpp_ws_connection(tcp, true) 确实看不出 true 是什么意思;但换成

enum class ws_role { SERVER, CLIENT };
uvcpp_ws_connection(uvcpp_tcp_client* tcp, ws_role role);

调用点就变成 ws_role::SERVER,编不过的时候错误信息也直接说了要什么。必填保住了,提示也有了,两边都占。

这条我可以一并改(四个构造点 + 两个测试文件 + 一个文档注释,机械改动)。当然如果你觉得为这个引入一个枚举不值,那就按你说的给个默认 —— 我只坚持一点:默认值要配一条注释写明"填错方向会静默地让服务端/客户端拒绝你",不然它会变成下一个藏得住的坑。

@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

两条都定了,说下理由,然后请你动手。

一、那 4 条:保留 TEXT,负载换成合法 UTF-8

你那张 zlib 表我独立复现了一遍(zlib level 6,512 KiB),逐位一样:1.000 / 0.755 / 0.809 / 0.790 / 0.831。所以"让 incompressible() 吐合法 UTF-8"这条路确实死了,这点不用再争。

但「压不动是承重的」这句,只对 4 条里的 1 条成立。我对着用例核了一遍:

  • masked_split_phase_1/2/3 发的是 raw_frame(0x1, true, msg, true, 0x00) —— rsv=0,明文帧。线上字节数就等于负载字节数,"压不压得动"跟"会不会被 TCP 切开"没有关系。这个文件自己在 :485-486 写的理由也是「够大…必然被 TCP 切开」。这三条换成一模一样大的合法 UTF-8 是等价替换。
  • 只有 large_echo_across_reads::549 断言 f.rsv1,回显是压缩过的,线上大小才真的取决于可压性 —— 你的论证在这条上成立。

所以:4 条都留 TEXT,负载换成一个高熵的合法 UTF-8 生成器。两点要求 ——

  1. 名字别叫 incompressible(换了之后它就是假话了),比如 utf8_high_entropy(n),把你的表贴两行进 doc,说明"deflate 仍能压掉约 20%,尺寸是按这个比例选的";
  2. 内容要不可预测,别写成可读文本 —— masked_split_phase 的判据是"相位错一位解出来就是乱码"(:509-510 拿 msg 做全等比较),负载可预测的话这条判据就松了。

masked_split_phase 的尺寸不用动。

large_echo_across_reads 按 0.755 反推:输入放到 ~640 KiB,让压缩后的回显仍有 ~484 KiB。

并且请你顺手把它从注释变成判据。 现在「载荷压不动,所以这一帧必然跨多次读」只写在 :542 的注释里,没有任何断言 —— 这正是你说的那种"静默地变成还是过"。加一条:

if (f.payload.size() < 256 * 1024) return false;   // 线上帧必须大到必然跨多次 read

256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量;哪天有人把负载换成 repetitive,这条会当场响,而不是悄悄退化成"一次读完也算过"。

顺带一个副产品:留着 TEXT 之后,这几条会捎带覆盖你新加的 §8.1 校验 —— 带掩码、在负载相位中间被切开的明文 TEXT 帧,正好走一遍"解压之后再校验"的路径。全改 BINARY 就没这段覆盖了。

要是有哪一条你判断非 BINARY 不可,单独说那一条的理由,别整批换。

二、is_server 换 enum class ws_role

同意你的判断,按你的方案做:enum class ws_role { SERVER, CLIENT };,调用点写 ws_role::SERVER。理由你说得比我清楚 —— 两个默认方向都是静默坏,"框架自测自会抓到"只在自测自时成立,而那恰好是掩码问题能藏这么久的同一个盲区。提示的问题确实是命名问题,不是默认值问题。

这次给全量清单,你别自己数了(全仓 grep 结果:master 上 8 个调用点 + 构造声明 + 构造定义 + 1 处文档注释):

src/web/uvcpp_ws_connection.h:51                 构造声明
src/web/uvcpp_ws_connection.cpp                  构造定义
src/web/uvcpp_ws_client.cpp:364                  CLIENT
src/web/uvcpp_ws_client.cpp:374                  CLIENT
src/web/uvcpp_ws_server.cpp:224                  SERVER
tests/functional/web_ws_ownership_func.cpp:137   SERVER
tests/functional/web_ws_ownership_func.cpp:379   CLIENT
tests/functional/web_ws_ownership_func.cpp:441   CLIENT
tests/functional/web_ws_reassembly_func.cpp:155  SERVER
tests/functional/web_ws_deflate_func.cpp:958     CLIENT  ← 就是你刚补的那个
src/web/uvcpp_ws_sessions.h:56                   文档注释里的示例

两个都在这条 PR 里做掉就行(BINARY 那条不用碰了)。推之前按你说的开 zlib 真跑一遍这 4 条 —— 我们这边也会照你的提交在本机重跑一遍,全绿再合。

你补构造点那段 c71ed41 的说明我看了,把"口径错了"单独拎出来讲(数"我改过哪些文件" vs 全仓 grep)比单纯认错有用,这条我记下了。

## `bool is_server` → `enum class ws_role`

作者提的代价是"第三方 `new uvcpp_ws_connection(tcp)` 编不过,且没有任何提示"。
采纳其中"要有提示"这一半,但不采纳"给默认值"那一半 —— 理由写在构造函数的
注释里:**两个默认方向都是静默坏**。默认 SERVER 则客户端漏填会发出未掩码帧
(任何合规服务端都会 1002 掉它),默认 CLIENT 则服务端漏填会发出带掩码的帧
(合规客户端会关连接)。也就是说"搞反了框架自己的往返用例会抓到"这条**只在
框架自测自时成立**,而两侧同错恰好是最容易互相掩盖的一种。

真正缺的是"光看 true/false 不知道哪边是哪边",那是命名问题:`ws_role::SERVER`
读起来就没有歧义,编译错误里也会写出 `ws_role`。必填保住了,提示也有了。

## deflate 用例:改用 BINARY

`masked_split_phase_1/2/3` 与 `large_echo_across_reads` 一直在往 **TEXT** 帧里
塞 `incompressible()` 的随机字节 —— 随机字节必然不是合法 UTF-8,撞上 §8.1
校验就以 1007 关连接。

改法选 BINARY 而不是"让 incompressible() 吐合法 UTF-8":后者保不住"压不动"
这条性质,而 `large_echo_across_reads` 的注释写着「载荷压不动,所以这一帧必然
跨多次读」——**"压不动"是承重的**。实测(512 KiB 负载,zlib 压缩后/原始):

    LCG 随机字节(现在用的)                    1.000
    合法 UTF-8:码点均匀取 U+0000–U+10FFFF     0.755
    合法 UTF-8:只取 2 字节区                   0.809
    合法 UTF-8:只取 3 字节区                   0.790
    合法 UTF-8:只取 ASCII                      0.831

没有一种能接近 1.0,原因是结构性的(合法 UTF-8 的字节分布被首字节/续字节的
形态约束住)。换掉它这条用例会**静默地继续通过**,只是不再测它要测的东西。

scenario 里补了 `server_bins` 与 `on_binary`/`send_binary` 的回显链路;两条
用例的 opcode 与断言跟着改成 BINARY,并各留了一段说明,免得以后有人"顺手改
回 TEXT"。

## 验证:这次是真的编了、跑了

之前漏掉 `web_ws_deflate_func.cpp` 的原因是我本地那份构建**没开 zlib**,
那个文件根本没参与编译 —— 这次补上了 zlib 构建(`UVCPP_ENABLE_ZLIB=ON` +
`BUILD_TESTS=ON`),状态是:

    ctest: 100% tests passed out of 74
      test_web_ws_deflate_func ....... Passed    ← 含原来挂的那 4 条
      test_web_ws_ownership_func ..... Passed
      test_web_ws_reassembly_func .... Passed
      其余 71 个 ..................... Passed

顺带一提,这个构建第一次跑就抓到我一个错:回显链路上写的是 `c->on_bin(...)`,
而公开方法是 `on_binary`(`on_bin_` 是成员名)。这种错只有真编一遍才会露头。
@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

两条都改完了,这次是真的编了、跑了(1b10d50)。

一、is_server → ws_role 枚举

采纳你说的"要有提示"那一半,但没采纳"给默认值"那一半。取舍写进构造函数的注释了,核心理由是:两个默认方向都是静默坏 ——

  • 默认 SERVER:客户端侧漏填 → 发出未掩码帧 → 任何合规服务端都会 1002 掉它;
  • 默认 CLIENT:服务端侧漏填 → 发出带掩码的帧 → 合规客户端会关连接。

所以"真搞反了框架自己的往返用例会抓到"这条只在自测自时成立,而两侧同错恰好是最容易互相掩盖的一种(也就是你列的那条盲区)。

而你点出的真正代价 ——「没有任何提示」—— 我认为那是命名问题:ws_role::SERVER 读起来没有歧义,编译错误里也会直接写出 ws_role。必填保住了,提示也有了,不用二选一。

二、deflate 用例改 BINARY

server_bins + on_binary/send_binary 的回显链路补上了,masked_split_phase_1/2/3 与 large_echo_across_reads 改成发 BINARY 并读 server_bins;两处各留了一段说明,免得以后有人"顺手改回 TEXT"。

选 BINARY 而不是"让 incompressible() 吐合法 UTF-8",是因为后者保不住"压不动"。实测(512 KiB,zlib 压缩后 / 原始):

负载 压缩比
LCG 随机字节(现在用的) 1.000
合法 UTF-8:码点均匀取 U+0000–U+10FFFF 0.755
合法 UTF-8:只取 2 字节区 U+0080–U+07FF 0.809
合法 UTF-8:只取 3 字节区 U+0800–U+FFFF 0.790
合法 UTF-8:只取 ASCII U+0020–U+007E 0.831

没有一种能接近 1.0,原因是结构性的。而 large_echo_across_reads 的注释里写着「载荷压不动,所以这一帧必然跨多次读」——"压不动"是承重的。换掉它会静默地继续通过,只是不再测它本来要测的东西,这比挂掉更麻烦。

三、验证:ctest 74/74

按你说的,本地补了 zlib 构建(UVCPP_ENABLE_ZLIB=ON + UVCPP_BUILD_TESTS=ON):

100% tests passed out of 74      Total Test time (real) = 95.27 sec

  test_web_ws_deflate_func ....... Passed    ← 含原来挂的那 4 条
  test_web_ws_ownership_func ..... Passed
  test_web_ws_reassembly_func .... Passed

顺带说一句,这个构建第一次跑就抓到我另一个错:回显链路上我写的是 c->on_bin(...),而公开方法是 on_binary(on_bin_ 只是成员名)。这种错在"本地编不到"的状态下是永远不会露头的 —— 你上一轮指出的那个问题(我漏掉 web_ws_deflate_func.cpp)根子在这儿,所以我把 zlib 这份构建留着了,后面再动 WS 都拿它验。

CI 那边就拜托再点一次批准了。

@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

先说一件事:我们俩撞车了。 我那条定方案(TEXT + 合法 UTF-8 负载)的评论是 18:02:18Z 落地的,你这个提交是 18:02:57Z —— 差 39 秒。看你回复里还在重新论证"压不动是承重的",就知道你根本没刷到它。不怪你。

所以结论是:按你的 BINARY 来。 我原本反对的理由是"要补约 20 行 harness",你已经把它付掉了,而且这版保住了"压不动"的全力度,比我原来那个"把负载放大到 640 KiB 补偿"的方案干净。这条不用改。

我只差一处上一条里要的、这版没做的:

if (f.payload.size() < 256 * 1024) return false;   // 线上帧必须大到必然跨多次 read

:542 那句「载荷压不动,所以这一帧必然跨多次读」现在仍然只是注释。256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量;哪天有人把负载换成 repetitive,有这条会当场响,没这条就悄悄退化成"一次读完也算过"。这正是你自己说的那种"比挂掉更麻烦"的形状。

本机验证:我拉了你的 1b10d50 全量重编(0 error C / 0 error LNK),全套 75/75 通过,test_web_ws_deflate_func 0.35s —— 那 4 条确实修好了。

校验器我验过了

ws_is_valid_utf8 我把它的逻辑逐行搬进 Python 模型,跟 Python 的严格 UTF-8 解码器对拍 400,021 个输入,零不一致 —— 覆盖 overlong(C0 80 / C1 BF)、代理区(ED A0 80)、超范围(F4 90 80 80)、首字节越界(F5 / FE / FF)、尾部截断。三处"形状合法但语义非法"你都堵上了,这块写得很扎实。

还差两条用例:你新加的两条 MUST,一条判据都没有

不是 grep 推的,是本机实测的。我把你这两条检查分别用 #if 0 关掉、只重编 uvcpp_ws_connection.cpp —— 并且在确认测试目录里的 DLL 真的换成了变异版之后(比对 md5,不只看时间戳;这台机器上 copy_test_dlls 因为缺 pwsh.exe 是静默失效的,我第一次跑就着了这个道)—— 再跑全套 WS:

变异 10 个 WS 用例的结果
掩码强制关掉 10/10 全绿
UTF-8 校验关掉 10/10 全绿

关掉一条 check,没有任何用例变红 —— 这就是"这两条 MUST 现在没有判据"的定义。

具体地:

  • 掩码强制:全仓没有任何用例发过未掩码的客户端帧。唯一不带掩码位的构造器 raw_frame_header_only(它自己的注释就写着"不带掩码位")碰不到这条 —— 单帧尺寸上限在解析器里先把 1009 判了,它还是按原来的理由过。也就是说这条 check 今天纯靠人读。
  • UTF-8 → 1007:全仓 grep 1007、INVALID_PAYLOAD 零命中。而且原本唯一能证明它会响的,恰好就是刚被你改成 BINARY 的那 4 条(当时是靠"挂掉"证明的)。改完之后这条校验连一个会走到它的用例都没有了。

按这个仓库自己的规矩(判据没断言等于没测),麻烦在这条 PR 里补上,三小条就够:

A. 服务端收到未掩码帧 → 1002,两条子用例:数据帧一条、控制帧一条。控制帧那条是给你自己那个"放在控制帧/数据帧分叉之前"的位置选择上判据 —— 位置对了才会两个都响,放进去就只能盖住一半。

B. 服务端收到非法 UTF-8 的 TEXT 帧 → 1007,且 on_text 没有被调用(这才是"拒绝交付"的判据,光看 1007 不够)。负载用 "\xC0\x80" 这种 overlong 比用随机字节更好,判据更死。

C. 同一条,但走压缩路径 —— 用你测试侧的压缩器把非法 UTF-8 压进去、按 rsv1 发出去,断言仍然 1007。这条是给"校验点在解压之后"那个位置选择上的判据;它也正是这件事的起因(放错位置会让所有 deflate 连接都通不过),最值得钉。

B 和 C 建议再加一个"分片"变体:非法字节落在第二个分片里 —— 钉住"校验的是重组后的整条消息,而不是逐帧"。

(形状可以直接抄 web_ws_ownership_func.cpp:238-253 的 t_protocol_error:发帧 → 等 sessions.recycled() == 1 → 断言 errors[0] 的码 + closes 为空 + 恰好 recycle 一次。)

顺带

CI 上 1b10d50 已经绿的 15 个 job 全 success(含上一轮红的 full (ubuntu) / h2 (ubuntu) / ssl (ubuntu)),只剩 ssl (windows) 在跑。这次也没卡批准。

另外那个 on_bin vs on_binary —— 你把 zlib 构建留着的决定是对的,跟"这份构建编不到某个文件"就该当成"那个文件我一行都没核"是同一件事。

`test_large_echo_across_reads` 里那句「载荷压不动,所以这一帧必然跨多次读」
原来只是注释,**没有任何断言**。哪天有人把负载换成可压缩的内容,压缩后的回显
会缩到一次读完,这条用例就**静默地**退化成"不跨读也算过" —— 而它整条存在的
意义就是打跨读那条路径。测试还是绿的,比挂掉难查得多。

加一条:

    if (f.payload.size() < 256 * 1024) return false;

256 KiB 对上 libuv 每次 ≤64 KiB 的读是 4 倍余量。

**验证这条断言是承重的**(不然它只是另一句注释):

    负载 = incompressible(512 KiB)      -> large_echo_across_reads PASS
    负载换成同尺寸可压缩内容(变异)      -> large_echo_across_reads FAIL  ← 断言响
    还原                                -> ALL PASS

加断言前后各跑一次全量:`ctest` 74/74。
补这两条检查时没在仓库里留下任何用例 —— 只靠我外部的一个裸 socket 探针验过
一次,将来谁动了 `on_ws_frame` 里的判断顺序,它们会静悄悄地回到老样子,而
CI 一片绿。这正是这次整轮里反复出现的那种形状。

`web_ws_reassembly_func.cpp` 加三条:

    unmasked_frame_rejected     未掩码的客户端帧  -> PROTOCOL_ERROR(1002),且不交付
    invalid_utf8_rejected       文本帧非法 UTF-8  -> INVALID_PAYLOAD(1007),且不交付
    valid_utf8_still_delivered  反向对照:合法多字节 UTF-8 照常交付

第三条是专门防"校验写严了"的:补 UTF-8 校验最容易的错法就是规则过严,于是
中文、emoji 全被拒掉,而一般用例只发 ASCII 根本发现不了。它同时覆盖三字节
(中 U+4E2D)与四字节(😀 U+1F600)两种长度。

顺带一条同批的:`test_large_echo_across_reads` 里「载荷压不动,所以这一帧必然
跨多次读」原来是注释而不是判据,补了

    if (f.payload.size() < 256 * 1024) return false;

**两条检查、一个断言,都做过变异验证**(摘掉之后对应用例必须转红):

    掩码检查失效        -> unmasked_frame_rejected      FAIL
    UTF-8 校验失效      -> invalid_utf8_rejected        FAIL
                          valid_utf8_still_delivered    PASS(不受影响,符合预期)
    负载换成可压缩内容  -> large_echo_across_reads      FAIL(新断言响)
    全部还原            -> ctest 100% tests passed out of 74
@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

撞车的事不用放在心上 —— 39 秒的窗口,我那条回复里确实还在重新论证,一看就是没刷到。按你最后那条来:BINARY 保留。

你两次都点的那条断言补了(bf98405),另外顺着它我自己还发现一个洞。

一、把「必然跨多次读」从注释变成判据

if (f.payload.size() < 256 * 1024) return false;

做了变异验证(不然它只是另一句注释,那正是你反对的形状):

变异 结果
负载换成同尺寸可压缩内容 large_echo_across_reads FAIL ← 断言响
还原 ALL PASS

二、顺着你那句"留 TEXT 会捎带覆盖 §8.1"想到的

你那条的末尾提了一句:保留 TEXT 的话,那几条会顺带走一遍我新加的 §8.1 校验。改成 BINARY 之后这点覆盖就没了 —— 而我去仓库里翻了一遍,我那两条检查在仓库里一个用例都没有,只靠外部一个裸 socket 探针验过一次。

也就是说:谁哪天动了 on_ws_frame 里的判断顺序,它们会静悄悄地回到老样子,而 CI 一片绿。这正是这整轮里反复出现的那个形状,我自己又踩了一次。所以补了三条(b2e8500,加在 web_ws_reassembly_func.cpp):

用例 判据
unmasked_frame_rejected 未掩码的客户端帧 → PROTOCOL_ERROR(1002),且不交付
invalid_utf8_rejected 文本帧非法 UTF-8 → INVALID_PAYLOAD(1007),且不交付
valid_utf8_still_delivered 反向对照:合法多字节 UTF-8 照常交付

第三条是专门防"校验写严了"的 —— 补 UTF-8 校验最容易的错法就是规则过严,于是中文、emoji 全被拒掉,而一般用例只发 ASCII 根本发现不了。它同时覆盖三字节(中 U+4E2D)与四字节(😀 U+1F600)。

这三条也做了变异:

变异 结果
掩码检查失效 unmasked_frame_rejected FAIL
UTF-8 校验失效 invalid_utf8_rejected FAIL
(同上) valid_utf8_still_delivered PASS ← 不受影响,符合预期
全部还原 ctest 100% tests passed out of 74

三、调用点清单对上了

你给的那 11 条我按 grep 重扫了一遍,一一对上(8 个构造点 + 声明 + 定义 + 文档注释)。上一轮之后我把口径改成全仓 grep 了,这次没漏。

CI 再麻烦点一次批准。

@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

bf98405 + b2e8500 都验过了,写得对。我把三条新用例逐个做了变异,结果如下 —— 其中一条把我自己上一条的要求否掉了。

变异验证

分别 #if 0 掉库里的对应检查、只重编 uvcpp_ws_connection.cpp,并且先确认测试目录里的 DLL 真的换成变异版(比 md5,不只看时间戳 —— 只建 --target uvcpp 是不刷新测试目录那份的,我第一次着了这个道;本机没有 pwsh.exe,copy_test_dlls 也是静默失效的):

变异 结果
掩码强制整段关掉 unmasked_frame_rejected FAIL ✅
UTF-8 校验关掉 invalid_utf8_rejected FAIL ✅(另两条照旧 PASS)
UTF-8 校验挪到解压之前 被抓住,但是被 negotiate_default / real_client_roundtrip / split_frame_across_reads 三条既有的 deflate 用例抓的

我要撤回一条:C(压缩路径)不用写了

我上一条让你补的 C ——「压缩帧里塞非法 UTF-8 → 1007」,用来钉「校验点在解压之后」—— 是多余的。上面第三行变异就是它的等价物:真把校验挪到解压前,web_ws_deflate 立刻红三条。既有用例已经把那个位置钉住了,再写一条只是重复。

是我上一条没先验就开口,抱歉。A 的控制帧变体不算在内,见下。

剩最后一条,有实测证据

A 的控制帧变体是真缺口。 我把掩码检查改成只盖数据帧:

if (is_server() && !frame.masked && !frame.is_control_frame()) {   // 变异 D

全套 WS 10/10 全绿 —— 也就是说「有人把检查挪到控制帧/数据帧分叉之后」这个改动,今天没有任何用例会响。

这有价值是因为你当初把检查放在分叉之前是对的,而那个"对"现在只建立在人读上。补一条就够:

/** @brief 未掩码的**控制帧**同样违反 §5.1(§5.5 说明控制帧不例外)。 */
static bool test_unmasked_control_frame_rejected() {
  scenario s;
  if (!s.start()) return false;
  if (!s.send(raw_frame(0x9, true, "ping", false))) return false;   // PING,mask=false
  s.pump_until([&] { return !s.errors.empty(); }, 2000);
  return s.errors.size() == 1 &&
         s.errors[0] == static_cast<int>(ws_close_code::PROTOCOL_ERROR);
}

(这条不要断言 s.pings.empty():检查在分叉之前,PING 根本不会被派发,所以它必然为空 —— 但那是实现的副作用,不是规范要求的,钉上去会在合理重构时误报。)

顺带

valid_utf8_still_delivered 那条反向对照加得好,我没要 —— 补 UTF-8 校验最容易的错法就是把规则写严(中文、emoji 全拒),只发 ASCII 的用例根本发现不了,而它三字节四字节都盖了。valid_utf8 本身我也逐行搬进 Python 跟严格解码器对拍过 400,021 个输入,零不一致。

本机全量:0 error C / 0 error LNK,75/75 通过。

这条补上我就合(合的时候会把 UVCPP_VERSION_PATCH 12 → 13,你的分支没动版本头,squash 时会带上)。CI 那边 16/16 全绿,不用再批。

Antruly added a commit that referenced this pull request Sep 20, 2026
IS_RELEASE 保持 0:HTTP/2 全做完之前不升大版本、不出 release。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Antruly
Antruly merged commit 87df278 into Antruly:master Sep 20, 2026
16 checks passed
@Antruly

Antruly commented Sep 20, 2026

Copy link
Copy Markdown
Owner

合了。实现本身我上一轮已经变异验证过(三条新用例逐个撞红),这里只交代我为什么不再拿那条用例卡合并,以及我自己的一个错。

我之前那条要求站不住

我要的 test_unmasked_control_frame_rejected,理由写的是"两条新 MUST 零判据"。重新核了一遍代码:掩码检查在 on_ws_frame 开头、控制帧/数据帧分叉之前(uvcpp_ws_connection.cpp:184,你自己的注释也写明了"放进去就只能盖住一半")。所以控制帧走的是同一个检查,不是漏的。

也就是说:那条用例是回归护栏(防止以后有人把检查挪进数据帧那一支),不是补一个没测到的路径。我把"预防性用例"说成了"判据缺口",这个措辞是错的 —— 而且我是凭推理要的,没有先变异证明缺口是真的。这个错在我这边,不在你的 PR 上。

所以我自己补了

unmasked_control_frame_rejected,发一个完全合法的未掩码 ping(0x9、FIN、负载 4 字节 <125)—— 唯一的违规点只有"没掩码",所以它红了只可能因为掩码这一条规则。变异证明它不是空转:

变异(src/web/uvcpp_ws_connection.cpp:184) unmasked_frame_rejected unmasked_control_frame_rejected
原样 PASS PASS
&& !frame.is_control_frame()(放走控制帧) PASS FAIL

只有它红、数据帧那条照旧绿 —— 这正好证明两条用例钉的是两个不同的位置:一条钉"检查在不在",一条钉"检查放对了没有"。

(顺带一个坑:第一次变异编译失败了(is_control 这个名字不存在,实际是 is_control_frame),而失败的构建没有刷新 DLL,于是用例对着旧库跑、报了个 PASS。差点得出反结论 —— 本仓的 error C 一定得先看。)

本机验证

  • 干净合并(无冲突),5 个提交原样保留、作者归属不变;
  • 构建 0 个 error C / error LNK;
  • 全套 77 个 ctest 77/77(web_ws_reassembly_func 19 条用例逐个 PASS);
  • 页面堆门禁:77 个用例裸跑 + 完整页堆各一遍。

两条 RFC 6455 MUST(§5.1 客户端帧必须掩码、§8.1 文本帧负载必须合法 UTF-8)以及"客户端从不掩码"这个同源缺陷,从这一版起是真的实现了 —— 这个改动本身写得很干净,尤其 UTF-8 校验那条反向对照(合法多字节必须照常交付)是**主动防"规则写严了"**的,一般人不会想到补。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants