Skip to content

响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17

Closed

Description

@Antruly

静态下发剩下的 2.00× 用户态拷贝:body() 自存一份 + to_string() 头体合并成一条。

(本条是从 #14 里拆出来的。背景、测量方法、以及"拷贝放大比"这个指标怎么算,
见 #14 —— 那里已经量清楚了,本条不重复。快路径那部分已经落地:PR #15 / 00b1eed。)

为什么它比快路径大一个量级

探针数出来的账(每上线一字节所付的用户态拷贝字节):

响应体 基线 快路径之后 剩下的两份
4 KiB 3.11× 2.05× body_store + tostring
256 KiB 3.00× 2.00× 同上
4 MiB 1.00× 0.06× 走分片,不经过这两处

这两份不是同一件事的两半,是一次所有权设计的两个面:

  • to_string() 之所以存在,是因为要把头和体合并成一条才能交给一个 uv_buf_t;
  • 改成 nbufs = 2(头一条、体一条)就能不合并 —— 但异步写要求那条 uv_buf_t
    活过完成回调,而 uvcpp_web_response 今天是"体拷进自己存一份",响应对象析构后
    体就没了。要让 uv_buf_t 指向它,就得让响应体变成引用计数
    (shared_ptr<const std::string> 之类)而不总是拷贝。

顺带解决探针发现的另一个结构性事实:uv_write 的 nbufs 恒为 1.00 ——
散射聚集能力一次都没用上。

爆炸半径

uvcpp_web_response 不只静态下发放用:流式、压缩变体、h2 都过它。
所以这是改一个所有出站路径都要走的对象的语义,不是加一条旁路。

已记下的线索(不在本条的必做范围)

  • TLS 路径比明文多两次拷贝(静态下发:每服务一字节被拷 3 次,其中一次可用 uv_try_write 消掉(附实测与原型,想先定完成时序) #14 里作者只作为线索提,没做实测):
    tls_flush_out() 里 take_ciphertext → tls_out_.append 一次,
    uvcpp_buf bufcpp(chunk.data(), chunk.size()) 又一次。第二次看起来可以用同一个
    试发手法消掉(chunk 是连续的、tls_out_ 本身就是成员 string),但
    部分接受之后新产生的密文不得插到余量前面 —— 这条次序约束得单独想清楚。
  • 缓存命中的静态文件是最理想的情况(文件字节本来就在 LRU 里、生命周期够长),
    这条路上 body() 那份拷贝能一起免掉。

非目标

  • 不动分片流式那条路(它本来就只有 transport 那一份)。
  • 不为它加开关:这是所有权语义的改动,可配置等于两条语义并存。

Activity

  1. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    把我这边欠的两件东西补上:按路径的拷贝普查,和你表里 4 KiB 那一格的更正。顺带量到一件我没预料到的事 —— 压缩把这条比值放大了 4~7 倍,而且热路径上那份文件拷贝是纯白付。最后一条与你刚落的 #11 变体缓存直接接上。

    一、更正:4 KiB 那格是门槛前的旧口径

    你表里写"快路径之后 4 KiB = 2.05×"。那是 #15 里无门槛那一版的数。落地的形状带 32 KiB 门槛(UVCPP_TRY_WRITE_MIN_BYTES),所以 4 KiB 那一档根本没走快路径,三份拷贝一份没少:

    4 KiB 明文静态 拷贝/上线字节
    基线(快路径整个关掉) 3.11×
    快路径 · 无门槛 2.05×
    快路径 · 门槛 32 KiB(= master 实际形状) 3.11×

    同一棵树、同一个二进制里量到的,探针里 tw_skipped 每响应 +1.03、tw_attempted 恒 0 —— 门槛把这一档整个让出去了,所以它在 4 KiB 上等于没开。

    这条对 #17 是好消息:最常见的档位上一份拷贝都没省,你在 #17 里能拿走的比表上写的更多。

    二、按路径的拷贝普查(master d5cbcbd = 1.1.24)

    装置重建在 master 上了(老探针树停在 1.1.14,master 现在是 1.1.24,中间隔了 #15/#16/#11 三条改动,那些数不能再用)。这棵树的形状是可核的:git archive d5cbcbd + 15 行插入 + 2 个新文件,0 删除,其余与 master 逐字节相同;全量 ctest 76/76。探针只加计数点,不改任何控制流。

    明文(不带 accept-encoding)—— 与上面那张表同口径:

    路径 body_store tostring transport 合计 快路径
    4 KiB 4100.3 4319.2 4319.2 3.11× 跳过(门槛下)
    64 KiB 65540.3 65761.2 4.2 2.00× 吃下 100%
    256 KiB 262148.4 262370.3 4.3 2.00× 吃下 100%
    4 MiB 22.1 28.4 262396.3 0.06× 吃下 93.8%,17 笔写/响应
    web-4k.html 4100.4 4320.3 4320.3 3.11× 跳过
    web-64k.html 65540.4 65762.3 4.3 2.00× 吃下 100%
    /__fixed?n=4096(纯内存,隔离磁盘/静态层 LRU) 4100.4 4220.3 4220.3 3.06× 跳过

    三件事被这组数钉住:

    1. nbufs 恒 1.00,七条路径无例外 —— 你说的第 ② 条在 master 上原样成立。
    2. body_store 就是那份文件拷贝:4 KiB 档 4100 / 响应(1.01 次/响应),4 MiB 档塌到 0.05 次/响应(流式那条路不把整个文件装进 body)。它和 tostring 的可省部分,恰好就是 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 的两个靶子。
    3. 4 MiB 那条路 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 够不着:它 0.06×,拷贝几乎全在 transport(分片流式,每笔 256 KiB),body_store/tostring 都接近 0。你的非目标里写了不动分片流式 —— 数字支持这个决定。

    #17 之后的可证伪预期(按这两个数字推):

    档位 现在 只做引用计数 引用计数 + nbufs=2
    4 KiB 明文 3.11× ~2.11× ~0.11×
    64 KiB 明文 2.00× ~1.00× ~0.0035×

    中列那一格值得看一眼:4 KiB 档只做引用计数只从 3.11× 落到 2.11× —— 因为 tostring 那份"头体合并"里还裹着一整个体。nbufs=2 不是可选项,它是第二半。 64 KiB 档中列就到 ~1.00×(那一档的快路径本来就把 transport 吃干了)。

    前提写清楚:右列成立的条件是异步写那条路直接吃引用计数缓冲、不再拷进 uvcpp_buf —— 那正是引用计数要买的东西。如果只做引用计数、不碰 nbufs,落中列。另外我这套探针数的是 uvcpp_web_response::body() 这个调用点;静态层一旦改成交引用(不再调 body()),body_store 会直接掉到 0,所以同一套装置可以原样复测,不用改判据。

    三、没预料到的:压缩把这条比值放大 4~7 倍

    两组冷/热数(accept-encoding: gzip,资产是 gzip 到 440 / 3796 字节的可压文本):

    压缩静态 body_store tostring transport compress_out variant_hit variant_store 合计
    4 KiB 冷 4118.1 734.4 734.4 440.0 — 440.0 14.70×
    4 KiB 热 4100.5 711.7 711.7 — 440.0 — 13.55×
    64 KiB 冷 65558.5 4092.8 4092.8 3796.0 — 3796.0 21.43×
    64 KiB 热 65540.5 4069.8 4069.8 — 3796.0 — 20.41×

    (冷 = 20 个不同文件名、同一份内容的资产,每个都必然未命中;探针把「未命中数 == 请求数」当判据卡死,不是旁证。热 = 紧接着请求同一个文件,cv_hits 正好 100。)

    两个机制,一个比一个值钱:

    ① 分母塌了,分子没动。 body_store 拷的是未压缩的原始文件,而上线字节是 gzip 之后的。web-4k.html 4096 → 440 字节,body_store 一项就占了 931.9% ~ 1726.6%。所以文件越大、越可压,这条比值越难看 —— 它量的其实是"压缩换来的分母缩小",不是拷贝变多了。

    ② 热路径上那份文件拷贝是纯白付。 这是最扎眼的一条。命中时 uvcpp_http_server.cpp 走的是:

    resp.body.clear();
    resp.body.clone(it->second.data);

    静态层刚把整个文件拷进 resp.body(4100.5 / 65540.5 字节/响应),下一行就 clear() 掉。命中率 100% 的那 100 次里,这一份一次都没被用过。它不是 #11 引入的 —— 变体缓存之前,这份拷贝的用途是"拿去 deflate";有缓存之后,命中时它的用途变成零。#11 的收益(省掉整个 deflate)是真的,代价是让这份拷贝从"必要"变成"白付"。

    四、与你刚落的那条变体缓存直接接上

    我为这三处新加了探针点位(老探针一处都没有,因为 1.1.14 时还没有压缩路径):

    • compress_out —— resp.body.clone(result.data),deflate 的产出拷回响应体,冷热都有,1× 压缩体;
    • variant_hit —— resp.body.clone(it->second.data),命中时从表里拷回来,热路径,1× 压缩体;
    • variant_store —— v.data.clone(resp.body),就是你注释里那句「存进表要再拷一份」,冷路径,1× 压缩体。

    所以 #17 的引用计数和这条缓存是同一条线的两头,而且方向一致:

    • variant_store 那一份本来就是被注释解释为"不得不"的(响应自己那份还要发出去,不能 move)——引用计数正好解掉这个"不得不":表里存 shared_ptr<const string>,存的是引用不是副本,那句话就不成立了;
    • variant_hit 那一份同样变成换个引用;
    • 连带地,resp.body.clear() 前那句"刚拷完整个文件"也没必要发生。

    我的判断(你说过我定):先做 #17,不要反过来。 理由不是"哪个更重要",而是顺序:先在 uvcpp_http_server 里局部造一个"响应体可以共享"的约定,等 #17 把所有权通用化时还得改一遍 —— 一次到位更省事。#17 整个落下去(body_store 那份 + tostring 里裹着的那份体都消掉)能拿走明文路径上 **64%(4 KiB)/ 99.8%(64 KiB)**的拷贝量,变体缓存那三处是它的免费副产品。压缩那条路上的预测:热路径 13.55× → 约 1.0×(body_store 与 variant_hit 归零,tostring/transport 缩到头)。

    一个要提前想的记账问题:compress_variants_bytes_ 现在是"表内字节"的精确账(32 MiB 上限靠它)。body 变成引用计数之后,"表里存了多少字节"不再等于"表占了多少内存" —— 一个被共享的 body 可能本来就是活的。要么把上限改成按唯一字节记,要么明确接受它变成高估。这条不影响正确性,但会让人在调上限时算错。

    五、装置与判据

    装置在 D:\build\census\(copysrv.cpp + measure2.js + census.sh + build.bat),跑 bash census.sh 复现全部数。它把老装置那几条硬前置检查都带过来了,因为它们全是踩出来的:端口被遗留进程占着会让两轮数一模一样地"成功";run/ 下的 DLL/exe 是拷贝,忘了对齐只会表现为数不对;copy_probe_stats 是按值返回的结构体,加字段后旧 DLL + 新 exe 会读到栈上垃圾 —— 所以客户端里那条「reset 后 body_store_bytes == 5 且 transport_bytes == tostring_bytes」的自检同时也是 ABI 检查。

    局限,写明白:探针树是 master + 15 行计数插入,不在上游,别人复核要走这套装置;全部走回环,绝对值(那几个 ns、MB/s)随机器变,但比值与压测端速度无关(计数器在服务端),所以上表里拷放比那些数是稳的。压缩那两组用的是可压文本资产(gzip 到 10.4% / 5.7%),换成真图片/已压过的资产会靠近 1.0× 那一侧 —— 这一条我按"最坏方向"给的。

  2. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    一、4 KiB 那格的更正:接受,是我没跟着自己的改动更新表

    你是对的,而且错得比"抄错了"更难看一点 —— 门槛是我自己在 #15 里加的,落地之后我没回头改 #17 那张表,
    于是表里的"快路径之后 2.05×"是无门槛那版的数。探针里 tw_skipped 每响应 +1.03、tw_attempted 恒 0
    是硬证据:门槛把 4 KiB 这一档整个让出去了,它在那一档上等于没开。

    所以这张表该按落地形状重写。而且方向对 #17 是好消息:最常见的档位上一份拷贝都没省。

    二、普查数我认;并且"热路径那份文件拷贝是白付"我这边当场核过

    不用探针也能核,两处对读就够了:

    // src/webapp/uvcpp_web_static.cpp:962-963 —— 先拷整个文件进响应体
    resp.body(j->data->data() + static_cast<size_t>(first),
              static_cast<size_t>(count), std::string());
    // src/web/uvcpp_http_server.cpp:923-924 —— 命中时下一句就把它 clear 掉
    resp.body.clear();
    resp.body.clone(it->second.data);

    中间没有任何一处读过那份拷贝。命中时它 100% 是白付 —— 与你的计数器一致。

    三、排序:同意,先 #17,而且我这里还有一条独立理由

    你说的理由(先在 uvcpp_http_server 里造局部约定,等 #17 通用化时还要再改一遍)成立。补一条我自己的:

    我本来会想到一个"便宜的替代" —— 把变体表的查表提到 web_static 产出 body 之前,命中时那一份文件拷贝就压根不发生。
    但那正是我在 apply_compression 里钉死不许做的事:

    到头来"要压"已经定了。缓存只在这之后介入…… 谁要把查表提到门前,这条就破了。

    (那条注释护的是四个运行时旋钮的"改了配置不会发出旧结果":查表必须在"要不要压"这个判断之后,
    否则"配置变了但表里还发旧结果"就成立了。)

    所以这份浪费没有便宜的修法,只有 #17 —— 你这个排序与那条约束是同一个方向。
    顺带,它给那条注释加了一条新理由:把查表提前不仅能发旧结果,还会让热路径多付一次白拷贝。

    variant_store 那句「存进表要再拷一份(响应自己那份还要发出去,不能 move)」的注释,
    等 #17 落地就是过期的绕行说明 —— 引用计数正好解掉那个"不得不"。到那天我会跟着改掉它,
    不会让它比我描述的 bug 活得更久。

    四、你提的记账问题:接受高估,不取"unique bytes"

    我的判断是保留今天的"表内字节"口径,并把触发条件写进上限的注释。

    理由是先把高估的范围缩小:body 变成引用计数之后,"表里存了多少字节"什么时候才不等于"表占了多少内存"?
    只有一种情形 —— 两条表项引用同一个 shared body(两个 tag 对应同一份压缩产物,比如内容相同、
    mtime 不同的两个文件)。单条表项引用的 body 反而更接近正确:响应释放之后,是表在持有它,
    那笔内存就是表占的。

    而为这一种罕事去维护一个指针集合,代价落在每次 store/evict 上;何况高估的方向是偏早淘汰 ——
    内存少用一点,是安全的那一侧。所以我取"接受 + 注释写明触发条件",不取去重。

    五、如果要做,我要的落地面

    四条,前两条是形状、后两条是门槛:

    1. 加法式:body 变成一个可共享的手柄(shared_ptr<const std::string> 或等价物)。现有入口
      (body(const char*, size_t, ...) 等)语义不变、仍是拷进来,另加一个"接管已有缓冲"的入口。
      这样对现有消费者不破坏 —— 与 三条接入与加固问题:req.path() 不规范化致前缀鉴权可被 // 绕过、请求头与 URL 无长度上限、使能宏不一致静默出错 #6 第 3 条那种"动公开头契约"的改动不是一类,不需要出包级验收。
    2. nbufs = 2 同批做。你那张中列已经把它钉死了:只做引用计数,4 KiB 档只从 3.11× 落到 ~2.11×,
      因为 tostring 那份"头体合并"里还裹着一整个体。它不是可选项,是第二半。
    3. 必须过页面堆门禁(tests/tools/run_pageheap_gate.py)。改的是所有权语义,而所有权是 UAF 的高发区
      —— 裸跑绿不算数,本仓在这上面栽过。
    4. 判据不新增概念:compress_variant_stats() 已经在(hits / misses / stored / entries / bytes),
      对应你探针的 variant_hit / variant_store。你说的"静态层改成交引用后 body_store 会直接掉到 0、
      同一套装置可以原样复测"——这个复测方式我认可,判据不用改。

    六、一个前提我认,但想写明

    你右列的 ~0.11× / ~0.0035× 成立的前提是"异步写那条路直接吃引用计数缓冲、不再拷进 uvcpp_buf"。
    这条我同意,但它是形状上的必做项,不是可选的优化 —— 只做引用计数不碰 nbufs,落你写的中列。
    所以上面第 2 条我写成"同批",不是"顺带"。

  3. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    一、四条落地面收到,排序也收到(先 #17)

    逐条认,并且说清楚哪几条在我这边兑现、哪条只能在你那边:

    1. 加法式 —— 照做。新入口与现有 body(const char*, size_t, ...)/body(const std::string&) 并存,
      不是替换 —— 这样「现有消费者一行不改」本身就是一条可核的判据。
    2. nbufs = 2 同批 —— 同意,而且我把你那句「它不是可选项,是第二半」读得更具体一点:
      只做引用计数会把节省停在中间那列,4 KiB 档从 3.11× 只落到 ~2.11×,离右列差一个量级。
      所以「同批」的意义是不让中间状态被当成结果。
    3. 页面堆门禁 —— ⚠️ 这一条只能是你这边:tests/tools/run_pageheap_gate.py 在本机跑不了
      (没有 cdb,也没有 gflags)。和 perf(net): write() 先 uv_try_write 试发,吃得下的那部分不再拷进待发缓冲 #15/perf(handle): uvcpp_stream::try_write 不再拷贝一份 uv_buf_t 数组 #16 一样的分工:我这边交裸跑 ctest + 变异 + 端到端,
      门禁由你跑。我不会把「裸跑绿」说成「过门禁」——这两句话在你这里不是一个意思,我知道。
    4. 判据不新增概念 —— 接受。compress_variant_stats() 那五个字段够用;
      「静态层改成交引用后 body_store 直接掉到 0、同一套装置原样复测」这条复测方式我照用。

    二、你那两处坐标我核过,结论一致

    uvcpp_web_static.cpp:962-963 先把整个文件拷进响应体,uvcpp_http_server.cpp:923-924 命中时下一句就
    clear() 掉 —— 中间没有任何一处读过那份拷贝,命中时 100% 白付。与我的计数器一致。

    三、那条「便宜的替代」确实被封死,我复核了你的理由

    把查表提到 web_static 产出 body 之前被否掉,理由是「配置改了会发旧结果」—— 成立(而且那正是
    apply_compression 上面那条注释护的东西)。所以这份浪费没有便宜的修法,只有 #17,同意。

    两处注释的到期日我记下来了:variant_store 那句「响应自己那份还要发出去,不能 move」在 #17 落地后
    就是过期说明。到那天建议一次改两处,顺带把 apply_compression 那条「谁要把查表提到门前,这条就破了」
    补上你新加的那半句理由(提前查表还会让热路径多付一次白拷贝)。你改或我改都行 —— 若你自己改,
    我在 PR 里核这三处措辞与代码是否还对得上。

    四、记账:接受高估 + 注释写明触发条件

    同意,而且我认为关键是你把高估缩到「只有两条表项引用同一个 shared body」这一步 ——
    它把触发条件从「可能有偏差」变成了可判定的(内容相同、mtime 不同的两个文件)。我会把这句话
    写进上限常量的注释,并且不引入指针集合。补一条我这边能顺带做的:装置上加一格
    「表内字节 vs 进程 RSS 之差」的观测,这样那种罕事一旦真发生,是看得见的,不用等它把内存吃歪。

    五、我打算怎么落地(结构,供你先否)

    先报一个我读到的事实,它决定手柄的落点:uvcpp_buf 今天是纯拷贝 API ——
    clone(const uvcpp_buf&) / clone_data(const char*, size_t) / clear(),没有共享路径,
    resp.body 就是它(uvcpp_http_response.h:41)。所以「可共享的手柄」不是给已有东西换个用法,是新加的:

    1. 手柄:uvcpp_buf 内加一个可选 std::shared_ptr<const std::string>(等价物按你偏好),
      get_const_data() / size() 优先读它。所有既有 mutator 一律先丢弃手柄(clear()、clone*())——
      这保证现有入口语义逐字不变,也保证「共享之后又被改写」只会退化成一次拷贝,不会写坏别人的缓冲。
      为了让这种退化可观测而不是静默变慢,我打算给它挂一个探针计数(沿用我们这套口径)。
      这一条如果你想要硬断言而不是静默丢弃,请在 PR 上直接说,我按你的来。
    2. 接管入口:加一个"接管已有缓冲"的入口(名字待定,倾向 body_ref(handle) 一类)。
      静态层这边几乎白拿:j->data 本来就是 shared_ptr,整份下发时递手柄 = 一次引用计数加一;
      partial(切片)留在拷贝路径上 —— !partial 那道判断已经把这个岔口分好了。
    3. 写入侧 nbufs = 2:头的序列化与体不再合并成一个 std::string,改成
      uv_write / uv_try_write 递两个 uv_buf_t(头 + 体)。体那个 uv_buf_t 的生命周期
      由写请求持有的手柄保证 —— 这正是「异步写那条路必须直接吃引用计数缓冲」那条前提的兑现方式,
      你把它定成形状必做项我同意:不这样做,nbufs 就还是 1。
    4. 表的存与取:compress_variant.data 变成手柄,命中时递手柄而不是 clone;
      存入时同理 —— 那句「不能 move」的注释到期就是这一步。

    顺序:先做 1+2(静态层整份下发命中路径 + 未命中路径都走手柄),量一次;再做 3,量一次。
    两次都用现装置(body_store 那条判据两次读数),这样「中间那列」的落点是被量出来的,
    不是推出来的 —— 也正好能验证我上面第 2 条说的「不让中间状态被当成结果」。

    我先在探针树上做,不在 fork 上开 PR 直到本机自证(裸跑 + 变异 + 端到端)过了。

  4. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    一、四条对上了;第 3 条确实只能我跑

    分工照 #15/#16 办,门禁在我这棵树上跑。你的 PR 出来我就跑,结论只属于那个 SHA ——
    我报的时候会连同 SHA 一起报,不会拿"上一版跑过门禁"顶账。

    二、静默丢弃,不要硬断言 —— 理由是 clear() + clone() 不是误用

    你要的这条我直接定:静默丢弃 + 计数器。

    clear() 紧接 clone() 是响应对象复用的正常序列(keep-alive 上同一个响应对象被反复填),
    不是误用。硬断言会把一段合法用法变成崩溃,而你想防的事("写坏别人的缓冲")已经被
    shared_ptr<const std::string> 自己挡住了 —— 那个 const 就是防线,不需要第二道。
    断言在这里加的不是安全,是约束了一个不该被约束的用例。

    计数器我认,加一条判据:它在现有全套用例上的读数必须是 0,或一个能逐处解释的常数。
    到处非零说明有地方"先共享又丢"—— 那不是错误,是一次白拷贝,但值得看见。

    三、nbufs = 2 的措辞我改一下:不是"第二半",是"唯一一次能看到中间状态的机会"

    你说"不让中间状态被当成结果"—— 对,我把它写得更具体:

    • 只做 1+2:4 KiB 档 3.11× → ~2.11×,那不是一个可用终点,是一个会被误读成"优化过了"的数;
    • 做 1+2+3:才是右列。

    所以两次量我同意,但两次必须在同一棵探针树上、同一次进程内,否则两次之间混进了别的改动
    就说不清是哪一步的功劳。(#15 那次的教训:结论只属于跑它的那棵树。)

    四、记账:接受高估;你那格"表内字节 vs 进程 RSS"是对的加法

    我上一条把高估缩到"只有两条表项引用同一个 shared body"这一步,你加一格 RSS 观测正好补上另一半
    ——它让"罕事一旦发生是看得见的",而不用等它把内存吃歪。这比我原来"写进注释就完了"强,
    照做。

    五、落地顺序与注释到期日

    1+2 → 量 → 3 → 量,同意。两处注释你来改(你在那个 PR 里动得最多),我在 PR 上核三处措辞:

    对不上我会在 PR 上直接指出来,不另开一轮。

  5. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    一、1+2 步落地了,也量了:整份文件那份拷贝真的消掉了

    先按你上一条给的加法式要求兑现:新入口 body_share() 与现有
    body(const char*, size_t, ...) / body(const std::string&) 并存,替换掉的一个没有。
    落点:

    • uvcpp_buf 里加一个 shared_ptr<const std::string> shared_ 手柄。capacity_ > 0 = 自有块、
      capacity_ == 0 && base = 外部视图 —— 手柄是第三种,判据是 shared_ != nullptr,
      跟这两种都不撞。
    • 加 materialize():先拷、后放引用(顺序反了就是把那个 string 析构掉再 memcpy 它)。
      所有写入口(resize / set_zero / get_data / get_udata / out_uv_buf)都先过它,
      move_buf() 把引用一起搬走。所以"共享出去之后又被改写"不会写坏别人的字节 —— 这是
      共享能不能成立的那一条,不是优化。
    • 静态层整份命中路径与未命中路径都改成走手柄;!partial 是护栏(见下面第三档)。

    一个施工期踩到的编译错,值得记一笔:get_data() const 里调 materialize(),而它是非 const
    成员函数 → error C2662。只把成员标 mutable 不够(那让成员能在 const 上下文里被换掉,
    不让非 const 成员函数能在 const 上下文里被调用)。最后是 materialize() const + buf /
    capacity_ / shared_ 三个成员一起 mutable,没有用 const_cast —— 本类的 const 从来只
    保护"句柄的同一性"、不保护那些字节(既有的 char *get_data() const 就交出可写指针),
    mutable 只是把这个既有事实写明。你那边如果落法不同,这一处是唯一会因为 const 报错的地方。

    二、方法:同树变异 A/B,不是跨版本比

    你说"想要那个数",但跨版本比(1.1.24 基线 vs 1.1.26+#17)会把 206 和掩码那两个改动混进差值里。
    所以走的是同一棵树上只差一行:

    编译开关、探针、资产、客户端、机器全是同一份,差值只能归给那一行。变异脚本对锚点带断言,
    来回都幂等。另外为了让"前后"两臂可比,两臂各自跑了两遍 —— 确定性计数逐点一致
    (DLL 哈希会变,但它不是内容判据:同一个源码连着链两次哈希都不一样,PE 里有链接时间戳)。

    三、数(1.1.26,Release,单位 = 字节/响应;share_ref = 这次"免掉"的拷贝)

    场景 前:拷贝合计 后:拷贝合计 后 share_ref
    静态 4 KiB 明文 12770.6(3.12×) 8674.6(2.12×) 4096
    静态 248 KB 明文 508156.6(2.00×) 254204.6(1.00×) 253952
    内存 4 KiB 明文(对照) 12545.0 12545.0(逐点相同) 0
    Range bytes=0-1023(反面对照) 3648.9 3648.9(逐点相同) 0
    【冷】248 KB 压缩(20 个不同文件) 383005.8(8.93×) 129053.8(3.01×) 253952
    【热】248 KB 压缩 340042.2(7.93×) 86090.2(2.01×) 253952

    判据是一升一降、而且降在预期那处:body_store 每档正好降一份文件大小
    (253952 / 4096),share_ref 正好升同样多;tostring / transport / variant_hit /
    variant_store / compress_out 一处没动。这两条同时成立才说明钱省在了该省的地方,
    而不是"合计变小了、不知道小在哪"。

    两档对照臂逐点相同,我认为这比主结果的更能说明问题:

    四、两件我想单独说的事

    1)248 KB 明文那一档落到了 1.00×,剩下的唯一一份是 to_string()。
    不是"还差一点",是结构性的:transport 那一份已经被 try_write 吃掉了(吃下 100.1%),
    所以明文大文件这条路上剩下的就只有头体合并那条报文 —— 而它本来就是发出去的字节,
    不是白付。换句话说,明文大文件这条路上 #17 已经到底了。

    2)热压缩那档剩下的 2.01× = tostring + variant_hit——正是"第二半"(③④)的目标。
    冷压缩从 8.93× 掉到 3.01×、热压缩从 7.93× 掉到 2.01×,差额全部来自 body_store。
    热路径上 variant_hit(命中拷回)那一份还在:#17 的 1+2 只碰了"从文件到响应体"这一段,
    没碰"从变体表到响应体"那一段。它要 ④ 才能消掉。所以我把 ③ 和 ④ 当成一次量、不是两次。

    五、一个必须说清的副作用:这是二进制 ABI 变更

    我这边先在探针树上自证,中间撞到一次看起来像 bug、其实是我自己的错的现象,但结论对你有用:

    uvcpp_buf 加了 shared_ 成员、materialize() 从非 const 变成 const(改名的)——
    结构体布局和符号名都变了。只把新 uvcpp.dll 换进去、不重编消费者,会炸:
    旧的测试 exe + 新的 DLL → 局部 uvcpp_buf 按旧尺寸开栈帧、DLL 的构造函数按新尺寸写
    → 退出码 0xC0000409(栈越界)/ 0xC0000374(堆损坏),在 ctest 里表现为
    0.01 秒、一行输出都没有(这类失败最像"环境问题",我第一次就误判成 flaky;
    把 exes 重编一遍 → 76/76,一次不差)。

    所以:"加法式"在源码层成立,在二进制层不成立 —— 现有消费者一行不改是对的,
    但不重编不行。这条要怎么处理(版本号 / 说明 / 放不改的成员位置)由你定,我只是把它
    量出来了。我认为该说明而不是该回避:这个库是 DLL 分发的,静默的 ABI 破坏会以
    "别人的程序崩在一行莫名其妙的代码上"的形式出现,那种报告最难查。

    六、本机状态与下一步

    • PR 树(1.1.26 干净树 + 前两步):76/76 全绿。探针树:76/76 全绿。
    • 下一步做 ③ nbufs = 2,量一次。queued_write 必须持有手柄——我把响应析构、
      写请求还指着那两个 uv_buf_t 的时序专门查过:不持有就是悬空指针,而不是"拷贝还在"。
      然后 ④ 变体表按手柄存/取。
    • 后面给你 PR,会跑你那个 run_pageheap_gate.py(本机没 cdb,跑到哪步我会写清楚)。
  6. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    一、③ 落地了:PR 在这里,门禁请跑这个 SHA

    PR #19,head fdccc32e40bb3b3cb503bf5e567fd1cc9c2d05b(分支
    sercebr:perf/response-body-refcount)。①②③ 三步 + 新测试 + 你 04:22 点名的那两处注释都在这一个
    PR 里。/workspace/libuvcpp 上那个 run_pageheap_gate.py 跑的就是这个 SHA,不是之前那个 aaa9d8f。

    按你 §三 的要求落的方法:两臂是同一棵树、同一份二进制、背靠背跑,唯一差异是一个环境变量
    (UVCPP_NBUF2_OFF=1 = 头体先合并成一块再写,也就是 ③ 之前的形状;不设 = 两块交出去)。编译
    开关、链接、探针插点、资产、客户端、机器全同一份,差值只能归给那条分支。臂的物证是服务端自己
    报的
    nbuf2_off 与探针上 uv_write 的平均 nbufs(前恒 1.00 / 后恒 2.00,六档逐档核过)。

    说清一句:我是两个服务进程(同一个 exe)依次跑完两臂,不是字面意义上"一个进程里量两臂"——
    同一棵树、同一轮、中间不夹别的改动这个意思我兑现了。你要的若是字面的一进程内两臂,我把开关
    做成请求级的(/__arm?nbuf2=0|1),一句话的事,你说要我就做。

    二、数(字节/响应;比值 = 用户态拷贝合计 ÷ 上线字节)

    档位 上线字节 前 后 后臂剩下的
    静态 4 KiB 明文 4096 2.12× 0.06× 头部 239 B
    静态 248 KB 明文 253952 1.00× 0.00× 头部 242 B
    内存 4 KiB(对照) 4096 3.06× 1.03× 调用方自己的 body_store
    Range bytes=0-1023(反面对照) 1024 3.56× 1.28× 同上(!partial 护栏没变)
    248 KB 压缩【冷】(20 个不同文件) 42896 3.01× 2.01× compress_out + variant_store
    248 KB 压缩【热】 42896 2.01× 1.01× variant_hit(= ④)

    六档的 tostring(头体合并那次拷贝)全部归零;③ 碰不到的那几行(body_store /
    compress_out / variant_hit / variant_store / share_ref)逐字相同 —— 这两条同时成立才
    说明省在该省的地方。后臂的 248 KB 明文落到 242 B/响应,那就是头部本身,不是"还差一点"。

    三、你定的那条判据:计数器的读数

    按你 04:22 §二 落的:共享视图被写时静默物化(clear() 紧接 clone() 是复用响应对象的正常
    序列,那个 const 就是防线),但计进 uvcpp_buf::share_discard_count(),只在那一支 ++。

    全套用例的读数:77 个用例,只有新测试自己是 1(那 1 就是它 E5 里故意"先共享又丢"那次),
    其余 76 个全是 0 —— 包括整个 web/静态层/压缩那一片。那个 0 是逐档看过的,不是"没量到"
    (临时在库退出时打印、量完已撤,没留在 PR 里)。

    变异验证它能咬:把热路径取字节从 get_const_data() 改成 get_data()(该只读的地方拿了可写
    指针),三条判据同时红,其中就有这条。另一次变异(hold 传 nullptr)红的是引用计数那条。

    四、其余两份力气的落点

    • 那两处注释按你说的落在 PR 里了:variant_store 的"不能 move"补上"≠只能拷"(表里存句柄就
      是 ④);apply_compression 的"谁要把查表提到门前,这条就破了"补了后半。但第 2 条这一半
      我按"我量到的那条机制"落的笔,可能不是你说的那条
      —— 我写的是:提前查表换不到你要的
      东西,热路径上那次白拷贝的成因不是"查表太晚"而是"命中判断晚于身体就位"(静态层先把整份文件
      拷进 resp.body,命中那一支下一句就丢掉),而查表早不了(键要的 cache_tag 与门槛要看的
      body.size() 都是身体就位之后才有的)。你要指的是另一条,PR 上直接指出来,我改。
    • 本机 ctest -C Release:77/77(改动前 76/76)。探针树同样 76/76。

    五、这个 PR 唯一的接口变化

    send_response 由 void 改成返回"这次上线的 body 字节数"。以前访问日志是调用之后回头读
    resp.body 拿到的;头体分开走后体会被移动出去,再读一次静默变成 0(gzip 的响应会记成 0
    字节,比"记成压缩前的长度"更难看)。所以改成由函数返回。uvcpp_buf 那边加的是加法式入口
    (share() / body_share() / 两个 append_uv_buf_* / 那个计数读数),既有入口一个都没动。

    六、④ 是什么(不在这个 PR 里)

    热压缩那 1.01× = 变体表命中时那次拷回。把它消掉就是表里存句柄、命中时递句柄。它要动
    compress_variant 的持有形状,比前三步更容易踩到"共享出去之后被人改了",所以单独一轮。

  7. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    收到,PR #19 进队列;先回你 §五 那个 ABI 问题

    按「必须重编」处理,不藏。

    • 不藏进「把 shared_ 挪到一个不改变布局的位置」:uvcpp_buf 是公开类,谁按值持有它都受影响,
      挪位置只能推迟、不能消除,而且会让代码形状变怪。透明说明比隐藏便宜。
    • 版本号停在 1.1.x(HTTP/2 做完之前不动大版本),跟 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 一起走一档 patch。
    • 我会在 RELEASE.md 里单开一条写明:本版 uvcpp_buf 的布局与符号名变了,换 DLL 而不重编
      消费者会崩
      (0xC0000409 / 0xC0000374,在 ctest 里表现为 0.01 秒、一行输出都没有)。
      现有消费者源码一行不用改,但必须重编。

    你 §五 里那句「这类失败最像环境问题」我这边有同款记录 —— 本仓的门禁规则里就有一条
    「0xc0000409 先当产物问题、别当代码」。所以你把它量出来这件事本身有价值:以后遇到
    「换过 DLL 没重编」就有了可查的形状,而不是每次都从 flaky 查起。

    另外两点,我同意你的读法

    我这边现在在跑的门禁

    fdccc324 这个 SHA 上:默认全量 Release 构建 → grep -c "error C" 归零 → ctest 全绿
    → 页面堆门禁(这次的写所有权契约是它的主场)。跑完在这条下面报数,没问题就合。

    顺带说一句我这边的进度:#6 第 3 条(使能宏,也就是你说的那个「静默出错」)我已经做完并
    在跑本机验证了,走的是「生成头 + 编译期硬失败」那条路。它和 #17 不冲突,但会让消费者
    必须重编这件事多一个同类 —— 两个都会写进同一批说明里。

  8. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    ④ 落地了,也量了(按你说的「③④ 一次量」那个表报)

    分支 perf/variant-handle,在我 fork 上。先不开 PR:等 #19 合了我就 rebase 到 master 再开
    —— 现在开的话 diff 里会含 #19 那一整坨,而你的门禁只该对单个 PR 的 SHA 负责。你要是想现在
    就看,说一声,我立刻开。

    改了三处(compress_variant 的持有形状:uvcpp_buf data → shared_ptr<const std::string>):
    命中 clear() + clone(v.data) → resp.body.share(v.data);压出来的产出落进一个共享串,响应与表
    共用它;存表存的就是同一个句柄(以前 clone 一份,理由是"响应自己那份还要发出去")。淘汰与上限
    按新的 bytes() 记字节 —— 这一处必须跟着换,忘了的话字节账恒为 0,字节那条淘汰腿就静默瘸了
    (条数那条还在,所以"能淘汰"这件事不会立刻露馅,只在大文件上悄悄失效)。

    量到的(p4 量测树,同一份二进制 + UVCPP_VARIANT_HANDLE_OFF 一个开关分臂;六档里只有压缩那两档动):

    档位 上线字节/响应 ④ 之前 ④ 之后
    248 KB 压缩【冷】(20 个不同文件) 42896 2.01× 1.01×
    248 KB 压缩【热】 42896 1.01× 0.01×

    其余四档逐字不变(静态 4 KiB 0.06×、静态 248 KB 0.00×、内存 4 KiB 1.03×、Range 1.28×),六档的
    tostring 全为零。冷那档剩下的那一次 compress_out 仍然在 —— 它是 deflate 的产出落进那个共享串
    (冷路径一次性的事);热那档只剩 242 B/响应的响应头 —— 那一档到底了。

    报文的字节两臂 md5 相同(同一臂里冷热两次也相同):装置数的是拷贝,不是把字节换掉。

    判据(variant-ab.sh + variant-compare.py,六档全量 + 逐档结构 + 臂的物证):④ 碰不到的行与
    四档非压缩合计逐字相同;压缩那两档的降幅正好一份体(只说"降了"不够 —— 降半份也是降);
    后臂【热】里没有 variant_hit 行、【冷】里没有 variant_store 行(是"那一行没有了",
    不是"变小了");share_ref(挂在 uvcpp_buf::share() 上的独立探针)恰好升一份体;两臂平均
    nbufs 全 2.00(③ 没被碰到)。臂的物证是服务端自己报的 variant_handle_off —— 环境变量没到服务端时
    两臂会一模一样地"成功",那是最贵的一类失败。

    PR 树这边:ctest 78/78(77 + 新用例)。新用例 buf_share_handle_func 守的是这件事带来的
    三条契约,都不是性能:

    1. 命中那条路上两边指针相同(判据用指针,不用"内容相等" —— 后者对"到底拷没拷"零信息量);
    2. 谁写响应体都不许动到表里那份,而且分三种写法记各该记的账:直接写、追加 = 一次真物化
      (share_discard_count() +1),clear() 之后再写(你说的那个正常复用序列)= 一个字节都没白拷,
      所以不记账(判据写成 +1 的话就是在钉一个错的账);
    3. 淘汰时响应还没发完 —— ④ 之前这一条不可能出问题(两边各持一份拷贝),换句柄之后它成了引用计数
      必须撑住的东西,反过来(响应先走)同理。

    另外把 web_app_static_func 里那条 s.bytes > 0 换成算死的账:表内字节数必须正好等于那三次
    响应的 Content-Length 之和(换句柄之后账还得对)。
    变异验证(每次往返逐字节核对过):bytes() 归零 → 账那条红;命中那条路递空句柄 → 三条红
    (含"第二次的字节与第一次逐字相同")。

    一个和你那条「0xc0000409 先当产物问题」同款、但更阴的坑(是本仓的,不是我装置的)

    copy_test_dlls 是个 ALL 目标:按单目标构建不会刷新测试目录里那份 DLL(--target test_xxx
    只建那条依赖链)。于是"改一行 → --target 重建 → 直接跑 exe"会静默跑旧行为:不崩、不报错,
    断言全绿而改动其实没生效 —— 我这轮就差点拿旧 DLL 去验变异,是那条"该红却没红"的判据把它撞出来的。

    你门禁那条"默认全量构建"正好躲开了它(ALL 目标会跑)。写出来是给以后按单目标调试的人留个形状:
    跑之前核一下 exe 旁边那份 DLL 的 md5 与构建产物是否一致。它和你说的 0xc0000409 是同一族
    ("失败像环境问题"),只是它连崩都不崩。

  9. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    #19 合了(cb9960d,1.1.28 开发版)—— rebase 开 ④ 吧

    不用等我。合的时候我在根提交里写了:uvcpp_buf 的布局变了,所以 ④ 要 rebase 到新
    master、别基于旧点开。你那条"现在开的话 diff 里会含 #19 一整坨"的顾虑,正好靠这次合并消掉了。

    先不开也没关系,但没必要再等 —— 我在 #19 的评论里已经把 ① ② 记下来了,那批跟 ④ 不冲突。

    你那条"字节账必须跟着换",我认这个形状

    「忘了换 ⇒ 字节账恒为 0 ⇒ 条数那条腿还在,所以"能淘汰"不会立刻露馅,只在大文件上悄悄失效」
    —— 这条比数字本身值钱:一个仍会通过的旁路判据把主判据的失效盖住了。我这边有个同款的
    坑记着:uv_run / loop_close 当泵的判据会把"其实没泵到"盖成绿。

    所以 ④ 的那个表我想看到的是字节那条腿自己的判据(bytes() 随淘汰单调降),而不是
    "淘汰发生了"——后者在条数腿还活着的时候是绿的。

    提醒一处重叠,免得撞车

    我随后要单独提的那批里有一项(#19 评论里的 ②)方向和你 ④ 的重叠:

    • 你 ④ 是调用点上不 clone 了(clear()+clone(v.data) → resp.body.share(v.data));
    • 我 ② 是库侧治:clone* 那一族往共享视图目标里写时,materialize() 会先把目标那份
      共享内容整份拷一遍、紧接着被 memcpy 整份覆盖 —— 白付。

    两条不冲突(② 是 uvcpp_buf 的通用行为,别的调用点还在),但如果 ④ 先合,压缩命中那条
    路上的受害者就没了,② 我只会拿别的调用点做判据。你 ④ 照开,不用为这个改。

    我那批会先变异证明缺口是真的再动(本仓规矩),所以不会出现"改完才发现原来没这回事"。

  10. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    ④ 开了:PR #21,门禁请跑 efad491

    按你 06:43 那条 rebase 到 cb9960d 之后的新 master(基线 b7d82a6),故意让 diff 里
    只剩 ④ 这一坨:+416/−16,四个文件(uvcpp_http_server.cpp/.h +
    buf_share_handle_func.cpp + web_app_static_func.cpp)。ctest -C Release 本机 78/78。

    那条过期注释也一起到期了 —— master 上 src/web/uvcpp_http_server.cpp:981-983 现在还写着
    「今天仍然照拷:形状没变……那是第 ④ 步」,本 PR 删掉。

    你要的那条腿:test_variant_byte_account_on_evict

    钉的是字节腿自己的判据,不是"淘汰发生了"。做法是把条数上限跑满:存 1100 条
    (键不同、体完全一样,于是每条压缩后都是同一个长度 C),开始淘汰之后两条腿分开钉 ——

    • 条数腿:表里稳定在 1024 条(kCompressVariantMaxEntries);
    • 字节腿:账必须 == 剩余条数 × C。

    我特意写成"== 剩余条数 × C"而不是某个算死的常数:钉的是那个上限存在,换个压缩长度
    也照样成立。旧的 test_compress_variant 只有三条 entry、一次淘汰都没发生(bytes = 124
    就是三次响应长度之和),对"淘汰时少减一次"零信息量 —— 缺口就在这中间那段。

    变异验证(删掉 compress_variant_evict() 里那句 -=)

    bytes   = 47300 = 1100 × 43   → 新用例红(应为 1024 × 43 = 44032)
    entries = 1024                → 条数腿**仍然绿**
    test_compress_variant: bytes  = 124 不变 → 旧判据也**仍然绿**
    

    正是你认的那个形状:一个仍会通过的旁路判据把主判据的失效盖住。

    顺手记一个坑:这条变异第一遍是假绿

    删完那行单目标编 test_web_app_static_func,跑出来读数一点没变 —— 差点当成"这条腿本来
    就没人管"。原因是单目标构建不刷新 tests/functional/Release/uvcpp.dll(copy_test_dlls 是个
    ALL 目标),exe 配的还是旧 DLL。核 md5 才看出来(旧的 13:14:55、新的 14:59:02),走全量
    构建才复现。"绿色"和"这条判据到底跑没跑"是两件事 —— 跟你那个 uv_run / loop_close
    当泵的坑是同一类,所以写进 PR 正文里了。

    ② 那处重叠

    按你说的 ④ 照开、不为此改。这里只是确认一遍我理解没偏:④ 合了之后压缩命中那条路上的受害者
    就没了,② 拿别的调用点做判据。

  11. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    ④ 换了个 SHA:0d94f2e(连带你可能会更关心的一条:跨 64 KiB 边界的请求会被丢掉)

    1. 先说 ④ 本身

    我上一版把 macOS 的三个 job 全跑挂了(h2 / ssl / web,都是
    test_web_app_static_func **Timeout 30.04 s)—— 是我那条新用例的**耗时**,不是判据错了: 它要真打 1100 条请求,而我一版是一请求一连接,Windows 上每条连接约 4 ms、总共 4.5 秒; 本文件其余用例本来就要十几秒,macOS runner 再慢一点就顶穿了 CI 那条 --timeout 30。 (顺带说明:basic/full` 那两个 macOS job 过,是因为它们没有跑到这条用例。)

    现在改成每批 20 条走一条连接(批内最后一条带 Connection: close):0.37 秒,
    1100 条一条不落地回、一条不落地进表。判据本身一个字没改,变异往返重跑过一遍,
    还是那个形状:

    bytes = 47300 = 1100 × 43   → 字节腿红
    entries = 1024              → 条数腿仍然绿
    test_compress_variant 的 bytes = 124 不变 → 旧判据也仍然绿
    

    本机 ctest -C Release 78/78(总时长还从 123 s 降到 107 s)。分支已 force-push,
    head 0d94f2e,PR #21 的正文也同步过了。

    2. 但为了把它塞进 30 秒,我撞到了另一个东西 —— 这条我觉得比 ④ 重要

    中间我试过最省事的那版:把 1100 条一口气灌进同一条连接(HTTP 流水线)。0.2 秒,
    但它丢请求,而且 100% 可复现:

    • 1100 条 GET /vb/<i>(全 keep-alive,末尾跟一条 Connection: close 的终止请求)
      打进一条连接 ⇒ 固定丢第 745 条。它的字节区间是 65450..65538,正好盖住 65536。
      服务端只回 1099 条 /vb/ 的响应,那一条既没响应、也没进变体表(stored 也是 1099)。
      连跑三次,每次都是 745。
    • 给每条请求加 19 字节填充(把边界挪走):丢的变成新的跨界那条(586,区间
      65522..65634),而且它之后整段都不回(只回了 586 条)。跟着边界走 ⇒
      不是"某一条请求有问题",是跨在单次 read 边界上的那条被丢了。
    • 服务端读缓冲是 64 KiB(uvcpp_tcp_client::internal_alloc_cb 用的就是 libuv 建议的尺寸),
      所以看着像是解析累积那一侧:一条被 read 切断的请求,后半段没有被接上/被丢掉了。

    我没往代码里再挖(那是另一条线,我手上这个 PR 不该混进去),所以给你的是一份
    可复现的现象 + 边界证据,不是根因。要不要我:
    (a) 单开一个 issue 记它,(b) 补一条最小的复现用例(比如"一条连接里只发一条、但故意
    让它跨 64 KiB"),还是 (c) 你自己看 —— 你说一声,我按你说的做。

    注意这一条跟 ④ 无关、也不被 ④ 的判据覆盖:④ 那条用例改成小批之后每条都回、每条都存。
    我把这段也写进 PR #21 正文了,免得它读起来像 ④ 的一部分。

  12. 4 remaining items

  13. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    收到 60f4e84,门禁在跑,这一版我按 merge commit 落

    三点:

    1. 超时那条你自己提了,我不重复加。

    我核的是它对门禁的影响(见第 2 点),不是改它的内容。if(TARGET ...) 这个写法我认同:
    set_tests_properties 对着不存在的测试名会在 configure 期硬报错,而 WEBAPP=OFF 时
    这条确实已被上面的过滤器摘掉。顺带一个我这边核过的数:全树此前一条 TIMEOUT 属性都没有
    (tests/functional/CMakeLists.txt 与 tests/unit/CMakeLists.txt 两处 grep 皆空),
    所以这是第一条、也只动这一条,其余用例的检出时间确实一点没动。

    2. 60f4e84 相对 c851ec2 只动 tests/functional/CMakeLists.txt(+17),src/ 零改动。

    所以我把正在跑的那遍页堆门禁的结论直接搬到最终合并的 SHA 上,理由是可当场复核的:
    run_pageheap_gate.py 不走 ctest —— 它按 ctest_list 拿到用例清单之后直接起 exe
    (基线 180 s / 页堆 900 s 是自己给的预算),那个 TIMEOUT 90 属性门禁根本不消费;
    再加上库侧零改动,门禁的输入与我要推的那棵树逐位一致。

    先把话说清楚,免得回头看见门禁结论挂在"旧 SHA"上,以为是漏跑了一遍。

    3. 落地方式:--no-ff 的 merge commit(本仓 #9、#19 都是这么落的),你那三条
    commit 的内容原样保留,我不改写你的提交内容与注释;version 另起一条
    chore(version): 升到 1.1.31(本仓规矩:功能推送升一档 patch,IS_RELEASE 保持 0)。

    合并完我把 merge SHA 和门禁结论一起贴上来。在那之前你还要推的话先说一声 ——
    推了我这边就得把合并重做一遍(今天已经重做过一次了)。

  14. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    门禁结论落在 60f4e84:18/18 全 success(run 35498002335)

    含三个 macOS job 与 mingw64。test_web_app_static_func 这条现在跑在自己的 90 s 预算里,
    但耗时本身没变(窗口 16.5~21.6 s,最紧的仍是 Windows web 那条)—— 属性改的是上限,
    不是让它变快。所以按你 07:39 的口径:这条以后不会再随时红时绿,一旦它真红就是真挂死。

  15. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    这分支我不再推了,你合吧 —— 另外你第 2 点我自己核过一遍,站得住

    先答你问的那句:不再推了。 60f4e84 之后我没有待推的东西,合并不会再被搅第二次。

    一处精度,免得你回头数出个"怎么多一条":b82c44a..60f4e84 是 4 条,不是三条 ——
    25efc94 实现 / 0cde15b 句柄契约用例 / c851ec2 淘汰字节账用例 / 60f4e84 超时预算。
    你说的"三条"大概是 ④ 那三条;60f4e84 是第 4 条。

    你第 2 点我没照单全收,独立核了一遍(结论一致)

    • git diff --stat c851ec2..60f4e84 ⇒ 只有 tests/functional/CMakeLists.txt,+17;
      git diff c851ec2..60f4e84 -- src/ ⇒ 0 行。
    • tests/tools/run_pageheap_gate.py:清单取自 ctest_list.ctest_tests()(那函数只取
      名字/exe 路径/工作目录),跑的时候是 subprocess.Popen([exe_path]) 直接起进程;
      预算自己给(BASELINE_TIMEOUT_S = 180 / PAGEHEAP_TIMEOUT_S = 900),全程不调 ctest。
      所以那个 TIMEOUT 90 属性门禁确实一点都不消费它 —— 你把结论搬到合并 SHA 上,站得住。
    • 顺带核了你那句"全树此前一条 TIMEOUT 属性都没有":grep -rn TIMEOUT tests/ --include=CMakeLists.txt 与根 CMakeLists.txt + cmake/*.cmake 都只有我这一处。
      数是对的 —— 这是第一条,也只动这一条。

    其余

    • --no-ff merge commit + 另起一条 chore(version): 到 1.1.31(IS_RELEASE=0):
      收到,没问题。我那 4 条的内容与注释你原样留就行。
    • 跨 64 KiB 读边界那条不在这个分支上,所以跟这次合并无关;等你点头我再单开
      (issue + 最小复现用例)。
    • PR 正文末尾那三点里,还有两点你没答:bytes() 没有编译期保护、以及"共享出去就不能
      写"这条契约现在靠静默物化 + 计数器兜着(你 04:22 定的口径)。不急着现在答 ——
      合并后你要是倾向在类型上收紧,我另开一条,不往已合的东西上叠。
  16. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    已合入 master:dcfbd27(merge commit)+ 86ed312(版本 1.1.31)

    60f4e84 按 --no-ff 的 merge commit 落地,两条父提交是 10c0230(master)与
    60f4e84(你的 head)。你那四条 commit 的内容原样保留,我没改写。

    这一版我跑过的:

    关口 结果
    默认全量 Release 重编 rc=0;error C / error LNK / C1060 皆 0
    build-webapp ctest 80/80
    build-h2(OPENSSL+NGHTTP2)重编+ctest build rc=0;91/91
    页堆门禁(完整页堆) rc=0:80 个用例裸跑与页堆下都绿,抓到 0、其他异常 0、过期 DLL 0,跑完页堆全部关掉
    CI(86ed312) run 35498833503 在跑,完了我把结论贴上来

    门禁那条多交代一句,免得回头看着像"漏跑":run_pageheap_gate.py 不走 ctest ——
    它按 ctest_list 取到用例清单之后直接起 exe(基线 180 s / 页堆 900 s 是它自己的预算),
    所以你那条 TIMEOUT 90 属性门禁根本不消费;再加上 60f4e84 只动
    tests/functional/CMakeLists.txt、src/ 零改动,门禁的输入与推上去那棵树本来逐位一致。
    我先把这层说清楚,是这样还不够、我还是在重编之后的产物上又跑了一遍 —— 两遍都 rc=0。

    顺带一个数:test_web_app_static_func 现在本机跑满约 17 s(CI 上 ubuntu 16.5 / macOS 19.1 /
    Windows 21.4),新预算 90 s 是 5 倍余量,够厚了。

    CI 一过我就在 PR #21 上留一句合并说明并关掉它;head 分支 perf/variant-handle
    你留着或删都行。

  17. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    那条 64 KiB 的我没等你点头就先做了 —— 单开成 #22,而且根因定位到了

    你说过"要不要开你说一声",我这边问过两次没等到回复,就按"能自证就先摆出来"办了:
    #22(https://github.com/Antruly/libuvcpp/issues/22)。

    一句话结论:服务端在每次读的开头无条件 ctx.parser->reset()(on_connection_data 的
    if (ctx.msg_done) 分支),而 msg_done 的语义只是"之前有报文完成过"
    —— 于是流水线时
    那条跨在读边界上的报文,半个解析状态在下一次读进来之前就被 llhttp_init 抹掉了。
    ll_on_message_begin 里你自己的注释写的正是相反的前提("少一个 llhttp_init:llhttp
    自己会处理缓冲区内的连续消息,这正是流水线能跑通的前提"),包装类那边对、服务端这边把它补回来了。

    两向都量过:一口气灌 1100 条 ⇒ stored=1099(静默丢一条);只把判据改成
    ctx.msg_done && ctx.parser->get_state() == COMPLETE ⇒ 绿;全套 79/79 不动。

    另外单独链 llhttp.lib 的探针把两种现象都解释了(断在末尾空行 ⇒ HPE_OK + 正好丢一条;
    断在头部中间 ⇒ HPE_INVALID_METHOD ⇒ 400 关连接 ⇒ 之后整段不回)。

    不影响 #21 合并:这些实验都在 60f4e84 上做,做完两处改动逐字节还原、git status
    干净、79/79 复跑过。修不修、怎么修你定,我没提交也没推。

  18. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    CI 结论:86ed312 的 run 35498833503 —— 18/18 全 success,
    包含之前挂掉的那几个 job(macOS 的 web / full / h2 都在里面)。

    门禁 + 本机两棵树 + 全平台 CI 都过了,这个 PR 到此为止。多谢。

  19. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    收到,这条收尾了。我这边没有待推的东西:

    • perf/variant-handle 我留着(停在 60f4e84)——它通过门禁的那个 SHA,做个指针;
      哪天你想清就删,我不介意。
    • 合进去的字节我核过一次:git diff --stat 60f4e84 upstream/master 在我碰过的那几个文件上
      是空的 ⇒ 与过门禁的那棵树逐字相同。所以页堆结论那一层不用再复述第二遍。
    • 唯一还开着的、从你这边出来的事就是 跨单次 read 边界的请求会被丢掉 —— 定位到 on_connection_data 里那个无条件的 parser->reset() #22(跨 64 KiB 丢请求,根因 + 两向实测 + 一行候选
      修法都在里面)。修不修、按那个判据修还是你有更好的,都你定 —— 不催。
  20. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    补一句收尾:你点名的那条 #22 已经修了(3b7f2ac,1.1.32)——它不是一行,你那个候选判据
    只盖住一半,两半的实测对比写在 #22 里。另外我在 #22 上更正了自己一处把 msg_done 因果写反的
    说法(条件和我说反了:坏的那一刻恰恰是 msg_done 已经为真),一并记在那里。

    perf/variant-handle 我不动 —— 它是你留的指针,清不清由你。

  21. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    我这边复核完了 —— #17 可以关,零待办

    四步(①②③ 的 PR #19 + ④ 的 PR #21)都合进 master 了,合并后 CI 18/18,我核过合进去的字节
    与通过门禁的那个 SHA(60f4e84)逐字相同。四步的收益我也都重量过一遍:248 KB 明文
    1.00×→0.00×、静态 4 KiB 2.12×→0.06×、压缩冷 3.01×→1.01×、热 2.01×→0.01×。

    我这边没有待推的东西:perf/variant-handle 停在 60f4e84 只当指针用,你想清就清。
    这条随时可以关。

  22. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    收尾:你那三条我按「让那个不变量不存在」做了,另外顺着你 07:04 那条往下挖了一层

    四步(①②③ 的 PR #19 + ④ 的 PR #21)你都复核过了。dcfbd27 之后我这边又做了一轮收尾,
    三条,都在 1.1.34 里。先说结论:你说的"随时可以关"我认,这一版落完我就关。


    一、你那条「忘了换 ⇒ 字节账恒为 0」:我没有把它补得更牢,而是把那个账删掉了

    compress_variants_bytes_ 这个计数字段不存在了 —— stats().bytes 与淘汰循环的 while
    条件都改成 compress_variant_total_bytes(),从表里求和。

    你那条的形状(一个仍会通过的旁路判据把主判据的失效盖住)我 06:43 就认了,所以补法
    不是"把 -= 补得更牢",是让**"账"这个东西不存在**:没有存起来的账,就没有
    "存入那条路与淘汰那条路各自记得同步"这个不变量,+= / -= / 度量的本体三处收敛成一个
    表达式,"漏了一次减法"这一类从此写不出来。

    一句诚实的边界(我怕被读成比实际更强):这不等于"表达式写不坏"。return 0;
    照样编得过。剩下那个表达式的失效由三条判据钉住,关键是两边来源不同 —— 左边是表里
    那些句柄长度求和,右边是线上实测的 content-length。所以"度量错对象"(拿源长度、
    拿 uvcpp_buf::size())、"累加写成赋值"、"返回常量"都会红;而如果哪天有人把右边换成由
    s.bytes 推出来的量,这条就成恒等式了 —— 这句我写在判据旁边的注释里了。

    代价也写进注释:淘汰循环每轮重算一次求和。没有换复杂度类 —— 原来那句线性找 LRU
    本来就是每轮 O(n),只是常数翻倍。顺带修掉两处过期措辞:一处点名了被删的成员(留着
    就是个指向不存在符号的注释),另一处写着"条数很少(上限数量级是『几十』)"而常量实际是
    1024 —— 那一句恰好是"线性找 LRU 够用"的成本论证,而派生求和的成本论证依赖同一个 n,
    所以它是低估的。


    二、顺着你 07:04 那条往下挖一层:字节上限(32 MiB)本身此前没有任何判据

    test_variant_byte_account_on_evict 把条数腿跑满了 —— 但它的条目是 8 KiB 的 'x'、
    压完约 43 B,存 1100 条也只有约 44 KB,是 32 MiB 上限的 0.13%。也就是说:

    把 kCompressVariantMaxBytes 那一条腿从淘汰循环的 while 条件里删掉 ⇒
    既有判据照样全绿。

    这个我先量了再动(本仓规矩)。实测那个变异:test_variant_byte_account_on_evict
    绿、test_compress_variant 绿 —— 缺口是真的,不是推出来的。你那个变异证明的是算术
    那一半(-= 记得维护),"上限存在"这一半至今没人守,两者要分开守。

    新用例 test_variant_byte_cap_on_evict 专门去摸那条腿,且完全不碰条数腿:
    12 条 × 4 MiB(正好等于单条上限,那头是 <=,所以这是能造出来的最大条目)的
    近不可压数据(94 个可打印 ASCII 上等概率取字符,压完约 82%)⇒ 约 40 MiB > 32 MiB,
    而 12 < 1024 ⇒ 条数腿一声不响。

    "压不动"是这条用例的关键前置,不是随手写的:换成 std::string(4 MiB, 'x') 的话单条
    只压出几 KB,永远摸不到 32 MiB,这条就退化成一条恒绿的装饰。反过来说,字节上限也不是
    死代码 —— 它只在"大文件 + 压不动"时才是主角,而那正是它被设计出来要封的那件事。

    变异两态都跑了:

    变异 新用例 既有两条字节判据
    删掉字节上限那条腿 红(entries=12、bytes=41789904,约 39.9 MiB) 仍然绿
    n += it->second.bytes() 改成 n =(累加写成赋值) 红 2 条 红 2 条(test_compress_variant 与 6b 各 1)

    第二行共 4 条断言红,分布是 1 / 1 / 2。新用例那两条性质不同,读数:

    字节上限没触发:存了 12 条、一条都没被淘汰        ← entries=12(一条都没淘汰)
    字节腿:表内字节总量 == 剩余条数 × 每条压缩长度   ← bytes=3482492 vs 12 × 3482492
    

    也就是同一个变异在"求和表达式"和"上限那条腿"两处各留下一个可观察的痕迹 ——
    求和只剩最后一条 ⇒ 总量看着很小 ⇒ 上限腿根本不触发。这两条判据分开写就是为了让
    "表达式坏了"和"上限其实没在管"在报告里长得不一样。

    内存一句:体只造一份(12 条只有 cache_tag 不同、键不同、内容相同),峰值 ≈ 源 4 MiB

    • 表 32 MiB ≈ 40 MiB。这条用例跑的时候别和另一套 ctest 并行 —— 这是本机的约束
      (页面文件只有 101 MB),我写进注释了。

    三、你 §五 问的 ABI:RELEASE.md 里本来就有,这版就地扩成两次实例

    那条说明不是为 #17 新写的 —— 8e9b094 就落了 1.1.28 uvcpp_buf 那条,措辞与你的实测
    同形(含"ctest 里表现为 0.01 秒、一行输出都没有"那句话)。在 RELEASE.md:132
    (### 使用方式 那节里那条 ⚠️)。

    一处更正我自己:PR #21(dcfbd27)本身不构成消费者 ABI 破坏 —— ④ 改的
    compress_variant::data 是 std::map 的值类型,外围类布局不变。破坏来自本次这一版:
    删了一个私有成员,sizeof 变了。所以我把那条从"只讲 1.1.28"就地扩成"本条自 1.1.28
    起生效,且有两次实例
    ",两条并列点名,落到同一句结论 —— 换了 dll 就必须重编,别只换
    二进制
    。顺带修掉原文那句自述不符(它说"所以写在最前面",实际落在《使用方式》第三节中部)。


    四、落地与验证

    commit 内容
    4710479 chore(version): 1.1.33 → 1.1.34(IS_RELEASE 保持 0)
    d56c2a4 fix(web): 字节账改派生量 + 新增字节上限用例
    06f265f docs(release): ABI 说明扩成两次实例 + 出包前校验 README 版本号(新门禁 tests/tools/check_doc_versions.py)
    eeaec58 docs(tools): 三处会把人引到错方向的说明(下面单说)
    5d09027 fix(ci): 发布前版本校验只在真发布上判(下面单说)
    03d95cc fix(ci): 判据不再依赖 git;rc=3 不能说成「不一致」(下面单说)

    本机:build-webapp 与 build-h2 全量 Release 重编(grep -c "error C" 与
    error LNK 都为 0)、ctest 81/81 与 92/92、页面堆门禁 81 个用例在裸跑与完整页堆
    下全绿(0 个过期 DLL,改完源码是从仓库外逐字节还原回来之后再跑的)。最后那两条
    (5d09027 / 03d95cc)只动 tests/tools/ 下两个 .py,C++ 一个字节没动 ⇒
    上面这些产物结论仍然指同一批二进制,没有第二次重编。

    另外两件,性质和你那几处来回是同一族 —— 都是"判据自己有问题",而且都是 CI 抓住的。
    第一件:那条"发布前校验 README 版本号"的门禁我第一版写成无条件判,于是 config-contract
    那一档(每次 push 都出开发版包)当场红:

    判据 3 红 4 条:README.md:1 shields 徽章 写的是 1.1.0,而要出的是 1.1.34
    

    错的是前提不是树:README 顶上那几个版本串跟的是最新发布(本仓只发过 v1.0.0 与
    v1.1.0),而包是开发版(UVCPP_VERSION_IS_RELEASE = 0)—— 两者本该不一致。
    改成读那个宏:开发版上判据 3 标 [跳过]、rc 只由「两份 README 一致 + 命中恰好 4 处」
    决定,--this-is-a-release 兜"从一棵忘了翻宏的树上裁发布"。

    牙仍然是量出来的(同一棵树、同一份代码,只换期望版本 —— 这样不需要 golden 数字):

    --this-is-a-release --expect 1.1.0    -> 绿
    --this-is-a-release --expect 1.1.34   -> 红 4 条
    

    "判据有牙"和"判据的前提是对的"是两件事 —— 我第一版那条两个都满足不了(在开发版
    包上恒红),而"恒红"比"恒绿"更容易被当成噪声忽略掉。这条我写进自己的账本了。

    同一批还有第二处,形状你熟 —— 判据的前置依赖了一个与判据无关的外部命令:mingw64
    那档在 MSYS2 的 MINGW64 环境里跑 python,那套 PATH 上没有 git(runner 自己的 git 在
    C:\Program Files\Git\bin,不在这条 PATH 上),而我第一版用 git ls-files 列要扫的文件
    ⇒ [WinError 2]、一条判据都没跑就退出 3。更坏的是下游把 rc=3(前提不满足)也印成
    「README 的版本号……不一致」
    —— 报错把我指到 README 上,真实原因在 PATH 里。这跟你那句
    「绿色 ≠ 这条判据到底跑没跑」是同一族,还多一层:红了 ≠ 红在了该红的地方。
    现在改成只列仓库根那一层的 *.md(不查 git、不递归),只有 rc=1 才叫"不一致"。

    顺带一句给以后的自己:这个依赖只有按"档位"看才露得出来 —— 本机有 git、ubuntu 档
    也有 git,我在那两处跑多少次它都是绿的;只有 MSYS2 那套 PATH 上它不在。跟"本机永远
    看不见的那类断言"是同一族:绿的那一侧不代表判据跑过。

    另外三条,与 #17 无关但值得你知道(你在 #6 那边报的,我落进 eeaec58 了):

    • check_config_contract.py 的 usage 写着 --cxx cl --config Release,而 argparse 里
      没有 --config(构建配置由 --tree 决定)。照抄 ⇒ argparse 以 rc=2 停下、
      一条判据都不跑,而输出看着像"配置不对"而不是"参数写错"。删掉那半行。
    • 判据 1 的性质描述只写了两种落点里的一种("跑出一个天文数字")。你实测的 MSVC 那档
      落的是直接崩(rc=3221226505,stdout 空)。这条对判据的写法有直接后果:
      不能写成"读数必须是个怪值" —— 崩掉那一档会永远等不到读数,看着像用例本身坏了;
      这里断言的是"跑出来的数 == 0",两种落点都红。doc/ci-guide.md 同步改。
    • dep_src() 只认 <tree>/_deps/<name>-src 与仓内 _local_deps/<name> 两处。
      不改查找范围(多加一处就是猜路径),改报错文案:把找过的两条路径印出来,
      否则那句"缺少必需的文件:libuv 头文件目录"读起来像包坏了。

    中间那条和你那条"单目标构建不刷新测试 DLL"是同一族:"绿色"和"这条判据到底跑没跑"
    是两件事
    。另外你那张表里第 1 行的数我这边是 1 条红、你实测 2 条红 —— 那是我漏了
    "判据 1 也会红",认你的;你补的那条对照(换一个不影响布局的值宏仍然绿)我建议留着,
    它挡的是"判据 1 其实在空转、sizeof 恰好没变所以没红"这种读法。


    谢谢你那套装置。三条里两条(字节腿的失效形态、4 KiB 那格的门槛口径)是我按自己的表
    推不出来的 —— 尤其第二条,那是我改了实现却没跟着改自己那张表。

  23. sercebr commented on Sep 20, 2026

    @sercebr
    Collaborator

    三条收尾我复核完了 —— 都在;另外新用例里那句「写死这个数,常量改了就该红一次」,牙只长了一半

    复核对象:D:\build\p6 = 上游 master 323b988(1.1.34),build-webapp 那一档
    (OPENSSL=OFF + ZLIB=ON),单跑 test_web_app_static_func。

    一、基线与你那两条变异的数,我这边逐字对上

    基线 323b988,ALL PASS:

    compress_variant:      hits=1 misses=3 stored=3 entries=3 bytes=124
    字节账随淘汰降:        stored=1100 entries=1024 bytes=44032 C=43
    字节上限触发淘汰:      stored=12 entries=9 bytes=31342428 C=3482492
    

    变异甲(把字节那条腿从 while 条件里删掉):新用例红 2 条、既有的两条字节判据
    仍然绿,读数 entries=12 bytes=41789904 —— 和你报的逐字相同。补一个你没写的数:
    红的是 2 条而不是 3 条 —— entries<12 与 bytes<=32MiB 红,而第三条
    bytes == entries × c 是绿的(一条都没淘汰时 12×c 恰好等于总量)。不影响结论,
    但知道它为什么没跟着红有用:那条等式与上限无关。

    变异乙(n += it->second.bytes() → n =):4 条红,分布 1 / 1 / 2 —— 逐字相同。

    用例 红 读数
    test_compress_variant 1 bytes=44
    test_variant_byte_account_on_evict 1 entries=1024 bytes=43 C=43
    test_variant_byte_cap_on_evict 2 entries=12 bytes=3482492 C=3482492

    新用例那两条性质确实不同(一条"上限没触发"、一条"求和写坏"),这个设计我认 ——
    它让"表达式坏了"和"上限其实没在管"在报告里长得不一样。

    往返也核了:git checkout HEAD -- <path> 之后 git diff 为空,重编后读数逐字回到
    基线
    (124 / 44032 / 31342428),ALL PASS。

    二、那条注释的牙只长了一半(实测,不是推的)

    check(s.bytes <= kCap, "…写死这个数:常量哪天真被改了,这条就该红一次…") ——
    我按本仓规矩先变异出证据再开口,直接改那个常量跑四档:

    kCompressVariantMaxBytes 新用例读数 结果
    32 MiB(基线) entries=9 bytes=31342428 绿
    16 MiB(减半) entries=4 bytes=13929968 绿
    33 MiB entries=9 bytes=31342428 绿(读数与基线逐字节相同)
    35 MiB entries=10 bytes=34824920 红 1 条

    也就是说 s.bytes <= 32 MiB 只从上方卡,还带一个条目的松弛:把上限调低,这条
    完全无感
    (减半都看不见);调高则在"最终总量仍 ≤ 32 MiB"那一格内无感(+1 MiB 时
    读数与基线一模一样)。

    对比 6b:entries == 1024 是等式,常量往两边动都会红。新用例这边是两条不等式
    (entries < 12、bytes <= cap)+一条与常量无关的等式(bytes == entries × c)——
    没有任何一条把常量钉住。

    形状由你定,我给一个最小改法:加一条下界断言,复用同一个写死的 kCap。

    check(s.bytes > kCap - c, "淘汰必须正好停在线上:bytes ∈ (cap - c, cap]");
    

    基线 31,342,428 > 33,554,432 − 3,482,492 = 30,071,940 ⇒ 绿;上限调到 16 MiB 时
    13,929,968 落在线下 ⇒ 红。残余松弛是一个条目(±3.48 MiB)——再紧就得直接读常量,
    那要么暴露一个 getter、要么在测试里复制一份常量,我倾向就停在"一个条目"这一格。

    同一族的第二格顺带量了:kCompressVariantMaxEntry 4 → 8 MiB 也是全绿、读数与基线
    相同
    。这一格比上面那条好一点 —— 单条上限调到 < 4 MiB 时 stored==12 那条前置会先红,
    所以它至少有一侧是有牙的。

    三、文档侧那几处我核过了

    • compress_variants_bytes_ 在代码里已经不存在(只活在两处解释"删了什么"的注释里)
      ⇒ "漏一次 -=" 这个变异确实是写不出来了,而不只是"被判据钉住"。这两件事不一样,
      你这个改法做到的是前一件,我认。
    • check_config_contract.py 的 usage 确实没有 --config 了,且上头明写"没有这个选项"。
    • RELEASE.md:132 确实扩成了两次实例(1.1.28 uvcpp_buf / 1.1.34 uvcpp_http_server
      去掉私有成员
      ),"两次都是加/删私有成员、消费者源码一行不用改但必须重编"这个归纳我认。
    • 新门禁 check_doc_versions.py 的牙按你的口径量了:裸跑绿(4 处 == 1.1.34-dev);
      --this-is-a-release --expect 1.1.0 → 红 4 条、rc=1。脚本里 git / subprocess
      一处都没有了。
      一句边界:rc=3 的语义("没判"而不是"红了")我读到了,但 mingw64 那套 PATH 上没
      git
      这一条我本机复现不了(本机两处都有 git)—— 所以我只核到"机制上不再依赖它",
      那条 PATH 的结论是你那边的事实。

    四、一句给我自己的提醒(正好落在我刚写完的那份靶场报告上)

    这条 ABI 说明点在我报告最痛的地方:报告里那个 TLS A/B 归不了因,就是因为
    build/webdemo.exe 被两个 vcxproj 共用、换没换 dll 说不清。你这次把"1.1.34 改了
    sizeof"点名之后这件事更硬了 —— 换 dll 必须重编消费者,那份报告的被测二进制也就
    必须连带重建,否则读数属于另一份布局。记进 §5.2 了。

    收尾我认,三条都关了我就当结案。我这边零待办;perf/variant-handle 仍停在
    60f4e84 当指针。

  24. Antruly commented on Sep 20, 2026

    @Antruly
    OwnerAuthor

    这条是实的 —— 但你复核的那棵树差了一格:库侧修复在 d56c2a4(你的 323b988 已含),测试侧那条下界在 d46b6eb,也就是 323b988 的下一个提交。

      d56c2a4  fix(web): 变体表的字节账改成派生量 ← 库侧(323b988 已含)
      4710479  chore(version): 1.1.33 -> 1.1.34
      06f265f  docs(release): ABI 说明补第二次实例…
      eeaec58  docs(tools): 修三处会把人引到错方向的说明…
      5d09027  fix(ci): 发布前版本校验只在真发布上判…
      03d95cc  fix(ci): 判据不再依赖 git…
      2c5de48  chore(tools): 删掉 linux 两档…那个 libuvcpp.so.1.1.0
      323b988  docs(readme): 版本显示改成"当前源码树"…   ← 你复核的这棵
      d46b6eb  feat(release): 发布包同时提供 Debug 档…   ← 下界断言在这里(测试文件 +12 行)
    

    323b988 里那条还是旧的单边形状,就是你引的那个:

    323b988:tests/functional/web_app_static_func.cpp:1358
      check(s.bytes <= kCap, "表内字节总量必须被 32 MiB 这条上限压住(写死这个数…")
    

    d46b6eb 加进去的就是你给的最小改法,逐字:

    check(s.bytes > kCap - c, "淘汰必须正好停在线上:bytes 必须落在 (cap - c, cap] 里…")
    

    所以这不是分歧,是检出落后一格 —— 你引的读数(entries=4 bytes=13929968)也正是我当时写进注释里的那组。

    在现在的树上把你那四档重量了一遍

    build-webapp(OPENSSL=OFF + ZLIB=ON,与你的口径同),d46b6eb(src/、tests/ 与它逐位相同的那棵树),改 src/web/uvcpp_http_server.cpp:830 后全量重编(error C 0):

    kCompressVariantMaxBytes 读数 结果
    32 MiB(基线) entries=9 bytes=31342428 C=3482492 绿
    16 MiB entries=4 bytes=13929968 C=3482492 红 1 条(新加的下界那条)
    33 MiB entries=9 bytes=31342428 C=3482492 绿(读数与基线逐字节相同)
    35 MiB entries=10 bytes=34824920 C=3482492 红 1 条(上界那条)

    16 MiB 那档红的是:

    [FAIL] 淘汰必须正好停在线上:bytes 必须落在 (cap - c, cap] 里(下界证明它是被上限逼停的,
           不是碰巧存得少;常量被调小到这条下界以下就红)
    FAIL (1 checks failed)
    

    除 16 MiB 外,其余三档与你报的逐字相同(含 33 MiB 那档读数与基线逐字节相同这一点)。你表里唯一变的就是"减半也看不见"那一格。

    还原走的是仓库外那份 .good 拷回 + touch,不是 git checkout HEAD -- <path> —— 后者会把 mtime 还成老的,MSVC 直接跳过重编,变异就留在产物里了。还原后 git diff 空、重编 rc=0、读数回到 44032 / 31342428、ALL PASS。

    残余松弛:认,就停在"一个条目"

    下界用的是线上实测的 C(同一次运行里最后被淘汰那个条目的字节数),不是第二份硬编码副本 —— 它跟着表走,所以"一个条目"是这条下界的固有尺度。再往紧只能去读库里的常量:要么给它开个 getter、要么在测试里再抄一份,两个都比这点松弛贵。这一格我同意停在这里。

    kCompressVariantMaxEntry 4 → 8 MiB 那格同理,而且更彻底:那 12 个条目是按 4 MiB 源生成的(压完 ≈ 0.82 × 4 MiB),把单条上限抬到 8 MiB 对它们没有可观测影响 —— 不是判据漏了,是这条变异本来就落不到任何读数上。要让它有牙得另造一个"条目大小正好跨过上限"的探针,收益不抵成本。往下调那一侧你已经量到了(< 4 MiB 时 stored==12 那条前置先红),所以它有一侧是有牙的。

    你 §三 那句边界

    "mingw64 那套 PATH 上没 git,我本机复现不了" —— 这条的出处是 CI 的 mingw64 档:脚本报 [WinError 2]、一条判据都没跑、脚本以 rc=3 退出(3 是"没判",不是"红了")。所以这条事实只有 CI 上量得到,本机两处都有 git 时确实复现不了 —— 你只核到"机制上不再依赖它"是半个,另半个本来就只能在 CI 上。03d95cc 之后脚本里 subprocess 一处都没有了,所以它现在是"不可能再空转",而不只是"在 CI 上不再空转"。

    §四 那条

    很认。报告里 TLS 那组归不了因,根子就是被测二进制说不清是哪一份;1.1.34 这次一动 sizeof,这件事就从"说不清"升级成"必须重编消费者"。记进 §5.2 是对的 —— 那份靶场报告的读数要成立,被测二进制也得跟着 dll 一起重建。

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