Skip to content

fix(net): 回调里析构 tcp_client 时,包装对象不再按连接数累积(#5) - #13

Merged
Antruly merged 2 commits into
Antruly:masterfrom
sercebr:fix/tcp-client-dtor-reclaim
Sep 19, 2026
Merged

Antruly merged 2 commits into
Antruly:masterfrom
sercebr:fix/tcp-client-dtor-reclaim

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

这个 PR 修 #5(连接频繁建立/断开导致内存不收敛)。里面有两处需要你拍板,写在最后一节:(1) 新加的那个诊断计数要不要留在公开 API 上;(2) "句柄还活着"这一支要不要简化成无条件 delete——我按保守做法留着了,但变异验证显示它不是非如此不可,证据都在下面。


症状

服务跑着,连接不停地来、不停地断,RSS 线性涨、不收敛。两个独立复现:

复现 规模 斜率
net 层(uvcpp_tcp_server,客户端反复连、对端 RST) 20000 次连接 +7.07 MB / 20000 ≈ 371 字节/连接
webapp(tests/14_churn.js,对端干净 FIN) 90 s,3128 次连接 0.74–1.17 MB/min

371 字节/连接和 issue 里估的 350–400 B 对得上,而且 20000 次连接里斜率一点没收敛 —— 不是缓存没热、不是分配器碎片。


根因

~uvcpp_tcp_client 里那条兜底:

if (loop_ != nullptr && loop_->is_running()) {
  // "本对象可能是在它自己的某个回调里被析构的" —— 所以
  // tcp_(uvcpp_tcp 包装对象)、几个 *_arg_ 堆块,一个都不拆
  loop_ = nullptr;  read_arg_ = nullptr;  /* … */
  return;
}

这一支的动机是对的(在回调帧里当场 delete loop_、delete read_arg_ 确实是悬垂,见下),但判据挑错了:loop_->is_running() 区分不了「我正踩在自己的回调里」和「循环只是恰好跑着」。而服务运行期间后者恒成立 —— 于是这个分支成了默认分支,tcp_ 那个包装对象一次也没被拆过。

用探针把两条复现路径分开

在析构点打了一组探针(leak / 句柄是否已摘 / 读回调帧 / 关闭回调帧 / 删除点),两条复现形状完全不同:

复现 析构次数 句柄已摘 读回调帧 关闭回调帧 触发点
net 层 uvcppchurn(2000 次,rst) 2000 2000 0 2000 release_client(关闭完成回调里)
webapp webdemo-zfw(1000 次,fin) 1000 0 1000 0 release_client(读回调里)

两边 is_closing() 都是 0;read_arg_ != nullptr 只在 webapp 侧成立。也就是说这两条路不是同一个"来不及删":一条是"句柄早摘了、根本没人压着我",另一条是"句柄还活着、外层那一帧确实还压着"。同一句"所以一个都不拆"把两种都盖住了。


改动

判据从「循环还在不在跑」换成「句柄还在不在」:

支路 判据 处理
句柄已摘 tcp_->get_handle() == nullptr 当场 delete tcp_(连 read_arg_ 那块一起)
句柄还活着、没在关 !tcp_->is_closing() read_stop(),然后把包装对象交给它自己的关闭完成回调去删
句柄正在关 tcp_->is_closing() 关闭回调那个槽被占(handle_close_cb 只有一个),插不进去,只能留下

第三条在实测的两条 churn 路径上都是 0 次触发(探针数出来的)。

同一个析构里还有一处同形的,一并改了:上游刚合入的握手超时定时器(d12188c)在「析构发生在定时器回调里」这一支里只关句柄、把包装对象留在原地,理由写的是"同上"—— 而上面那句现在不再"留在原地"了,那个理由已经不成立。支路划分和三支路一模一样,计数也进同一组。

这一处是推理出来的,不是实测的:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到,非 TLS 路径上它恒为 0。不想要的话就是一行的事(改回 [](uvcpp_handle*) {}),见末节 (3)。

"句柄还在"这一支为什么可以不必漏

靠的是 uvcpp_handle::callback_close 已经有的两条顺序保证:

  1. 它先把 handle_close_cb 拷到局部、把成员清空、wrapper->_handle = nullptr、释放掉 uv_handle_t 的内存,然后才调 close_cb(wrapper);回调返回之后不再碰 wrapper。
  2. uv_close 的完成回调是在本轮 uv_run 的收尾阶段(uv__run_closing_handles)跑的 —— 那时当前这一帧早就解开了。

两条合起来:在自己的关闭完成回调里 delete wrapper 是安全的。所以这一支不是"现在不能删",而是"不能现在删,但可以现在交给那个回调"。

read_arg_ 那块一并交出去:它不由任何 libuv 请求持有(trampoline_read 明确不删它 —— 读回调是长期注册、会反复进来;写/连接那几块由各自的完成回调 delete),而读注册在上面刚 read_stop 掉,所以"这一帧解开之后再删"同样安全。漏掉它就是每次连接漏一个 std::function 块 —— 实测那是 webapp 侧剩下的大头。

顺序仍是有意的:is_closing() 那一问不能省(覆写正在执行的那个闭包就是悬垂),get_handle() 要先于 is_closing() 问(后者把 _handle 直接交给 uv_is_closing,它在句柄已摘时解引用空指针)。


效果

net 层(uvcppchurn.exe + 同一个 blaster harness,每档 4000 连接;RSS 单位 MB)。两列是同一次测量里前后脚跑的:master 那一列是临时把上游的 uvcpp_tcp_client.cpp 换进构建树重建出来的,跑完按字节还原(md5 复核过)。

连接数 master 本分支
0 7.72 7.73
4000 9.20 7.78
8000 10.64 7.78
12000 11.98 7.78
16000 13.38 7.78
20000 14.79 7.78

master 斜率 +7.07 MB / 20000 连接 = 371 字节/连接;本分支 0(只在第一次灌连接时涨了 0.05 MB 的预热,之后 5 个采样点全部落在 7.78)。

webapp(tests/14_churn.js,90 s,3128 连接,18 个采样点):0.74–1.17 MB/min → 0.02 / 0.07 MB/min(RSS 8.7 → 8.8,判定"基本平坦")。


回归判据

全量套件:rebase 到 a02ebf7 之后全量重建 + ctest -C Release → 75/75 全绿(比 rebase 前多一条 —— 上游 1401d46 带进来的 test_web_http_client_selfdestroy_func)。

这套结果有两个边界必须说清,免得它看起来比实际更有力:

  1. 这棵构建树是 UVCPP_ENABLE_OPENSSL=OFF 的 —— 所以一个 TLS 用例都没跑(11 个 web_ssl_* 一条不在里面)。主体那段改动(tcp_ 那三条支路)在这个配置下是编到、也跑到了的;但第二个提交那处 tls_hs_timer_ 在 #if UVCPP_OPENSSL_ENABLE 里,这个配置下连编译都没编译过,75/75 对它一个字都说不上。已经在建一个 OPENSSL=ON 的配置来把它编过并跑上 TLS 那批用例,结果补在这里。
  2. 树上除了本分支,还停着另外几个在审 PR 的改动 —— 静态服务:命中 LRU 缓存的请求也要先抢工作池名额,默认配置并发 17 起大面积 503 #4/fix(ws): 服务端强制客户端掩码、文本消息校验 UTF-8,并让客户端真的掩码 #9/fix(web): * 不得覆盖 Accept-Encoding 里显式给出的 q=0 #10 那几条。它们都不碰 uvcpp_tcp_client 的析构路径,所以这套结果对本分支成立,但严格说它跑的不是"只有这一个提交的干净树"。

哪些数是 rebase 之后重测的:上面那张 net 层 RSS 对照表和整轮变异验证都是新基线上重跑的;探针区分表、webapp 的 0.74–1.17 MB/min 是 rebase 前那轮量的 —— 它们测的是 master 的行为,而 master 这个析构里处理 tcp_ 的代码在新旧基线上逐字相同(git show 比对过),所以照用。

新用例 test_reclaim_paths(tests/functional/tcp_client_func.cpp)

两条腿各钉一种"回调返回之后还剩什么",用外部循环(这样析构不会连循环一起漏掉,收尾后还能问 uv_loop_close()):

腿 形状 期望支路
1 对端断开,框架管理槽(set_close_manager,服务端用的就是这条)删客户端 deferred
2 自己 close(),完成回调里删 released

判据有两条,互相独立:

  1. uvcpp_tcp_client::reclaim_stats() 里 released + deferred 必须随断开次数涨 1、skipped 恒 0。计的是释放真的做成了,不是"打算这么做"(released 记在 delete 之后、deferred 记在那个延迟的 delete 里面),所以把释放动作删掉会让计数停在原地。
  2. 收起尾来 uv_loop_close() 必须返回 0 —— 退回到"一个都不拆"时,漏掉的那个 uv_handle_t 还挂在 handle_queue 上(既不活跃也没在关,uv_run 连 while 体都不进),这里只能 UV_EBUSY。这一条不需要新计数就能抓住老写法。

用 reclaim_stats() 而不是 RSS 斜率做回归判据,是因为后者是间接量:慢、噪声大,涨到能测出来要几千次连接,CI 上没法当断言。

变异验证(tests/tools/run_tcp_client_dtor_mutation.py,7 个变异)

(rebase 之后在新基线上整轮重跑过,7 条逐条复现;收尾的源码按字节还原、grep 无残留、PageHeap 已关、还原后重建 rc=0 也都过了。)

变异 结果
基线(未变异,裸跑) rc=0 全绿 ✅
基线(未变异,整页堆) rc=0 全绿 ✅ —— 新代码没有释放后使用
M1 数据路径不拷闭包 崩(0xC0000005)
M2 断开路径不拷闭包 崩
M3 不看存活令牌 崩
M4 泵换回 1ms 睡眠 计时腿红
M5 关掉 is_running 守卫 崩
M6 退回"包装对象一个都不拆" reclaim_paths 两条腿红(不开页堆)
M7 句柄还活着也当场 delete rc=0 没抓住 —— 判为等价变异,见下

M6 抓到的三条判据(逐条打印出来的):

reclaim_paths leg1 FAIL 支路不对:deferred+=0 released+=0 skipped+=0(应 1/0/0)
reclaim_paths leg1 FAIL loop_close rc=-4082(UV_EBUSY = 还有句柄挂在 handle_queue 上)
reclaim_paths leg2 FAIL 支路不对:released+=0 deferred+=0 skipped+=0(应 1/0/0)

M7 为什么没抓住 —— 是等价变异,不是覆盖缺口(写清楚免得它以后被当成功效证据)。把"交给关闭完成回调去删"换成"当场 delete"之后,6 条腿在整页堆下全绿。读一遍就明白:

  • 包装对象自己的那几个 trampoline(uvcpp_stream::callback_read、uvcpp_handle::callback_alloc、uvcpp_connect::callback_connect)都是先把闭包拷到局部再调(源码里写明了"回调里 delete self 是合法用法"),回调返回之后一个字节都不再碰 wrapper;
  • delete tcp_ 落到 uvcpp_handle::free_handle(),它按句柄当时的状态分派:正在关 → 塞个 detached 哨兵就返回(底层内存留给已经排队的 callback_close)、活跃或"init 过但没 start" → 自己补一次 uv_close、从没 init 过 → 才直接还内存。三条路都不会把"还挂在 loop->handle_queue 上"的内存还掉。

所以这一条不区分"当场删"和"交给关闭回调删",留着它是出于保守,不是因为它被证明了必要 —— 见末节 (2)。

两个工具上的连带改动

  • gflags.exe 在本机不存在(Windows Kits\10\Debuggers\x64 下只有 dbghelp/dbgcore/srcsrv 三个可再发行件),原来的 set_pageheap() 直接以"PageHeap 设置失败"退出,等于这个门禁在本机跑不起来。补了一条不依赖 gflags 的路:直接写同一个 IFEO 键,写完照样回查注册表。两个值缺一不可 —— GlobalFlag = 0x02000000 加 PageHeapFlags = 0x1(full)。少了后者只是标准页堆:释放过的块填 0xFEEEFEEE 但页还在,于是"释放后读"读得到、不崩,而这个门禁要抓的恰恰就是读。机制先单独验过:一个 malloc(64) → free → 读 的探针,裸跑 rc=0,这样设上之后直接 SEGV(rc=139),删掉键又回到 rc=0。
  • 抓 FAIL 明细那个正则 (\S+ FAIL.*) 匹配不上用例名带空格的(reclaim_paths leg1 FAIL …、既有的 delete_in_read_cb leg1 FAIL … 一样匹配不上),报出来是空的。判据本来就只看退出码,这一列只是明细 —— 但静默为空会让人以为"没红",改成 (.*FAIL.*)。

⚠️ 需要你拍板的三处

(1) 新增的那个诊断计数要不要留在公开 API 上

我加了

struct reclaim_stat { uint64_t released, deferred, skipped; };
static reclaim_stat uvcpp_tcp_client::reclaim_stats();

它是这个 PR 里唯一新增的公开面。动机写在上面:析构走哪条支路从外面完全看不见,不给个可观测量就只能拿 RSS 斜率当判据。仓库里已有 close_observer_count() / rejected_count() 这类诊断计数,所以我按同一个路子做了,但它确实是为测试加的生产 API。

不想要的话有两条退路:

  • 只留 uv_loop_close() 那条判据。代价是腿 2(released 支路)就没有判据了 —— 那条支路的泄漏在 handle_queue 上是干净的(uv_handle_t 早被 callback_close 释放了)。它只占 net 层那 371 B 里的一部分,我用 RSS 斜率量过,但那之后就只能靠慢测。
  • #ifdef 只在测试构建里编出来。多一个编译开关,我不太推荐。

我倾向留着 —— 这条路径的 bug 全是"悄悄少还一次内存",有个能直接断言的数比什么都强。

(2) "句柄还活着"这一支要不要简化成无条件 delete

M7 的证据反过来指着这个方向:既然"当场 delete tcp_"在整页堆下 6 条腿全绿,而 free_handle() 本身就把"活跃 / 正在关 / 没 start"三种状态收尾得当,那这一支或许根本不用分三种情况,一句

if (loop_ != nullptr && loop_->is_running()) {
  if (tcp_ != nullptr) {
    delete tcp_;            // 句柄交给 free_handle() 按状态自己收尾
    tcp_ = nullptr;
    if (read_arg_ != nullptr) { /* … */ }
  }
  /* … */
}

就够了 —— 而且它在"句柄正在关"这个第三种情况上不漏(现在是留着的,只是实测 0 次触发)。代价是:这条写法依赖 free_handle() 的状态机在"回调帧里"也成立,而那是个比"把删除推迟到关闭回调"更细的依赖 —— 后者是本仓库到处在用的形状(uvcpp_tcp_server 的 stream->close([peer](uvcpp_handle*){ delete peer; }) 就是它)。

我没敢替你选:探针能构造出来的形状里两者等价,构造不出来的那些帧(比如写完成里析构、TLS 半途析构)我没法证明等价。你想简化的话,改完之后 M7 这个变异就不存在了(它就是那段代码),回归判据也只剩 released 一列 —— 一并说一声我来改。

(3) 那处握手超时定时器的改动要不要留

它动的是你昨天刚合入的那行(d12188c 里的 close([](uvcpp_handle*) {})),不是这个 issue 原来圈定的范围。我改它的理由是那条注释里的"同上"已经指不通了 —— 但我拿不出实测,只能给推理,这正是它和上面 (1)(2) 的区别。要拆掉的话它跟 reclaim_stats() 里那两行计数是绑在一起的,说一声我拆成干净的(那边的计数也可以一并从 reclaim_stat 的文档里去掉)。

sercebr and others added 2 commits September 20, 2026 04:14
issue Antruly#5:连接频繁建立/断开时 RSS 线性涨、不收敛 —— net 层 371 字节/
连接(20000 连接 +7.07 MB),webapp 0.74–1.17 MB/min。

根因是 ~uvcpp_tcp_client 里那条兜底的判据「循环还在跑 ⇒ 包装对象
(tcp_)一个都不拆」。它区分不了「我正踩在自己的回调里」和「循环只是
恰好跑着」,而服务运行期间后者恒成立,于是每断一次连接就漏一个
uvcpp_tcp 包装对象。

把判据换成「句柄还在不在,以及处在哪一步」:

  - 句柄已摘(get_handle() == nullptr)→ 当场 delete(net 层那条
    churn 路径实测 2000/2000 都走这里);
  - 句柄还活着、没在关 → read_stop() 之后把包装对象交给它自己的关闭
    完成回调去删(webapp 那条路径实测 1000/1000 都走这里)。uv_close
    的完成回调在本轮 uv_run 的收尾阶段跑,那时当前这一帧已经解开;
    callback_close 也已经把回调拷到局部、清空成员、释放了 uv_handle_t
    的内存,回调返回之后不再碰 wrapper;
  - 句柄正在关(关闭回调槽被占)→ 只能留下,实测两条路径上都是 0 次。

read_arg_ 那块不由任何 libuv 请求持有(trampoline_read 不删它),
一并交给同一个关闭回调,它正是 webapp 侧剩下的大头。

实测:net 层 20000 连接斜率 371 字节/连接 → 0(7.79 MB 基线,6 个
采样点全平),webapp 0.74–1.17 → 0.02/0.07 MB/min。

新增 reclaim_stats() 诊断计数(与 close_observer_count 同一用途):
析构走哪条支路从外面看不见,回归用例需要判据。计的是释放真的做成了,
所以把释放动作删掉计数就会停在原地。

新用例 test_reclaim_paths 两条腿各钉一种回调形状,判据两条互相独立:
计数必须随断开次数涨 1 + 收尾后 uv_loop_close() 必须返回 0。

变异验证(tests/tools/run_tcp_client_dtor_mutation.py 新增两条):
  M6 退回「一个都不拆」→ 两条腿红(不开页堆)
  M7 句柄还活着也当场 delete → 没抓住,判为等价变异,理由见
     tests/tools 的说明与 PR 正文

工具:本机没有 gflags.exe,补一条直接写 IFEO 注册表的路(GlobalFlag
+ PageHeapFlags=0x1)并照样回查;抓 FAIL 明细的正则改成能匹配带空格的
用例名。

同一个析构里还有一处同形的(上游 d12188c 刚合入的握手超时定时器):
它在「析构发生在定时器回调里」这一支只关句柄、把包装对象留在原地,
而那句注释的理由写的是「同上」—— 上面那句现在不再「留在原地」了,
所以照同一个理由改过来(这一处是推理出来的,不是实测:触发它要 TLS
握手超时,net-only 的 churn 装置碰不到)。

基线:本分支已 rebase 到上游 a02ebf7。上游 master 在这个析构里处理
tcp_ 的代码与 rebase 前逐字相同,所以 master 侧的斜率在同一个新基线
上重测过:7.72 → 9.20 → 10.64 → 11.98 → 13.38 → 14.79 MB(每档
4000 连接),仍是 371 字节/连接;修复侧同机同次为 7.73 → 7.78 ×5
(只在第一次灌连接时涨 0.05 MB 预热),斜率 0。
d12188c 在 ~uvcpp_tcp_client 里处理 tls_hs_timer_ 时,把「析构发生在定时器
回调里」那一支写成「只关句柄、包装对象留在原地」,理由写的是「理由同上」
—— 指的是上面 tcp_ 那段。上面那段现在已经不「留在原地」了(包装对象交给
关闭完成回调去删),所以这个「同上」指不通;这里按同一个理由改过来。

与前一个提交不同,这一处**没有实测**:触发它要 TLS 握手超时,net-only 的
churn 装置碰不到,非 TLS 路径上它恒为 0。依据仍是 uvcpp_handle::callback_close
的顺序保证(先拷闭包、清成员、释放 uv_handle_t 内存,之后不再碰 wrapper)
加上 uv_close 的完成回调跑在本轮 uv_run 的收尾阶段。

计数进同一组 reclaim_stat:它的口径是「本客户端在析构时要放掉的包装对象」。
@Antruly
Antruly merged commit ce27803 into Antruly:master Sep 19, 2026
16 checks passed
@sercebr

sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

补上正文第 1 条边界欠的结果 —— OPENSSL=ON 配置建好了,那处 tls_hs_timer_ 编过了,TLS 那批用例也跑上了。

配置:上游 a02ebf7 + 本 PR,UVCPP_ENABLE_OPENSSL=ON、UVCPP_ENABLE_NGHTTP2=ON、UVCPP_ENABLE_ZLIB=ON,系统 OpenSSL 3.4.0(CMake 出力 Using system OpenSSL 3.4.0)。

结果:ctest -C Release → 87/87 全绿(对比 OPENSSL=OFF 那棵树的 75/75)。两棵树我是逐条 diff 的测试名单,不是对数字:新增恰好 12 条 = test_h2_session_func + 11 个 web_ssl_*,一条没丢。(对数字会漏判 —— 我之前在别处就这么错过一次。)

那处 tls_hs_timer_ 确实编进去了,两条独立证据:

  1. uvcpp.vcxproj 里的编译单元指向本分支的 src/net/uvcpp_tcp_client.cpp(不是上游那份);
  2. 链出来的 uvcpp.dll 里存在 reclaim_stats 符号 —— 它和那个改动在同一个 #if UVCPP_OPENSSL_ENABLE 块里。

test_web_ssl_server_hs_timeout_func 通过,并且在输出里真的打出了

[uvcpp_tcp_client] TLS handshake timed out after 300 ms, closing connection

但这条边界仍然要划清:以上证明的是「这一支能编译,且握手超时的路径被真的走到了」,不证明「析构发生在定时器回调里」那一支被走到了 —— 要触发它得让析构恰好落在那个定时器回调的帧里,这我没有构造出来。所以正文 (3) 里「那处改动是推理出来的、我拿不出实测」这个判断不变,仍然请你按推理的价值来决定留不留。

(另外正文说「这个 PR 修 #5」没带 closing 关键字,所以 #5 现在还是 OPEN 状态。)

Antruly added a commit that referenced this pull request Sep 19, 2026
合入 #13 的三支路里有两处没有判据盖住:

- `read_arg_` 非空那一支。既有各腿全部走 `read_start_events`,那条路的
  数据由成员 `net_read_cb_` 交付,`read_arg_` **恒为空** —— 于是"把它的
  释放推迟到关闭完成回调"这件事一条腿也没测到。腿 3 与腿 1 同形,只把读
  注册换成传统 `read_start`,它同时被 M6(退回"包装对象一个都不拆")抓住。
- 握手超时那条路。原来那两条判据(handle_queue、`uv_loop_alive`)对"包装
  对象漏没漏"完全不敏感:句柄关得掉,漏的是 C++ 对象。加一条断言,这条路上
  `tcp_` 与 `tls_hs_timer_` 的包装对象各延迟释放一次(deferred += 2 /
  released == 0 / skipped == 0);把定时器那条支路改回旧写法会让它掉成 1
  并变红,实测复现过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Antruly added a commit that referenced this pull request Sep 19, 2026
Co-Authored-By: Claude Code <noreply@anthropic.com>
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

已合入(ce27803,squash),随后三个维护者提交收尾(fe0b140 / 1b788a5 / 7cb1c0d)。

三处拍板的答复

(1)reclaim_stats() 留在公开 API 上 —— 留。
理由和你一样:这条路径的病全是"悄悄少还一次内存",外面看不见;仓库里已有
close_observer_count() / rejected_count() 同类先例。这次复核本身就靠它 ——
没有这个数,腿 2 与腿 3 的判据都无从写起。

(2)"句柄还活着"这一支保持三支路 —— 不简化。
你把 M7(无条件 delete)判成等价变异,方向对,但那次实验没有覆盖到关键变量:
M7 跑的是 test_tcp_client_func,而那份用例里凡是"在回调里析构"的腿全部走
read_start_events
—— 那条路的数据由成员 net_read_cb_ 交付,read_arg_ 恒为空。
read_arg_ 只有传统 read_start 才会置上(uvcpp_tcp_client.cpp:1443),它指向的
就是此刻正在执行的那个 std::function。所以"当场删"在 M7 上绿,是因为那个指针
压根没参与实验 —— 不是因为它可以被当场删。

现场证据(析构分支加一行探针,跑整份 test_tcp_client_func):

[probe] tcp_ branch: alive -> defer, read_arg_=null      <- delete_in_read_cb leg1/leg2
[probe] tcp_ branch: alive -> defer, read_arg_=null      <- reclaim_paths leg1
[probe] tcp_ branch: detached -> delete now              <- reclaim_paths leg2
[probe] tcp_ branch: alive -> defer, read_arg_=set       <- reclaim_paths leg3(新增)

"无条件 delete tcp_"这一支没有任何关闭完成回调可以挂,read_arg_ 那块要么当场
删(就是上面那个正在执行的对象)、要么漏掉 —— 而漏掉它正是 webapp 侧剩下的大头。
三支路不是保守,它是 read_arg_ 能有个地方放下的唯一形状。

(3)握手超时定时器那一处 —— 留,而且它不再是纯推理。
你说"触发它要 TLS 握手超时,net-only 的 churn 装置碰不到",对;但它不是碰不到,
test_web_ssl_server_hs_timeout_func 那条路就是它。加一行探针跑一遍:

[probe] tcp_ branch: alive -> defer
[probe] tls_hs_timer_ != null, in_cb=1 closing=0
[probe] tcp_ wrapper deleted
[probe] timer wrapper deleted

两条支路都走到了、两个包装对象都真的被删掉。改回 PR 之前那句 close([](uvcpp_handle*){})
则少一次释放。

本机复核

  • 两棵树重建(build-webapp、build-h2 = OpenSSL + nghttp2),error C / error LNK 0;
  • ctest -C Release:76/76 与 87/87;
  • PageHeap 门禁:build-webapp rc=0(76 个用例)、build-h2 rc=0(87 个用例),
    两次都是"裸跑与完整页堆下全绿、过期 DLL 0 个",注册表复查 76/76、87/87 已关闭。

随后的维护者提交

  1. fe0b140 web_ssl_server_hs_timeout_func.cpp 补一条判据:这条路上 tcp_ 与
    定时器的包装对象各延迟释放一次,断言 deferred += 2 / released == 0 / skipped == 0。
    把定时器那条支路改回旧写法会让它掉成 1 并变红(实测复现过)。原来那两条判据
    (handle_queue、uv_loop_alive)对"包装对象漏没漏"完全不敏感 —— 句柄关得掉,
    漏的是 C++ 对象。
  2. fe0b140 tcp_client_func.cpp 补腿 3:既有各腿全走 read_start_events,
    传统 read_start 那条路(read_arg_ 非空、webapp churn 复现的形状)一条腿都没有。
    腿 3 与腿 1 同形,只把读注册换掉;它同时被 M6 抓住。
  3. 1b788a5 run_tcp_client_dtor_mutation.py 两处过期说明:表头 M7 那行还写着
    "崩(PageHeap)",与正文结论(没抓住、判为等价变异)相反,是改判之后没回头改表;
    另外"本机没有 gflags.exe"不成立 ——
    Windows Kits\10\Debuggers\x64\gflags.exe 就在(同目录还有 cdb.exe),
    find_gflags() 走的就是它,set_pageheap_direct() 那条兜底在本机走不到。

reclaim_paths 的腿 3 在本地 40 次连跑全绿。

CI 结论只看 gh run view <id> --json jobs。

Antruly added a commit that referenced this pull request Sep 20, 2026
第四批收尾。八篇功能文档全部就位,`doc/` 下每一篇都能从 README 的正文里点到。
**只加/改 `.md`,一行代码没动。**

## 两篇新页

- **`doc/expand-guide.md`**(内存池 / 页堆 / span):这一层是全仓文档覆盖最差的地方。
  写出来的是"默认关着的理由",以及几处**只有读实现才知道**的行为 —— SUPER 档
  (>256 KiB)的块在 `push()` 里被静默丢弃、`allocate_aligned()` 的 `align` 只用来把
  size 凑整、`reset()` 与 `reset_stats()` 逐行相同、`max_total_memory` 全仓只在声明处
  出现过一次、每销毁一个池至少睡 7 ms(7 个 mpsc 队列各 1 ms)、
  `memory_usage_ratio()` 在无符号下溢后给出垃圾读数。另记一条实测:
  `uvcpp_memory_pool.h:1082` 用了 C++14 的 init-capture,而 `CMAKE_CXX_STANDARD` 是 11
  (g++ 只给 warning,`-pedantic-errors` 才报错)。
- **`doc/webapp-support-guide.md`**(框架底下那七个类型):按类型清单界定范围,开头就
  写明"框架行为以 `webapp-guide.md` 为准"。最有价值的是 **§10 默认值与头注释对不上**
  —— 头注释错、实现对的地方逐条列了出来,包括 `uvcpp_console_log_options{false, true}`
  那段"把自己说反了"的注释(实测确实编不过)、`color` 的默认值并不是"自动判断"、
  默认输出比头里的样例多一列 `(tid:N)`。安全相关的坑都带 `文件:行号`:
  `web_build_cookie()` 的 `path`/`same_site` 原样拼接(可注入)、
  `web_url_encode()` 的 `keep` 优先于 `plus_for_space`、`set_boundary()` 失败会把
  解析器打进终态且**救不回来**。

## README / CONTRIBUTING

- 两版 README 各加一张 **`### 模块指南` / `### Module guides`** 正文表,把八篇按模块
  列出来;`doc/` 结构树也补齐(原来漏了 5 篇)。
- 顺手修掉两版 README 类表里一处**陈旧散文**:`uvcpp_tcp_server` 没有
  `on_connection()`,连接回调是 `listen()` 的第一个参数(`src/net/uvcpp_tcp_server.h:177`)。
  这与上一批修掉的那 4 个烂例子同类,只是它藏在表格里,编译门禁够不着。
  对照:`uvcpp_ws_server::on_connection()` **是**真的(`src/web/uvcpp_ws_server.h:111`),
  所以 WS 那一处不动。
- `CONTRIBUTING.md`:在 "Where new documentation goes" 里列出这九篇,并写明
  **不要建 `doc/<模块>/` 子目录** —— 两个门禁都是非递归扫描,放进去等于对所有判据隐形。

## 验证

- `check_doc_snippets.py --pkg <六模块全开包> --cxx g++`:**rc=0**,
  候选 131 条:编过 61 条(37811 B),片段 70 条,跳过 0 条。
  新页 `webapp-support-guide.md` 编过 8/9 条 —— 那 1 条片段是 §10 故意编不过的反例。
- `check_docs.py`:rc=0,3 条判据全过,503 处引用可解析,`doc/` 下 15 篇**无孤儿**。
  本批之前 `expand-guide.md` 与 `webapp-support-guide.md` 两篇都是红的,
  正是被上面那张表清掉的。
- **判据 2 的对照组**:把一个锚点改成 `#13-没做的如实列` ⇒ rc=1,且点名
  「doc/webapp-support-guide.md 第 47 行的锚点 … 没有对应标题」;从仓库外备份还原后
  逐字节相同、复跑回绿。新页的 13 个锚点全部用 `check_doc_snippets.py --toc` 核过。
- `check_doc_versions.py`:rc=0,命中恰好 4,全部等于 `1.2.0`。
- `verify_tree.py --tree build-h2`:rc=0,构建 errors=0 / DLL md5 / exe mtime /
  ctest 93 个用例 0 失败,四条全 PASS(未传 `--stability`,故第 5 条未跑)。
- 版本号不动:本批只改 `.md`,不升档;两版 README 的版本串一个字未碰。

Co-Authored-By: Claude Code <noreply@anthropic.com>
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