Repository navigation
fix(net): 给 TLS 握手加一道超时闸门 - #2
Conversation
问题 ---- 服务端 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 那条路排除掉。
IS_RELEASE 保持 0:HTTP/2 全做完之前不升大版本、不出 release。 Co-Authored-By: Claude Code <noreply@anthropic.com>
|
已合入(squash 成 66d0147),谢谢 —— 问题本身核实为真,落点(超时的生灭挂在 本地复核(补上你标的那处"未经编译验证")
配置层、默认值 10000 ms、 复核中查到一个真实缺陷,已另行修掉超时那条路上会赔掉整个 实测不是推的(
触发它不需要用户做任何特别的事: 你新增的场景 4 挡不住这个 —— 它钉的是"连接确实被关掉了",而那条断言在两种 修法保留你的判断、只补上缺的那半句:包装对象留在原地,底层句柄单独 关于共享定时器那个取舍保持现在这样(每条连接一个 timer),别改共享。 理由就是你写的那条,而且这次 一处如实留白(没有改,先记在这里)
没有顺手改,是因为精确判据("这个闭包是不是还压在栈上")库里没有,而唯一能改进 |
判据与量法:`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,已复核)。
…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 报。
主仓那份 `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 会偏低。
问题:握手期的连接对
idle_timeout_ms完全不可见服务端 accept 之后要等握手成功才把连接交给上层:
这个推迟本身是对的(握手没完成时
write()必然失败,"已连接"这个语义也是错的)。但框架层的连接登记表是在
uvcpp_web_app::on_accept()里写的,而on_accept正是被推迟的那个回调。两者一叠加就出现了一个洞:
于是
idle_timeout_ms看不到它。实测
环境:本地自签证书起 HTTPS,
idle_timeout_ms=4000。idle_timeout清掉实验组跑的同时轮询服务端
/api/stats,connections_now全程是 1(就是轮询自己那条),证明卡住的连接压根没有被登记。
为什么这条值得单独处理
idle_timeout_ms兜着(判据是"从请求第一个字节起算",滴字节刷新不了)—— 但那条规则的前提是"连接已经在登记表里",握手期的连接
不满足这个前提;
SSL*、OpenSSL 读写缓冲区一直挂着。
修复思路
在拥有握手生命周期的
uvcpp_tcp_client里加一道一次性超时。选这个位置而不是
uvcpp_tcp_server,是因为超时的生灭与握手的生灭完全重合,放在客户端里,定时器的生命周期天然跟着连接对象走 —— 不会出现"服务端记着一个
已经释放的 client"这种悬垂。
三个关键点:
1. 起算点放在
tls_drive_handshake()里(幂等)服务端与客户端两条路都汇聚到本函数推进握手,所以一处起算、两处生效。
2. 到期走与"握手失败"完全相同的那条路
tls_fail已经负责"通知上层但绝不把连接交付出去",之后由既有的收尾链(读侧通知 → 关闭回调 → 服务端摘表并释放)走完。不需要为超时另开一条拆连接的路径。
3. 句柄生命周期
这是本改动里最容易出错的地方,单独说明:
stop()、不delete—— 那个句柄的回调正压在栈上,析构它就是在析构正在执行的闭包(与本文件析构注释里记的"三层悬垂"是同一类);
tls_hs_in_cb_判断 —— 若正跑在超时回调里则只停不放(宁可漏一个对象,也不踩正在执行的闭包);
tls_fail()里同样只 stop 不 delete,因为它可能就是被那个超时回调调进来的。句柄的最终归宿判断见下面的调用矩阵:
tls_drive_handshake()里 stop + deletetls_fail()stop;析构(正常分支)deletetls_hs_in_cb_决定 delete 或保留配置
新增三层透传,默认 10000 ms,
0= 不设:服务端那一层必须在
enable_tls()之前下发:enable_tls()里就会推进一次握手,而超时是从"第一次推进"起算的。
10 秒这个默认值是按"正常握手即便在慢链路下也远小于 1 秒"选的,留了十倍以上余量;
对握手特别慢的客户端想放宽,显式调大即可。
回归用例
tests/functional/web_ssl_app_func.cpp新增场景 4,两段:3 秒内被服务端关闭;
第 2 条是对照,不能省:没有它的话,"被关掉了"也可能来自别的路径(比如 idle
超时),这条用例就证明不了是握手超时干的。
idle_timeout_ms在两段里都故意留成30 秒,把 idle 那条路排除掉。
用例用不开 TLS 的
uvcpp_tcp_client去连 TLS 端口:要的恰恰是"线上字节凑不成一次完整握手"这个状态,用明文 socket 发 TLS 记录头的前几个字节,对服务端来说与一个
真正的 TLS 客户端发到一半卡住完全一样。
一处取舍,想听你的意见
改用共享定时器(服务端一个 timer + 一张待握手表)能省掉"每条 TLS 连接一个
timer 对象"的开销。没有那样做是因为共享方案需要处理"表里的 client 已经被释放"
这件事 —— 而当前方案里定时器归 client 所有,这个悬垂问题在结构上不存在。
按目前的实现,正常握手下每条连接会短暂创建并销毁一个
uvcpp_timer;只有超时那条路上它才活到连接被拆掉。如果觉得这点开销不可接受,我可以改成共享定时器版本。
测试说明
FetchContent依赖也拉不下来。改动跨 6 个文件(net 层两个、webapp 层两个、测试一个,外加头
文件),已逐行复核,但请以 CI 的编译结果为准;尤其是上面那张句柄归宿表,
是最值得复审的部分。
master当前内容,
uvcpp_tcp_server.cpp的推迟逻辑与idle_sweep()的判据在 master上均未改变。
git apply可单独应用,也可按任意顺序一起应用,无冲突。