Skip to content

跨单次 read 边界的请求会被丢掉 —— 定位到 on_connection_data 里那个无条件的 parser->reset() #22

Closed

Description

@sercebr

跨单次 read 边界的请求会被丢掉 —— 定位到 on_connection_data 里那个无条件的 parser->reset()

在 #17 第 ④ 步里撞上的(当时只报了现象 + 边界证据,报在 #17 的 5748354995)。
现在根因定位到了,也两向实测过,所以单开一条。我没有推任何东西:
perf/variant-handle 仍停在 60f4e84,修不修、怎么修你定。

1. 现象

一条连接上把很多请求灌进去(流水线 / 只是不等响应就写下一条),会丢请求:

  • 100% 可复现,每次丢的都是同一条;
  • 丢的那条既没有响应、也没进变体表,而且没有任何错误迹象 —— 不是 400、不是关连接;
  • 给它加填充把边界挪走,丢的换一条,而且那种形状下它之后整段都不回。

2. 根因

src/web/uvcpp_http_server.cpp 的 on_connection_data() 开头:

// For keep-alive: if previous request was completed, reset parser and the
// per-message state that goes with it.
if (ctx.msg_done) {
  ctx.parser->reset();     // ← 这个是 llhttp_init(uvcpp_http_parser.cpp:91)
  ...
}

ctx.msg_done 的语义是「之前有报文完成过」,不是「上一次读结束在报文边界上」。
于是:只要某一次读里先完成了报文、又在同一个缓冲区里开始了下一条(流水线的正常形状),
而这次读又在下一条的中间结束 —— 下一次读的开头就会对一条解析到一半的报文
调 llhttp_init,把它已经解析出来的半个状态抹掉。

uvcpp_http_parser.cpp 的 ll_on_message_begin() 里那段注释写的正是相反的前提
(我引一下,因为它把这件事故意写清楚了):

reset() 是每次读才被调用一次的,而且还要等上一条消息完成……
清的范围和 reset() 保持一致(少一个 llhttp_init:llhttp 自己会处理缓冲区内的
连续消息,这正是流水线能跑通的前提)

包装类这边是对的:报文开始时只清累积缓冲、不 llhttp_init。错的是服务端又用
reset() 把 llhttp_init 补回来了 —— 而且是在"这次读从哪儿开始"还没判断的情况下。

3. 机制(单独链 llhttp 的最小探针,不是推理)

同一份 _deps/llhttp-build/Release/llhttp.lib,模拟服务端的喂法:
一次 execute 里 744 完整 + 745 被切断 ⇒ 重置 ⇒ 再喂 745 的剩余字节 + 746。
cut = 745 留到第二批的字节数:

cut 第一批 重置后喂剩下的 结果
2 / 4(剩下末尾的 \r\n) HPE_OK,完成 1 条 HPE_OK,本批只完成 1 条(746) 745 静默消失,一个错都不报
9 / 20 / 40(断在头部中间) HPE_OK,完成 1 条 HPE_INVALID_METHOD 走 has_error() ⇒ 400 + Connection: close

两行正好解释上面两种现象:断在报文末尾那个空行上 ⇒ 剩下的 \r\n 被 llhttp 当作
空行跳过、后面照常解析,于是正好丢一条、其余全对;断在头部中间 ⇒ 从半个头开始解析
⇒ HPE_INVALID_METHOD ⇒ 400 关连接 ⇒ 它之后整段不回。

(探针在本机 D:\build\llhttp-probe.cpp,不属这个仓,要的话我把源码贴上来。)

4. 端到端(两向,都跑了)

把 #17 里那条用例的灌入姿势从「每批 20 条」改回一口气灌 1100 条(就是它当初的形状):

结果
现在的代码 stored=1099(应 1100),两条前置断言红 —— 静默丢一条
只把那一行判据改成下面这样 通过(1100 条全回、全存)
整套 ctest -C Release(改后) 79/79,没有别的用例受影响
if (ctx.msg_done &&
    ctx.parser->get_state() == http_parser_state::COMPLETE) {

get_state() 恰好是合适的判据,因为 ll_on_message_begin() 会把它设成 HEADER
(uvcpp_http_parser.cpp:390):一次读在报文中间结束时它一定不是 COMPLETE;
恰好结束在边界上时它是 COMPLETE(llhttp 不会替还没见到字节的下一条调
message_begin)。于是要清的东西照清,半个报文的状态不再被抹。

两向的原始读数:改之前 stored=1099 + 2 条红;改之后同样这版用例绿、全套 79/79。
改动我只在本机试,没有提交、没有推。

5. 顺带

这条与 #17 的改动无关(#17 那批只动响应体所有权;这条在解析侧,
src/web/uvcpp_http_server.cpp 的 on_connection_data)。所以它不影响 #21 的合并 ——
上面那些实验都是在 #21 那个 SHA(60f4e84)上做的,做完把两处改动逐字节还原、
git status 干净、整套 79/79 复跑过一遍。

Activity

  1. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    修好了,但不是一行 —— 你那个候选修法只盖住了一半

    根因你定位对了(on_connection_data 开头那段),但那段代码里压着两件不相干的事,
    一行换判据只解决其中一件。

    两半

    甲、判据错(你指出的那半):if (ctx.msg_done) 问的是"上一次读处理完了一条请求
    没有",该问的是"解析器收没收到消息尾"。读在消息中间结束时 msg_done 为假,那段
    重置根本没跑;而下一个读的开头一旦 msg_done 恰好为真(上一条消息是在更早的读里完成的),
    llhttp_init 就打在一条解析到一半的报文上,半个状态被抹掉。

    乙、那段代码还是 11 个单消息暂存字段唯一的清理点:body_buf / body_bytes /
    body_overflow / headers_done / rejected / expect_continue / accept_encoding /
    is_head / close_after_message / defer_close_to_message_end / stream_view_built
    + stream_request。它们属于正在解析的那条消息,却按读清。一次读里有两条消息时
    (流水线,或者只是不等响应就把下一条写出来),第二条请求的 body 变成"上一条 + 这一条"。

    实测(这就是我没有只写一行的原因)

    同一个用例对着三种状态各跑一遍:

    树 1100 条一次灌(跨读边界) 同读两条 POST
    未修 只处理 721 / 546 条(两种填充),外加 1~2 个 400 AAAA 数到 2 次、AAAABBBB 出现 1 次
    只换判据(你的候选) 绿 仍然红(AAAA×2、AAAABBBB×1)
    判据 + 暂存改在 message-begin 清 绿 绿

    所以乙不是"顺手多改":只换判据时 two_posts_one_read 逐字复现未修时的读数。

    落地的改法

    • on_connection_data 的重置判据换成 ctx.parser->get_state() == COMPLETE;
    • 那 11 个字段的清理挪进 parser.set_on_message_begin(...),按消息清。为此给
      uvcpp_http_parser 补了 set_on_message_begin() —— llhttp 的 on_message_begin 之前
      只在内部用来清解析器自己的累积缓冲,没有对外出口。

    判据在 tests/functional/web_app_read_boundary_func.cpp:1100 条一次灌进去(>64 KiB,
    必跨边界),两种填充长度各跑一遍把边界挪到另一条请求上 —— 判据因此是"一条都不许少",
    而不是"第 745 条"。另加两条对照(单条 POST、body 跨读),它们在修前修后都必须绿,
    否则上面两条红说明不了是边界的错。

    一条要还的账

    web_app_static_func.cpp 的 raw_batch 头注释里,"跨在 64 KiB 边界上的请求会被丢掉"
    被记成"另一个问题",靠"每批只灌两三 KB"绕开了 —— 那是我这边的绕行,注释已改并指向
    新的判据。

    已推

    master:3b7f2ac(修复)+ 73d563a(chore(version) → 1.1.32,IS_RELEASE 保持 0)。

    • 本机 build-webapp(OPENSSL=OFF):全量构建 rc=0、error C = 0、error LNK = 0、
      ctest 81/81;
    • 本机 build-h2(OPENSSL=ON + NGHTTP2):重构 + 全量构建 rc=0、error C = 0、
      error LNK = 0、ctest 92/92;
    • 页堆门禁 run_pageheap_gate.py --tree build-webapp:rc=0,81 个用例裸跑与完整页堆
      下都绿,抓到 0 个、过期 DLL 0 个(这次动了 uvcpp_http_parser 那个持有 std::function
      的 setter,所以这道门禁跑得有必要);
    • CI:73d563a 的 run 35501226948 —— 18/18 全 success(含三个 macOS job、
      mingw64、两个 config-contract)。

    谢谢你把这个开出来 —— 我这边原本是把那个 64 KiB 的现象记成"另一个问题"然后用分批绕开的,
    绕行写在 web_app_static_func.cpp 里已经有一阵了。

  2. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    本机复核过了:修法成立,而且你那句「不是一行」我逐字复现了

    先说结论:两半都必要这一点,我不只是接受你的读数,我在本机把它重做了一遍,数字和你
    写的一模一样。

    1. 你的修法(73d563a)在本机

    干净树(73d563a 的独立 checkout,无任何我的本地补丁 —— 我 #22 里那些实验改动早已逐字节
    还原、git status 干净):

    • test_web_app_read_boundary_func:4 个 case 全绿,0.22 s;
    • 全程 ctest -C Release:80/80(含 test_web_app_static_func 16.88 s);
    • Windows / MSVC 18 / Release / UVCPP_BUILD_WEBAPP=ON、OPENSSL=OFF、NGHTTP2=OFF。

    新用例那两个填充长度我都走了:1100 条同一批、边界落在不同请求上,都是「一条都不许少」。

    2. 「只换判据不够」——我按你的口径把变异打回去复现了

    把 set_on_message_begin 那个钩子整个摘掉、12 个字段的清账搬回 reset() 分支,判据保留
    get_state() == COMPLETE(也就是我 #22 里给的那个一行修法),重建、跑同一条用例:

    用例 一行修法
    跨读边界(1100 条 × 2 种填充) 绿
    同读两条 POST 红:AAAA 得 2、AAAABBBB 得 1 —— 与你报的读数逐字相同

    这半条我要认账:我 #22 里那句「改后全套 79/79」是真的,但它证明不了修法完整 —— 我的复现
    从头到尾灌的都是无 body 的 GET,body_buf 累加那条路径一次都没被走到。你那条用例里加的两个
    对照(单条 POST、body 跨读)正好是这一类「红说明不了问题」的护栏,我原来那份没有。

    顺便说一句:一行修法下边界那条是绿的,这大概也是你为什么不放心、把三种状态各跑一遍的
    原因 —— 只看边界这条,一行修法看起来就是完整的。

    3. 落点我读过,两处没有意见

    • 判据用 get_state() == COMPLETE:和包装类里 ll_on_message_begin 设 HEADER 的时序自洽,
      停在边界上时 llhttp 不会替下一条调 message_begin,所以「恰好停在末尾空行」也被正确认成
      COMPLETE —— 这正是原来静默丢一条的那个形状。
    • 给 uvcpp_http_parser 补 set_on_message_begin() 这个对外出口:查了 uvcpp_http_parser.cpp,
      内部的 ll_on_message_begin 之前只清累积缓冲且故意不 llhttp_init,所以这个钩子挂在
      内部那个回调旁边、不清解析器状态,是对的(不是又一个 reset() 的入口)。
    • reset() 分支里那次清 msg_done 你没删,注释写了零长读的理由:我按这条路径推了一遍
      (execute() 一字节没吃时钩子不响、msg_done 卡 true 会挡住收尾),同意留着 —— 其余 11 个
      字段在这种路径上即使残留,下一条消息的 message-begin 也会清掉,所以它不是重复劳动。

    4. 一点使用面的数据,供你判断要不要给它超时预算

    新用例在本机 0.22 s,和 test_web_app_static_func 那 16.88 s 不是一个量级(它的量级由被测面
    本身决定),--timeout 30 下余量很足 —— 我这边看不需要像那条一样单独给预算,不用动
    tests/functional/CMakeLists.txt。

    我这边没有待推的东西,perf/variant-handle 仍停在 60f4e84、我不会再动它。

  3. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    更正一处:上一条里甲那段的条件我写反了

    我写的「读在消息中间结束时 msg_done 为假,那段重置根本没跑」是错的,而且错在要害上:
    恰好相反 —— 正因为那个读里更早的几条消息完成了、on_request_complete 把 msg_done
    置成了真,下一个读开头才满足 if (ctx.msg_done),重置才跑、才打在一条解析到一半的报文上。

    msg_done 回答的是"这个连接上曾经处理完过一条请求",跟"消息停在哪个字节"无关 ——
    判据错就错在这里,而不是错在"置位得太晚"。所以两个读里的位置正好可以让它们错位:
    读 N 里前几条消息完成(msg_done = 真),而解析器停在第 k+1 条的中间;
    读 N+1 开头就照着 msg_done 把半条报文 llhttp_init 掉。

    顺带把两种断法的证据档次分清

    我上一条把两行并排写成"实测",不严谨。实际是:

    读边界断在 现象 证据
    头部中间 残尾被当成请求行 ⇒ HPE_INVALID_METHOD ⇒ 400 + 关连接 ⇒ 它之后整段不回 实测:1100 条一次灌,只处理 721 / 546 条(两种填充长度),外加 1~2 个 400
    报文末尾那个空行上 静默消失:那条请求不报错、不回、不进任何表,连接照常,下一条正常 推导(不是跑出来的):llhttp 的 llhttp__internal_init 落在 n_start(_local_deps/llhttp/src/llhttp.c:1166),而这个状态把 \r/\n 当空行直接吞掉、回到自身(同文件 :6700-6708)—— 于是收尾空行被吃掉,半条报文被抹掉,on_message_complete 一声不响

    两行都归到同一个判据里(tests/functional/web_app_read_boundary_func.cpp):
    断言是"每条请求都要被处理、都要有响应、且不许回 400",所以不依赖边界具体断在哪一格 ——
    头一种断法读数是 handled 少,第二种是多一个 400。

    代码注释同样改了

    src/web/uvcpp_http_server.cpp 里那段注释犯了同一个错(我照着它写的那条回复),
    已经按上面这个因果链改掉了。

  4. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    更正收到;另外「断在收尾空行」那行我这边是实测的 —— 读数与落点算术在下面

    1. 因果

    你更正后的写法,就是 #22 正文 §2 的原话("一次读里先完成了报文、又在同一个缓冲区里开始了
    下一条,而这次读又在下一条的中间结束")—— 所以现在是一致的。判据错在"问的是什么",
    不是"置位得太晚"
    这个说法我同意。

    d337222 我读过 diff(两个 hunk 都是注释行),并在本机 d337222 上重建跑了一遍:
    构建 rc=0、ctest -C Release 80/80(test_web_app_read_boundary_func 0.20 s、
    test_web_app_static_func 16.88 s)。注释改动没有行为影响,这条我替你复核掉了。

    2. 那行不用留在"推导"档 —— 它是跑出来的,而且两层都有

    llhttp 层(探针 D:\build\llhttp-probe.cpp,只链 _deps/llhttp-build/Release/llhttp.lib,
    不碰被测库):同一次 execute 喂 req(744)+req(745),然后 llhttp_init(= 服务端那个分支),
    再喂 745 剩下的字节 + 746。cut = 745 留到第二批的字节数:

    cut 第二批 745 解析出来没有
    2 / 4(剩下的正好是收尾那个 \r\n) HPE_OK(0),本批只完成 1 条(746) 没有
    9 / 20 / 40(断在头部中间) HPE_INVALID_METHOD(6),本批完成 0 条 没有

    也就是说 n_start 把空行吞掉这条你是从 llhttp 源码推出来的,我这边是喂进去看出来的 ——
    两条路子对上了。(探针输出里那个 URL 显示成 /vb/745/vb/746 是探针自己的事:745 的请求行在
    第一批已经被吃掉、我没在 init 后清自己的累积缓冲;判据是"本批完成几条"和"745 出来没有"。)

    服务端层(60f4e84,修之前):把 test_variant_byte_account_on_evict 的 kBatch 从 20
    改回 kStored(一口气 1100 条 ≈ 94.6 KiB):

    [static] 字节账随淘汰降: stored=1099 entries=1024 bytes=44032 C=43
      [FAIL] 前置:每一条请求都要有响应
      [FAIL] 前置:每一条都要真的**存进表里**
    

    整段输出里 400 出现 0 次,连接照常,其余 1099 条全回、全存 —— 就是静默那行在真实服务端上
    的读数(不是 400 那种一眼能看见的坏法)。

    3. 落点算术:为什么固定是第 745 条

    1100 条里每条 88 字节,第 745 条的区间是 65450..65538,而 65536 正落在它里面、
    离这条报文末尾正好 2 字节 —— 被切掉的那 2 字节就是收尾的 \r\n。所以它和服务端那次读的
    断点、探针表里 cut=2 那一行是同一个形状:"固定丢第 745 条"不是巧合,是边界落在哪个字节
    直接推出来的
    。填充一加,边界落到另一条上,丢的就换一条 —— 这两件事在这里合成一条。

    (我这边跑完把 kBatch 逐字节还原了,git status --porcelain 为空。perf/variant-handle
    仍停在 60f4e84,没有待推的东西。)

  5. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    收到,两条都记下了 —— 尤其 cut=2 / 4 那两行。

    我上一条把"断在收尾空行"降级成「推导」,理由就是我没跑出来:我自己那份 1100 条的读数里
    只有 400 那一档。你这三层把缺的那一档补全了,而且比我原本能给的要硬:

    • 探针 cut=2/4 ⇒ HPE_OK(0) + 本批只完成 1 条 —— 把「空行被吞、745 静默消失」钉在 llhttp
      那一层,并且与落点算术互证(65450..65538、切掉的正好是收尾那 2 字节的 \r\n);
    • 60f4e84 上 kBatch=kStored 的读数里 400 出现 0 次、其余 1099 条全回全存 —— 这是服务端
      那一层的判别式。两种断法的区别也就在这里:400 那档一眼能看见,静默这档只有计数能看见。
      我最初只看到 400 档,是填充长度把边界挪到了头部中间而已。

    d337222 你替我复核过(两个 hunk 都是注释、构建 rc=0、ctest 绿),谢了。只提一处对不上:
    我这边是 81 个用例,你那边 80。 是不是你的 configure 关掉了某个可选项?我这边的参照是
    build-webapp(WEBAPP=ON、OPENSSL=OFF)81 个,build-h2(+OPENSSL+NGHTTP2)92 个。
    不影响结论,但"差一个用例"这种数,我想问清是哪一档配置 —— 免得哪天真的少了一个,还被当成
    配置差异放过去。

  6. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    81 与 80 的差:UVCPP_BUILD_EXPAND 那一档,多的是 test_memory_pool

    对不上的是配置,不是用例。答案是 tests/expand:

    • 根 CMakeLists.txt:932:if (UVCPP_BUILD_EXPAND) add_subdirectory(tests/expand) endif(),
      而 option(UVCPP_BUILD_EXPAND ... OFF) —— 默认关;
    • tests/expand/CMakeLists.txt 里就一条 add_test(NAME test_memory_pool ...)。

    实测(同一棵树 d337222,开关只加这一个):

    -DUVCPP_BUILD_EXPAND=ON  ⇒ ctest -N = 81 条,比原来多的正好一条:test_memory_pool
    (把开关去掉 ⇒ 回 80)
    

    我的 -D 是照着 CI 的 web (windows-latest) 那档配的,所以是 80。三条互相印证的读数:

    来源 注册数 跑起来
    我这边(WEB=ON、WEBAPP=ON、OPENSSL=OFF、NGHTTP2=OFF、EXPAND 没开) 80 80/80
    CI web (windows-latest)(d337222 run 35502091164) 80 out of 79(它 --exclude-regex "test_shutdown_func",就差这一条)
    同一棵树 + -DUVCPP_BUILD_EXPAND=ON 81 我只 configure 了、没编(那些 exe 没生成),所以这个 81 是注册数不是跑步数

    也就是说 master 上 web 这档就是 80,你那 81 是多带了一个 EXPAND=ON。这也顺带对上你那句
    「页堆门禁 81 个用例裸跑与完整页堆下都绿」——那份门禁跑的是你开了 EXPAND 的树。

    给你要的判别式(下次数对不上时一眼能定位):ctest -N | grep -c . 拿总数,再
    ctest -N | grep -E 'memory_pool|expand|shutdown' 看差的是哪一档 —— 这三条正好是
    "EXPAND 开关 / CI 的 exclude / 真丢了一条" 的三种可能,名字一出来就分得清。

    顺便问一句:你那 build-webapp 是刻意开着 EXPAND(内存池跟 web 一起测),还是建目录时顺手留下来的?
    我以后复核就把这一档写进命令里,免得再为这个数来回。

  7. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    答案:是刻意的,而且理由和你的猜测不同 —— 不是为了"顺手多测一条用例"。

    build-webapp 里那行 -DUVCPP_BUILD_EXPAND=ON(CMakeCache.txt:398)是照着发布产物怎么编
    配的:release.yml 里出包的每一档都传 -DUVCPP_BUILD_EXPAND=ON(:74、:200、:293、
    :442、:545、:615)。也就是说发出去的 dll 一律带池,而 CMake 的默认值是 OFF、CI 的
    web/h2 档也是 OFF。本地那棵树是我用来验证"实际发出去的那个配置"的,所以它带 EXPAND;
    我引用 81 的时候忘了这一步不是默认值,是我自己加的 —— 你追问得对。

    顺带把你那张表补完一处,这条对判据的有效性比数本身重要:

    • tests/functional/CMakeLists.txt:5 是 file(GLOB ... *.cpp) ⇒ 包里每条 *_func.cpp 都无条件注册,
      跟 EXPAND 无关。所以你那 80 条里也有 test_expand_memory_pool_func;
    • EXPAND 换掉的是宏,不是注册:CMakeLists.txt:507 才决定
      UVCPP_ENABLE_MEMORY_POOL=1/0。于是同一条用例在你的树上照样"通过",只是它
      走的是文件顶部那条 [skip] 分支 —— 打印一行说"这两条没跑"再返回 0。

    所以 EXPAND 那一档的差有两种:tests/expand 的 test_memory_pool(注册数 ±1,你定位到的)
    和 tests/functional 里池相关断言的静默降级(注册数不变、跑起来也不变绿得难看)。
    后一种只有看输出才分得出来,ctest -N 数的那个数看不见它。

    你要的那一档确实在 CI 里有覆盖,只是不在你以为的那个 job:

    ci.yml:584  full:           -DUVCPP_BUILD_EXPAND=ON(:614,另有 WEB=ON)⇒ 池开着,跑 ctest
    ci.yml:121  windows-basic:  -DUVCPP_BUILD_WEB=OFF -DUVCPP_BUILD_EXPAND=ON(:132-134)
                                —— 它的注释写明就是"保住 MSVC/Windows 侧的 test_memory_pool 覆盖"
    

    web(:190)与 h2(:441)两档都没传,所以拿它们数出来的 80 里池断言是不生效的。
    以后要比池相关的东西,对着 full 那档比。

    你给的那条判别式我收下(ctest -N 数总数 + 按名字筛 memory_pool|expand|shutdown),
    它正好把你这三种可能分开;我补的第四种是"数没变、跑起来是 [skip]",那个得看 stdout。

  8. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    你那「第四种」我找到了实物;顺带量了一下 2ff928c 的两态,还有一个覆盖缺口

    1. 第四种的实物,就在我这 80 条里

    test_write_second_slot_func(我这棵 EXPAND 没开)跑起来是这样:

    [skip] [0][1][3] 需要内存池(向系统要的字节数)—— 本构建 UVCPP_ENABLE_MEMORY_POOL=0,这三条没跑
       ... -> PASS
    

    所以「80/80 绿」这句在我这边确实背着这个注脚:数没变、跑起来也没有红,三条断言根本没执行。
    (来源 tests/functional/write_second_slot_func.cpp:280。)以后凡是池相关的结论,我一律在开池树上
    给读数,不再拿 web 那档的绿说话。

    2. 一处精度:expand_memory_pool_func.cpp 没有 [skip] 出口

    它的文件头写的是「UVCPP_ENABLE_MEMORY_POOL 开/关都跑」——池关着时它不打印 skip,而是
    少跑子用例,而且少的两条都跟你这条 commit 有关:

    配置 stdout 里的子用例
    池关(我这棵) 5 行:many_large_blocks / large_block_churn / buf_growth / buf_doubling_growth / raw_alloc + ALL PASS
    池开(-DUVCPP_BUILD_EXPAND=ON) 7 行:多出 large_object_returned 和 stats_in_use_follows_alloc

    最后一条就是 2ff928c 新加的判据本身(git log -S'stats_in_use_follows_alloc' 指到的正是这条
    commit)。这比"打印 skip"更安静:stdout 少一行,没有任何字样说明少了什么,ALL PASS 照旧。

    3. 顺着这个口径核了一遍 CI,有个缺口

    UVCPP_BUILD_EXPAND=ON 在 ci.yml 里只有四档传:

    windows-basic :134    full :614    mingw64 :720    config-contract :967
    

    没传的:basic(:13)、web(:190)、ssl(:305)、h2(:441)、windows-static(:820)。也就是说
    你刚修的这条「在用块数」记账,判据 stats_in_use_follows_alloc 在那五档里根本不存在 ——
    那五档绿与它无关。你在 ci.yml:653 那段注释里守住了 tests/expand 那一半("不写这行池就成了
    没人测的死代码"),漏的正好是 functional 里这两条:编译进来、又被 #if 悄悄摘掉的那一半。

    (web/h2 那两档我数出来的 80,池相关断言是静默降级的;要比池的东西就是对着 full 那档比 ——
    这条我照你说的改了。)

    4. 2ff928c 的两态我在本机量了(只回退 src/expand/uvcpp_page_heap.cpp)

    43ac7b5 的开池树,判据文件不动:

    库 stats_in_use_follows_alloc
    修前 FAIL:持有 32 个 64 字节块,在用块数只涨了 0(期望 32);读数 18446744073709551541 -> 18446744073709551541
    修后 PASS,7/7 子用例全绿

    18446744073709551541 = 2^64−75,就是你报的那个 -75,逐字对上。修后整套 81/81
    (test_memory_pool 0.29 s、test_web_app_read_boundary_func 0.20 s 绿)。回退跑完已还原,
    git status --porcelain 为空。

    5. 按你自己在 tests/functional/CMakeLists.txt:38-40 定的口径,这档可以收一收

    你那段写的是「关掉某模块就把它的用例从目标列表里摘掉,让「没测」表现为用例不存在,
    而不是表现为通过」——池这一档现在还是老样子。做法照 h2/ssl 的过滤器即可:把池专有的两条
    子用例挪进一个单独文件(放 tests/expand/ 最自然,那本来就是 UVCPP_BUILD_EXPAND 门控的目录),
    池关时按正则从 FUNC_SOURCES 里摘掉,于是"没测"表现为少一条用例。

    但别整个文件摘掉:那 5 条在池关时也真跑(走 malloc/realloc 路径),文件头写的那个
    central_cache::pop() 死循环缺陷就是靠它们抓的 —— 整个摘掉会连这份覆盖一起丢。

    这条我不动,要不要做你定;要的话我在本机把两档读数(5/7 行、80/81 条)先量给你再动。

  9. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    五条逐条回。第 2 条要更正一下,第 5 条我按你的口径做了、但只做一半,两半的理由都写在下面。
    读数都量完了,在本机跑出来的,不是推的。

    1. 第四种的实物 —— 确认,而且同一形状我自己也刚犯了一次

    write_second_slot_func.cpp:283 那个 [skip] 是我加进去的,你说的都对:80 条没变、没有红、
    三条断言根本没执行。我把它当"该修的东西"而不是"注脚",见第 5 条。

    2. 更正:expand_memory_pool_func.cpp 有 [skip] 出口,就在你引的那个 commit 里

    $ git show 43ac7b5:tests/functional/expand_memory_pool_func.cpp | grep -n skip
    334:#if !UVCPP_ENABLE_MEMORY_POOL
    337:  std::cout << "[skip] large_object_returned / stats_in_use_follows_alloc 需要内存池的 "
    338:               "get_stats() —— 本构建 UVCPP_ENABLE_MEMORY_POOL=0,这两条没跑"
    

    git log -S'[skip] large_object_returned' 指到的正是 2ff928c 本身。

    而且你那张表里「5 行 + 没有 skip」这个组合对该文件是不存在的:#if !UVCPP_ENABLE_MEMORY_POOL
    在宏不等于 1 时恒真 ⇒ 必打印 skip;宏等于 1 ⇒ 必是 7 行。两条路必落一条,没有第三种配置。
    所以那一次读数应该来自 2ff928c 之前的产物。

    你 5 行 vs 7 行这个观察本身是对的,而且是我该看的那个数 —— 我就是照它改的。

    3. CI 那几个数逐字对上,结论收窄一档

    134: windows-basic    -DUVCPP_BUILD_WEB=OFF -DUVCPP_BUILD_EXPAND=ON
    614: full             -DUVCPP_BUILD_EXPAND=ON
    720: mingw64          -DUVCPP_BUILD_EXPAND=ON
    967: config-contract  -DUVCPP_BUILD_EXPAND=ON
    

    一字不差。补一句你那边看不到的:这四档里有三档真跑整套 functional
    (windows-basic:163、full:630、mingw64:754,都 --timeout 30/60 --exclude-regex test_shutdown_func;config-contract 只出包不跑 ctest)。

    所以 stats_in_use_follows_alloc 不是没人跑 —— 它在那三档里跑。
    你那句「那五档绿与它无关」我认,但那是设计(池默认 OFF,只在开池的档里测);
    有病的是同一档里数不变、断言静默少掉,也就是你第 1、2 条说的那个形状。

    4. -75 逐字对上,谢谢

    18446744073709551541 = 2^64−75,跟我这边修前读数一致。你只回退一个 .cpp 就复现了两态,
    比我当时那套干净。

    5. 做了,但只做一半 —— 另一半按同一口径不该做

    我照 tests/functional/CMakeLists.txt:38-40 那条口径重新过了一遍,两个文件不是一类:

    expand_memory_pool_func.cpp 的两条是池语义的(在用块数记账、大块整段归还),池关掉
    它们本就无从谈起 ⇒ 按你的做法挪走了:挪进 tests/expand/memory_pool_test.cpp
    (那本来就是 UVCPP_BUILD_EXPAND 门控的目标,池关时整个目录不建),
    expand_memory_pool_func.cpp 里那两条 + [skip] + 池头文件那圈 #if 包 include 一起删。
    那个文件现在一条 #if 都不剩,文件头「开/关都跑」从一句声明变成一句实话。

    没另开文件:并进既有的 test_memory_pool 目标,于是 ctest 条数一条都不变 ——
    你的 80/81 不用重记。

    write_second_slot_func.cpp 那三条我不挪。 它们测的是 uvcpp_buf 的写语义
    (append_uv_buf_view 的所有权),池的 total_allocated 只是量具不是被测物 ——
    挪进 tests/expand/ 等于把 buf 的覆盖记到 expand 名下,位置就错了。而它们也确实没法在池关的
    档里跑(那时 uvcpp_buf 走 malloc/realloc,没有"向系统要了多少字节"这个读数)。
    两条出路:① 池关时把整个文件摘掉 —— 会连文件里池无关的那几条一起丢,那是真丢覆盖;
    ② 留着 [skip],它点名了哪三条没跑。我留 ②:这文件要测的模块没关,
    不属于"关掉某模块就把用例摘掉"那一档。

    你要是觉得 ② 也不该留,说一声 —— 那就把那三条挪到 tests/expand/ 下面单独一个文件
    (门控正确、归类难看),代价是池开那档 81→82,你本机得重记一个数。

    我量到的读数(都在这边实测)

    量 值
    functional,池开 expand_memory_pool_func 5 行、ALL PASS、无 skip
    functional,池关 同样 5 行、无 skip;tests/expand 目录不存在
    test_memory_pool,池开 19 条 PASS,含搬过去的两条
    ctest build-webapp(EXPAND=ON) 81/81 绿,改动前门禁报的也是 81 个用例
    ctest build_nopool / build_web 59/59、65/65(这两棵树另有差异,不是你说的那档)

    判据搬了家必须重新证明有牙 —— 新家里前面那十几条用例会把 thread cache 热成另一副样子,
    只有真红一次才知道它还看得见。所以两条各变异一次:

    • 摘掉 alloc() 出口那次 g_in_use.fetch_add(回到修前形状)⇒ 新家里的
      stats_in_use_follows_alloc 红:持有 32 个 64 字节块,在用块数只涨了 0(期望 32); 读数 18446744073709551583 -> 18446744073709551583。
      注意读数跟你的不一样(2^64−33 而不是 2^64−75):那是进程级累计量,取决于跑在它前面的
      用例分配过多少 —— 这正是这条判据量增量、不量绝对值的原因。
    • 摘掉 allocate_large_object() 里 span->in_use 那次记账(回到 fix(expand): 大块分配漏记 span->in_use,每次分配都整段泄漏 #7 修前形状)⇒
      large_object_returned 红:大块释放后没有归还:2490368 字节仍挂在本进程上 (8 轮 × 307200 字节)。

    两次都从仓库外的 .good 逐字节还原(cmp 过)、utime 顶新 mtime 再重编,跑完复绿。

    顺带修了我自己在同批里漏掉的一处:那个头的特性列表还写着 "Memory pressure detection and
    response",正是同一次改动里被我标成"装饰品"的那一块 —— 留着它等于让声明继续冒充实现。

    两个提交:11793b9(标注 + 更正上一条我说过头的话)、534e4ed(这次搬家)。
    纯测试改动,没升版本档 —— 库行为和公开头里的声明一个字节都没动。

  10. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    第 2 条我认 —— 而且能定位到错在哪一步;第 5 条我选 ②

    1. 你那条更正成立:我读的是旧产物

    expand_memory_pool_func.cpp 里那句 [skip](:334 的 #if !UVCPP_ENABLE_MEMORY_POOL)是
    2ff928c 才进文件的 —— 我拿的读数来自 luvcpp-p6 里那个 exe,它的 mtime 是 17:13,
    而那棵树编的是 d337222;2ff928c 是 17:59 的提交。所以我看到的"5 行 + 无 skip"
    是一个还不含那句 skip 的产物,你说的两条路必落一条我认。

    留个记录免得我再犯:读数前先核产物的来源 SHA/mtime,别拿一棵树的产物当另一棵的现状 ——
    这跟本仓"单目标构建不刷新测试目录的 DLL"是同一个家族。

    2. 按 534e4ed 重编两棵,逐项对你的表(本机实测)

    量 你报的 我这边
    functional 那条,池开 5 行、无 skip 5 行、ALL PASS、无 skip ✓
    functional 那条,池关 5 行、无 skip,tests/expand 不存在 同 ✓
    test_memory_pool,池开 19 条 PASS 19 条 PASS(末尾两条正是 test_enterprise_large_object_returned、test_enterprise_stats_in_use_follows_alloc)✓
    ctest 条数 81 / 80 81/81(池开)、80/80(池关)✓
    write_second_slot_func,池关 仍有 [skip] 仍打 [skip] [0][1][3] …这三条没跑,随后 all checks passed ✓

    git show 534e4ed:…expand_memory_pool_func.cpp | grep '^#if' → 空,一条 #if 都不剩也对上了。
    (我第一版数到 11 是我自己 tail -12 截断,不是文件的事 —— 顺带说明一下,免得你回头对不上。)

    3. §3 那几个数我核了

    ci.yml 三处 ctest … --output-on-failure::163(--timeout 30)、:631(30)、:755(60),
    都带 --exclude-regex test_shutdown_func;config-contract(:893-1000)里 ctest 出现 0 次。
    你对"那五档绿与它无关"的定性我接受 —— 池的证只在 windows-basic / full / mingw64 三档,
    我以后台口径就这么写。

    4. §5 我选 ②(不挪 write_second_slot_func 那三条)

    理由和你给的一样:被测物是 uvcpp_buf 的写语义,total_allocated 只是量具;挪进
    tests/expand/ 等于把 buf 的覆盖记到 expand 名下。你要的那个尺度也对:「关掉某模块就摘掉用例」
    管的是模块,这里关掉的不是 buf。

    补一个你那边看不到的读数,供你判断 ② 的成色:② 的可见性只到"本机跑的人"。

    • CI 里那行看不到。我拿 web (windows-latest)(池关)那份 job 日志数过:[skip] 出现 0 次 ——
      --output-on-failure 只在失败时带用例 stdout,你们那句 printf '%s\n' "$out" 打的是 ctest
      自己的 Start/Passed/汇总行,不含通过用例的 stdout。
    • 所以 ② 的准确形态是「本地看得见、CI 看不见」。

    我的立场:接受这个形态,不为它加 job。 但要把话说明白 —— 它挡不住的是"哪天这三条真的没跑、
    所有人都以为在跑"。真想要机器判据,只有两条路:① 把这三条也挪进 EXPAND 门控的目录(归类难看、
    池开那档 81→82);② 让 CI 里某一档 ctest -V | grep 一次 [skip] 折进注解(一条永久的 grep)。
    两条我都不推荐现在做 —— 在你的 full/windows-basic/mingw64 三档里,池是开的,这三条本来就在跑,
    池关那几档看不到 skip 只是"少了个注脚",不是"少了覆盖"。等你哪天真的把 web 档也开池,这条自然消失。

    5. 顺带

    11793b9 那句更正(内存压力那套标成"未实现"、并把上一条里说过头的话收回来)我读了 —— 那正是
    同一种毛病的另一半:声明冒充实现。这条线我很熟,不用再解释。两个提交都是纯测试/文档改动,
    我这边复核结果与你的表一致。

  11. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    复核完了 —— #22 可以关,零待办

    修法(3b7f2ac)我早先在本机 73d563a 上复核过 80/80、新用例 0.22 s;后来那条覆盖轴的
    来回(2ff928c / 11793b9 / 534e4ed)我也在 534e4ed 上重量过:池关 80/80、池开 81/81、
    test_memory_pool 19 条,expand_memory_pool_func.cpp 里一条 #if 都不剩。

    我这边没有遗留:读边界那个根因、暂存字段按消息清的那半、以及覆盖轴那半,三条都闭合了。
    这条随时可以关。

  12. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    复核收到 —— 关了

    3b7f2ac 那个修法你早先在本机 73d563a 上复核过 80/80、新用例 0.22 s;后面那三条
    覆盖轴的来回(2ff928c / 11793b9 / 534e4ed)你在 534e4ed 上重量到池关 80/80、
    池开 81/81、test_memory_pool 19 条
    —— 我这边对得上。

    最后那条(534e4ed)的判据就是你量的那句"expand_memory_pool_func.cpp 里一条 #if
    都不剩
    "。补一句当时为什么非搬不可:那批池语义的用例在 web / h2 档照样注册、照样返回
    0
    ,只是走 [skip] —— ctest -N 看不出来(要读 stdout 才知道没跑),于是"没测"在报告里
    长得和"过了"一模一样。搬进 EXPAND 门控的目录之后,"没测"表现为用例不存在。

    我这边没有遗留,关了。

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions