Skip to content

fix(net): 给 TLS 握手加一道超时闸门 - #2

Merged
Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/tls-handshake-timeout
Sep 19, 2026
Merged

Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/tls-handshake-timeout

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

问题:握手期的连接对 idle_timeout_ms 完全不可见

服务端 accept 之后要等握手成功才把连接交给上层:

// src/net/uvcpp_tcp_server.cpp,listen() 的 accept 回调里
bool tls_handshake_pending = false;
if (ssl_ctx_ != nullptr) {
  const int tls_rc = client->enable_tls(ssl_ctx_);
  ...
  tls_handshake_pending = !client->is_tls_handshake_done();
  if (tls_handshake_pending) {
    client->set_tls_ready_callback([this](uvcpp_tcp_client* c, int st) {
      on_tls_handshake_done(c, st);
    });
  }
}
if (tls_handshake_pending) return;  // 握手完成时再交付

这个推迟本身是对的(握手没完成时 write() 必然失败,"已连接"这个语义也是错的)。
但框架层的连接登记表是在 uvcpp_web_app::on_accept() 里写的,而 on_accept
正是被推迟的那个回调。两者一叠加就出现了一个洞:

握手期这条连接不在任何登记表里,而 idle_sweep() 遍历的正是那张表。

于是 idle_timeout_ms 看不到它。

实测

环境:本地自签证书起 HTTPS,idle_timeout_ms=4000。

客户端行为 结果
连上之后什么都不发(明文连接) ✅ 4 秒左右被 idle_timeout 清掉
连上之后什么都不发(TLS 端口) ❌ 一直挂着 —— 连不上手,就进不了登记表
每 500 ms 发 1 字节喂 ClientHello ❌ 30 秒后仍然活着(超时值的 7.5 倍)

实验组跑的同时轮询服务端 /api/stats,connections_now 全程是 1(就是轮询
自己那条),证明卡住的连接压根没有被登记。

为什么这条值得单独处理

  • 明文的 slowloris 有 idle_timeout_ms 兜着(判据是"从请求第一个字节起算",
    滴字节刷新不了)—— 但那条规则的前提是"连接已经在登记表里",握手期的连接
    不满足这个前提;
  • 所以这是一条明文没有、上 TLS 才出现的占用路径;
  • 单位带宽的占用效率极高:每 500 ms 一个字节,就能让一条连接连同它的 SSL*、
    OpenSSL 读写缓冲区一直挂着。

修复思路

在拥有握手生命周期的 uvcpp_tcp_client 里加一道一次性超时。

选这个位置而不是 uvcpp_tcp_server,是因为超时的生灭与握手的生灭完全重合,
放在客户端里,定时器的生命周期天然跟着连接对象走 —— 不会出现"服务端记着一个
已经释放的 client"这种悬垂。

三个关键点:

1. 起算点放在 tls_drive_handshake() 里(幂等)

int uvcpp_tcp_client::tls_drive_handshake() {
  if (tls_ssl_ == nullptr || tls_handshake_done_) return 0;
  tls_arm_handshake_timer();   // 幂等:已经起过就不重起
  ...

服务端与客户端两条路都汇聚到本函数推进握手,所以一处起算、两处生效。

2. 到期走与"握手失败"完全相同的那条路

tls_fail(UV_ETIMEDOUT, false);

tls_fail 已经负责"通知上层但绝不把连接交付出去",之后由既有的收尾链
(读侧通知 → 关闭回调 → 服务端摘表并释放)走完。不需要为超时另开一条拆连接的路径。

3. 句柄生命周期

这是本改动里最容易出错的地方,单独说明:

  • 定时器惰性创建(第一次推进握手时),正常路径上只活几十微秒;
  • 超时回调里只 stop()、不 delete —— 那个句柄的回调正压在栈上,析构它就
    是在析构正在执行的闭包(与本文件析构注释里记的"三层悬垂"是同一类);
  • 析构分两种情况:正常分支安全释放;"在自身回调里被析构"那一支用 tls_hs_in_cb_
    判断 —— 若正跑在超时回调里则只停不放(宁可漏一个对象,也不踩正在执行的闭包);
  • tls_fail() 里同样只 stop 不 delete,因为它可能就是被那个超时回调调进来的。

句柄的最终归宿判断见下面的调用矩阵:

路径 定时器状态 谁释放
握手成功 tls_drive_handshake() 里 stop + delete 当场
握手因协议/证书错误失败 tls_fail() stop;析构(正常分支)delete 析构
握手超时 回调 stop;析构视 tls_hs_in_cb_ 决定 delete 或保留 析构
连接在握手期被外部关闭 析构 stop + delete 析构

配置

新增三层透传,默认 10000 ms,0 = 不设:

uvcpp_tcp_client::set_tls_handshake_timeout_ms(int ms);
uvcpp_tcp_server::set_tls_handshake_timeout_ms(int ms);   // accept 时下发给新连接
uvcpp_web_app::set_tls_handshake_timeout_ms(int ms);      // 框架层入口

服务端那一层必须在 enable_tls() 之前下发:enable_tls() 里就会推进一次
握手,而超时是从"第一次推进"起算的。

10 秒这个默认值是按"正常握手即便在慢链路下也远小于 1 秒"选的,留了十倍以上余量;
对握手特别慢的客户端想放宽,显式调大即可。

回归用例

tests/functional/web_ssl_app_func.cpp 新增场景 4,两段:

  1. 设了 300 ms 握手超时 → 发一段永远凑不完整的 ClientHello,断言连接会在
    3 秒内被服务端关闭;
  2. 对照:把这道闸门关掉(0) → 同样的连接在 2 秒窗口内必须不被关闭。

第 2 条是对照,不能省:没有它的话,"被关掉了"也可能来自别的路径(比如 idle
超时),这条用例就证明不了是握手超时干的。idle_timeout_ms 在两段里都故意留成
30 秒,把 idle 那条路排除掉。

用例用不开 TLS 的 uvcpp_tcp_client 去连 TLS 端口:要的恰恰是"线上字节凑不成
一次完整握手"这个状态,用明文 socket 发 TLS 记录头的前几个字节,对服务端来说与一个
真正的 TLS 客户端发到一半卡住完全一样。

一处取舍,想听你的意见

改用共享定时器(服务端一个 timer + 一张待握手表)能省掉"每条 TLS 连接一个
timer 对象"的开销。没有那样做是因为共享方案需要处理"表里的 client 已经被释放"
这件事 —— 而当前方案里定时器归 client 所有,这个悬垂问题在结构上不存在。

按目前的实现,正常握手下每条连接会短暂创建并销毁一个 uvcpp_timer;只有超时那条
路上它才活到连接被拆掉。如果觉得这点开销不可接受,我可以改成共享定时器版本。

测试说明

  • 环境同前一个 PR:Windows 10 + MSVC,客户端为 OpenSSL 3.5.6 / Python 3.15。
  • ⚠️ 本补丁同样未经编译验证:本机没有 OpenSSL 开发头,仓库的 FetchContent
    依赖也拉不下来。改动跨 6 个文件(net 层两个、webapp 层两个、测试一个,外加头
    文件),已逐行复核,但请以 CI 的编译结果为准;尤其是上面那张句柄归宿表,
    是最值得复审的部分。
  • 问题部分的实测数据来自对预编译包(1.1.0)的运行观察;代码引用为 master
    当前内容,uvcpp_tcp_server.cpp 的推迟逻辑与 idle_sweep() 的判据在 master
    上均未改变。
  • 两个 PR 已互相验证:git apply 可单独应用,也可按任意顺序一起应用,无冲突。

问题
----

服务端 accept 之后要等握手成功才把连接交给上层(`uvcpp_tcp_server::listen`
里的 `if (tls_handshake_pending) return;`),而框架层的连接登记表是在
`uvcpp_web_app::on_accept()` 里写的。于是**握手期这条连接不在任何登记表
里**——`idle_sweep()` 遍历的正是那张表,`idle_timeout_ms` 因此完全看不到它。

实测:`idle_timeout_ms=4000`,一个每 500 ms 发一个字节喂 ClientHello、永远
不把握手做完的客户端,连接连同它的 SSL 对象与读写缓冲区**挂了 30 秒仍然
活着**(服务端 /api/stats 的 connections_now 全程是 1,印证它压根没进登记
表);而同样条件下"连上什么都不发"的明文连接会在 4 秒时被正常清掉。

明文的 slowloris 有 `idle_timeout_ms` 兜着(判据是"从请求第一个字节起算"),
但那条规则的前提是"连接已经在登记表里"——握手期的连接不满足这个前提。这是
一条**明文没有、上 TLS 才出现**的占用路径,而且单位带宽的占用效率极高。

修复
----

在**拥有握手生命周期**的 `uvcpp_tcp_client` 里加一道一次性超时:

- 起算点放在 `tls_drive_handshake()` 里(幂等)。服务端与客户端两条路都汇聚
  到它推进握手,所以一处起算、两处生效;
- 握手一结束就撤销:成功在 `tls_drive_handshake()` 的 `rc == 1` 分支,
  失败在 `tls_fail()` 开头;
- 到期走与"握手失败"完全相同的那条路(`tls_fail(UV_ETIMEDOUT, false)`)——
  通知上层但**绝不把连接交付出去**,然后由既有的收尾链摘表并释放。

句柄生命周期是这个改动里最容易出错的地方,所以额外说明:

- 定时器**惰性创建**,正常路径上只活几十微秒;
- 超时回调里**只 stop、不 delete**——那个句柄的回调正压在栈上,析构它就是
  析构正在执行的闭包(与本文件析构注释里记的"三层悬垂"同一类)。释放留给
  析构;
- 析构分两种情况:正常分支安全释放;"在自身回调里被析构"那一支用
  `tls_hs_in_cb_` 判断——若正跑在超时回调里则只停不放(宁可漏一个对象,
  也不踩正在执行的闭包)。

配置:`uvcpp_tcp_client::set_tls_handshake_timeout_ms()`(默认 10000,
0 = 不设)→ `uvcpp_tcp_server` 在 accept 时下发给每条新连接(必须在
`enable_tls()` **之前**,因为 enable_tls 里就会推进一次握手)→ 框架层
`uvcpp_web_app::set_tls_handshake_timeout_ms()` 透传。

回归用例
--------

`web_ssl_app_func.cpp` 新增场景 4:发一段永远凑不完整的 ClientHello,断言
(1)设了 300 ms 握手超时时连接会在 3 秒内被关掉;(2)把这道闸门关掉
(0)之后,同样的连接在 2 秒窗口内**不会**被关掉。第 2 条是对照——没有它,
"被关掉"可能来自别的路径,这条用例就证明不了是握手超时干的。
`idle_timeout_ms` 故意留成 30 秒,把 idle 那条路排除掉。
@Antruly
Antruly merged commit 66d0147 into Antruly:master Sep 19, 2026
16 checks passed
Antruly added a commit that referenced this pull request Sep 19, 2026
IS_RELEASE 保持 0:HTTP/2 全做完之前不升大版本、不出 release。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

已合入(squash 成 66d0147),谢谢 —— 问题本身核实为真,落点(超时的生灭挂在
uvcpp_tcp_client 上、到期复用 tls_fail)我这边也认为是这一版里最合适的选择。

本地复核(补上你标的那处"未经编译验证")

  • build-h2(Release,OpenSSL + nghttp2)全量重编:0 error C / 0 error LNK;
    ctest 87/87(新用例是 #75,实跑不是 SKIP:[web_ssl_server_hs_timeout] ALL PASS);
  • tests/tools/run_pageheap_gate.py --tree build-h2:rc=0,87 个用例裸跑与完整页堆下
    都绿、过期 DLL 0 个 —— 本 PR 动的是"回调里可能析构句柄",这条不能省;
  • OPENSSL=OFF 的 build-webapp 树同样全量重编 0 错误(并行 4 那趟在两个用例上撞了
    C1060 编译器堆空间不足,降到并行 2 就过了)、ctest 76/76、门禁 rc=0;新用例按
    web_ssl_.* 排除规则正确地不出现在那棵树里(那棵树里 test_web_ssl* 可执行文件 0 个)。

配置层、默认值 10000 ms、0 = 不设,三处透传都按你写的行为复核过。

复核中查到一个真实缺陷,已另行修掉

超时那条路上会赔掉整个 uv_loop_t。 前一次析构在"本对象正在自己的超时回调里
被拆掉"这一支上只 stop() 不关句柄 —— 依据是"删包装对象等于析构正在执行的闭包",
这半句是对的,但只停不关的句柄既不是活跃也没在关,却照样挂在
loop->handle_queue 上。而 uv_loop_close() 正是遍历那个队列挑非 internal
句柄(_local_deps/libuv/src/uv-common.c:883),与活跃与否无关 —— 于是它永远
返回 UV_EBUSY,~uvcpp_loop 只能按既有策略把整块 uv_loop_t 泄漏掉。

实测不是推的(web_ssl_orphanprobe,同一套服务端 + 同一个卡住的 ClientHello,
唯一变量是"销毁发生在哪个回调里"):

销毁发生在 uv_loop_close() 队列上剩下的孤儿
超时回调(闸门响) -4082 (EBUSY) timer active=0 closing=0
读回调(对端断开走同一条 tls_fail) 0 无

触发它不需要用户做任何特别的事:uvcpp_tcp_server 自己的管理槽
(install_client_manager() 装的 set_close_manager)就是在 tls_fail() 第 5 步里
delete 客户端的。

你新增的场景 4 挡不住这个 —— 它钉的是"连接确实被关掉了",而那条断言在两种
销毁路径下都是绿的。所以补了一条站到循环这一侧的用例
(tests/functional/web_ssl_server_hs_timeout_func.cpp):uv_walk 数非 internal
句柄 + uv_loop_alive(),也就是 uv_loop_close() 那两条判据的非破坏性形式
(用例里的 server/client 是栈对象,真去 uv_loop_close() 会先销毁循环再析构对象)。

修法保留你的判断、只补上缺的那半句:包装对象留在原地,底层句柄单独 uv_close
(与该文件里 tcp_ 那句同一个形状 —— uv_close 是异步的,完成回调回来时包装对象
仍然有效)。提交:d12188c。去掉那句 uv_close,新用例必红:

1 handle(s) left on the loop's handle queue after the handshake timeout fired
  — uv_loop_close() would return UV_EBUSY forever and the whole uv_loop_t leaks
  (type=13 active=0 closing=0)

关于共享定时器那个取舍

保持现在这样(每条连接一个 timer),别改共享。 理由就是你写的那条,而且这次
的缺陷恰好是它的反面教材:悬垂不是"结构上不可能",只是"被所有权挡住了"。共享方案
要在每条拆除路径上从表里摘 client,而拆除路径在这个库里不止一条(正常收尾、协议
错误、外部关闭、以及现在这条超时),漏一条就是表里留个野指针 —— 而 idle_sweep()
遍历登记表那个形状的坑,正是这个 PR 要修的东西。次数上也不值得:定时器是惰性创建
的,正常握手只活几十微秒。

一处如实留白(没有改,先记在这里)

tls_hs_in_cb_ 是单向闩锁(uvcpp_tcp_client.cpp:1881 置上,全文件没有复位点)。
在"超时回调里没人释放客户端、之后才从别处析构"这条窄路上,析构会走 close 分支而把
那个 uvcpp_timer 包装对象留在原地 —— 句柄本身关得干净、循环也关得掉,泄漏的
只是一个约百字节的对象。

没有顺手改,是因为精确判据("这个闭包是不是还压在栈上")库里没有,而唯一能改进
它的位置(在 tls_fail() 之前清标记)正好会把原来那个"析构正在执行的闭包"放回来。
要真做对得动所有权,那是另一件事。

Antruly added a commit that referenced this pull request Oct 12, 2026
判据与量法:`build-rel15`(从 `_local_deps/libuv` v1.51.0 建,真 REUSEPORT 扇出)上跑
新用例 `tests/functional/http3_multiloop_func.cpp`(阶段 2 的 h3 扇出用例,8 条客户端线程
**共用同一个** `uvcpp_ssl_context`),以及同一份用例的 ASAN 树 `build-h3san`:

  改前:build-rel15 **30 次里 7 次**非零退出 —— 全部是 glibc 堆损坏中止
        (`double free or corruption (out)` / `(fasttop)` / `unaligned tcache chunk
        detected` / SIGSEGV),**没有一次是断言失败**;build-h3san **25 次里 6 次**
        `ERROR: AddressSanitizer: attempting double-free`。
  改后:build-rel15 **30/30**,build-h3san **25/25**(`ASAN_OPTIONS=detect_leaks=0` ——
        泄漏是 `~uvcpp_http_client` 有意为之的那条路,与本次无关)。

## 根因

ASAN 的栈是唯一的入口:

  #1 SSL_CTX_set_alpn_protos
  #2 uvcpp::uvcpp_ssl_context::set_alpn_protos    src/ssl/uvcpp_ssl_context.cpp:346
  #3 uvcpp::quic_detail::quic_session::setup_tls  src/quic/uvcpp_quic_session.cpp:439
  ...  ← uvcpp_quic_client::connect ← uvcpp_http_client::connect_quic ← client_run
  0x5020000260b0 is located 0 bytes inside of 3-byte region
  freed by thread T19 here: ... SSL_CTX_set_alpn_protos
  previously allocated by thread T20 here: ... CRYPTO_malloc

三字节 = "h3" 的 ALPN 线格式(长度前缀 0x02 + 'h' + '3')。`SSL_CTX_set_alpn_protos`
不是"写个字段":它 **free 掉 ctx 上旧的 `ext.alpn` 再 malloc 一份新的**。而 `setup_tls()`
在**客户端每一次会话**都无条件调它,落点却是共享的 `SSL_CTX` —— 两条线程各自读到同一个
旧指针、各 free 一次。触发条件正是"一个上下文被多条连接、多个线程共用",也就是阶段 2 的
QUIC 扇出(以及任何多线程客户端)把它变成常态的地方。

本库自己的 TCP / h1 / h2 客户端一直是**对的**:它们走 `uvcpp_ssl::set_alpn_protos()`
(`src/net/uvcpp_tcp_client.cpp:2420,2501`、`src/web/uvcpp_http_client.cpp:1066`),装的是
**本条连接**的 `SSL`。QUIC 会话是库里唯一一个往共享 ctx 上装的调用点 —— 所以这不是"用法
问题",是这一处偏离了库自己的既有做法。

服务端那一侧还有一个**同源**的竞争(这次没崩,只因为它写的是我们自己的 `std::string`、
不是 OpenSSL 的缓冲):闸门 `if (!ssl_ctx->has_alpn_select())` 与 `set_alpn_select_protos()`
里的 `alpn_select_wire_ = …` 并发就是边写边读一个 `std::string`,而"谁先装"恰好发生在
多条连接同时建起来的那一刻 —— 扇出之后必然发生。

## 改动

- `src/ssl/uvcpp_ssl_context.{h,cpp}`:新增 `apply_alpn_to_ssl(SSL*, protos)` —— 逐连接,
  编好线格式后调 `SSL_set_alpn_protos`;ctx 级的 `set_alpn_protos()` 与
  `set_alpn_select_protos()` 改到一把进程内的锁里;后者对同一份名单**幂等**(同一份不再
  碰 OpenSSL,于是并发的第一波安装只剩一个线程真正改 ctx);`has_alpn_select()` 从内联挪进
  `.cpp` 并进同一把锁。锁不做成成员:`uvcpp_ssl_context` 是公开类,加一个 `std::mutex`
  成员就改了它的布局;这把锁只覆盖"改名单"那几行,不进热路径。
- `src/quic/uvcpp_quic_session.cpp`:客户端那条腿挪到 `SSL_new` **之后**、装到 `ssl_` 上;
  服务端那条腿仍装 ctx(OpenSSL 只有 ctx 级的选择回调口),注释写清两条腿为什么不同、
  以及客户端为什么不能装共享 ctx。
- `CHANGELOG.md`:`### TLS 与网络` 末尾一条(`1.6.5-dev`)。

## 门禁与回归

- `build-rel15` 全量 ctest **126/126**(1 条 DB 用例按惯例 skip),含 `web_ssl_alpn_func`
  (ctx 级与逐连接两条 API 都覆盖)、`quic_*` / `http3_*` / `h2_*` / `web_ssl_*_h2` / `tcp_*`。
- `check_doc_versions.py` / `check_docs.py` / `check_ci_layout.py` / `check_capi_symbols.py`
  rc 全 0。`check_doc_lines.py` 这一提交是**故意红**的(本提交让
  `src/ssl/uvcpp_ssl_context.h` 长了一行,`doc/ssl-guide.md:43` 那条引用跟着挪):行号锁
  由本阶段末尾那条 docs 提交重刷,照 `8ed50fb` → `faf1e50` 的先例。
- 头部编码:`src/ssl/uvcpp_ssl_context.h` 仍带 BOM + LF(编辑会静默吃掉 BOM,已复核)。
Antruly added a commit that referenced this pull request Oct 13, 2026
…9-dev

`connection_generation(client)` 的文档用法就是「开始时记下这个指针、异步回来再问
一次」(`uvcpp_http_server.h` 那条的 @code 段,连"地址被新连接复用"那种情形都写
了);`reply_alive(reply)` 更是**专为"连接已经没了"设计**的入口。而两者的实现都要
先读一次 `client->loop_index()` 才知道查哪张表 —— 多循环下 `contexts_` 按循环切开,
这一步就是 use-after-free(单循环有 `contexts_.size() == 1` 的快路径,不读,所以
缺陷只在 `set_loops(n > 1)` 下存在)。

## 怎么发现的

MinGW 那档 CI 一直红在

    91/109 Test #91: test_web_static_server_func ....***Exception: SegFault 11.64 sec

(`3849b19` 那次 check-run 的 annotation 原文),而**进程捕获的 stdout 是空的**——
崩溃把用户态缓冲里还没 flush 的进度全带走了,所以只剩这一行。本地用 ASan 复现:

    heap-buffer-overflow ... READ of size 8     (Linux + ASan,5/5 次)
      #0 ctxs_of(uvcpp_tcp_client*) const       uvcpp_http_server.cpp:455   <- client->loop_index()
      #2 connection_generation(...)             uvcpp_http_server.cpp:2516
      #3 StaticTestServer::start()::{lambda}    web_static_server_func.cpp:260

Windows 的堆把释放掉的页还给系统,读它是访问违例(就是那个 SegFault);Linux/macOS
上那块内存通常还映射着,于是只有开了 ASan 才看得见。

## 中途那一版是错的(留个记号)

第一版改成"逐张表扫一遍、按纯指针比较找",本地当场换成另一种 ASan 报错:

    heap-use-after-free ... _Rb_tree::find  <- connection_generation() <- on_connection 回调

读到的那个结点是**另一条循环**的线程刚在 `remove_ctx` 里 erase 掉的 —— 跨循环读
别人的表本来就是 `contexts_` 那条注释里写明的 UB("一张全局表在两条循环上并发
insert/erase 是 UB,不是'慢一点'"),把一张表换成四张并不改变这件事。

## 改法

- `connection_generation()`:查一张新立的登记簿 `ctx_gens_`(`client -> 代次号` +
  一把锁)。它的写入与 `contexts_` 的增删**成对** —— `note_ctx_generation()` /
  `forget_ctx_generation()` 就贴在登记 ×2、摘除 ×2 那四句后面(另加析构里的清表),
  读侧只有 `connection_generation()` 与 `is_connected()`,**都不在每请求的路上**
  ⇒ 一张表都不查、`client` 指向的内存一个字节都不读。
- `reply_alive()` 的 h1 支路:用凭据**自己带的循环号**选表,与 h3 那条支路同一个
  形状(那边一直是用 `reply.loop_index_`)。

## 判据

同一条用例里补一条**确定性**的:传一个必然未映射的地址
`reinterpret_cast<uvcpp_tcp_client*>(1)` —— 实现只要读它就当场段错误,返回值必须是
`0`。悬垂指针读出来的东西往往还是老值、还被 `ctxs_at()` 的越界兜底夹住,"跑完没崩"
证明不了实现没读它;这一句不需要 ASan 也能在普通 CI 上钉住这条契约。

- Linux + ASan(RelWithDebInfo,`-fsanitize=address,undefined -fno-omit-frame-pointer`):
  `ctest -j4` **68/68 × 5 轮 0 报**;`multiloop_concurrency` 单独 `taskset -c 0-3`
  压 **20/20**;整条 `web_static_server_func` 与 `test_web_multiloop_func` 各 0 报。
- 收尾门禁(`--pkg` 喂 `package_release.py --tree build-vfy-cfg --platform linux-x64`
  出来的 stage):`check_doc_lines.py`(73 条引用整体平移 +9~+73 行、6 条歧义逐条
  定完,锁已重刷)/ `check_doc_versions.py`(4 处版本串 == 1.6.19-dev)/
  `check_docs.py` / `check_doc_snippets.py`(101 条片段编过)/
  `check_config_contract.py` / `check_ci_layout.py` / `check_capi_symbols.py` /
  `check_doc_lines_scenes.py` 全过。

## 顺带修掉的一处(同一文件,ASan 顺手抓到的)

413 那两处 `uvcpp_http_response::make(PAYLOAD_TOO_LARGE, "413 Payload Too Large", 23)`
里手写的 `23` 比字面量(21 个字符)长 2 —— `clone_data` 每次都从字面量后面多读 2 个
字节,ASan 报 `global-buffer-overflow ... READ of size 23`(`test_web_stream_func`
那条超长请求体的用例)。越过去的 2 个字节落在 rodata 里紧挨着的下一个字面量上,不越页
不越段,所以从 `c304a4c` 起一直只有 ASan 看得见。两处都改成
`sizeof("413 Payload Too Large") - 1`。改前那条用例每跑必报,改后 5 轮 0 报。
Antruly added a commit that referenced this pull request Oct 15, 2026
主仓那份 `site/data/results/libuvcpp.json` 已经与第四轮的结果提交逐字节相同
(`git log origin/main -- site/data/results/libuvcpp.json` 只有 af3b4fc 与
53f4911,都是 #1553 那次合并),所以榜上这组分数读的是第四轮(pin 6eede33 =
1.7.0),"榜上还是第二轮"那一段作废。

六族:h1 5 037.3 #11/76、h2 2 119.5 #3/23、h3 734.1 #5/13、ws 2 022.8 #4/23、
gw 1 454.1 #4/7、grpc 1 746.4 #2/6(默认档,tuned=0)。gw / grpc 两整族与 h3
那族的 static-h3 都是这一读才进榜的。

同时记下两件事:第五轮(6fadb86)还在 #1568 那条分支上,而它是 PR head 9964faf
的祖先 —— 合进主仓时那份 JSON 带的是含 9257623(写出器)那棵树的数字,与合并后
的 DOM 路径不是同一份代码,届时 json-* 三档会偏高、async-db 的 CPU/req 会偏低。
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