From 25efc94fc93d0151e3be4aa2d145cc9ca70ee08a Mon Sep 17 00:00:00 2001 From: sercebr Date: Sun, 20 Sep 2026 13:17:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf(web):=20=E5=8E=8B=E7=BC=A9=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E8=A1=A8=E6=8C=89=E5=8F=A5=E6=9F=84=E5=AD=98/?= =?UTF-8?q?=E5=8F=96=EF=BC=88=E5=AD=98=E8=A1=A8=E4=B8=8E=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=90=84=E5=B0=91=E4=B8=80=E6=AC=A1=E6=95=B4=E4=BB=BD=E4=BD=93?= =?UTF-8?q?=E6=8B=B7=E8=B4=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #17 第 ④ 步。表里原来自己持有一份压缩产物的拷贝(`uvcpp_buf data`), 于是热路径每次命中都要再拷一份回来。换成 `shared_ptr` 之后,压出来的那份字节**只存在一份**:存表时存的是同一个句柄,命中时把 句柄递出去 —— 两边都不拷。 - 命中:`clear()` + `clone(v.data)` → `resp.body.share(v.data)` (`share()` 顺手把静态层借来的那份放掉,不物化) - 压完:产出落进一个共享串,响应与表**共用**它。这一份拷贝仍在 (deflate 产出落进共享串,冷路径一次性),④ 去掉的是另外两份 - 存表:存同一个句柄(以前 clone 一份,理由是"响应自己那份还要发出去") - 淘汰/上限:按新的 `bytes()` 记字节 —— 忘了换的话字节账恒为 0,字节那条 淘汰腿会**静默**瘸掉(条数那条还在,所以"能淘汰"这件事不会立刻露馅) 报文的字节**逐字节不变**(实测两臂 md5 相同);表这边是 `shared_ptr`,拿不到可写指针,所以"共享出去之后被人改了" 这件事由 `uvcpp_buf` 的静默物化兜着 —— 谁写谁自己拿到一份私有的。 --- src/web/uvcpp_http_server.cpp | 36 +++++++++++++++++++++-------------- src/web/uvcpp_http_server.h | 12 +++++++++++- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/web/uvcpp_http_server.cpp b/src/web/uvcpp_http_server.cpp index e2ef975a..208729c6 100644 --- a/src/web/uvcpp_http_server.cpp +++ b/src/web/uvcpp_http_server.cpp @@ -831,7 +831,7 @@ void uvcpp_http_server::compress_variant_evict() { ++it) { if (it->second.last_used < victim->second.last_used) victim = it; } - compress_variants_bytes_ -= victim->second.data.size(); + compress_variants_bytes_ -= victim->second.bytes(); compress_variants_.erase(victim); } } @@ -958,8 +958,9 @@ bool uvcpp_http_server::apply_compression(conn_ctx& ctx, if (it != compress_variants_.end()) { ++compress_variant_hits_; it->second.last_used = ++compress_variant_clock_; - resp.body.clear(); - resp.body.clone(it->second.data); + // 递句柄,一个字节都不拷(第 ④ 步)。`share()` 顺手把旧的那份放掉 —— + // 静态层借来的那份就这么还回去,既不物化、也不多一次拷贝。 + resp.body.share(it->second.data); finish_headers(); return true; } @@ -971,23 +972,30 @@ bool uvcpp_http_server::apply_compression(conn_ctx& ctx, resp.body.size(), best); if (!result.success) return false; - resp.body.clear(); - resp.body.clone(result.data); + // 压出来的那份字节**只存在一份**:响应与表共用同一个句柄(第 ④ 步)。 + // 这一份拷贝仍在 —— 它是 deflate 的产出落进那个共享串(冷路径一次性的事); + // ④ 去掉的是另外两份:存表那份,以及每次命中拷回来的那份。 + const size_t out_n = result.data.size(); + const char *out_p = result.data.get_const_data(); + ::std::shared_ptr made = + (out_p != nullptr && out_n > 0) + ? ::std::make_shared(out_p, out_n) + : ::std::make_shared(); + resp.body.share(made); finish_headers(); - // 存进表要**再拷一份**(响应自己那份还要发出去,不能 move 走)—— 但"不能 - // move"**不等于**"只能拷":#17 之后两份可以**共用**同一块字节 - // (`uvcpp_buf::share()` 接一个 `shared_ptr`),表里存 - // 句柄、命中时把句柄递出去,这一份拷贝就不必付 —— 那是第 ④ 步(变体表按 - // 句柄存/取),单独一个 PR:它要动 `compress_variant` 的持有形状,比前三步 - // 更容易踩到"共享出去之后被人改了"。今天仍然照拷:形状没变,而这一份拷贝 - // 换来的是之后每次重复请求都省掉整个 deflate —— 两个数量级的差价。 + // 存进表**不再拷**:存的就是上面那个 `made` —— 响应与表共用同一个句柄。 + // 以前这里要 clone 一份,理由是"响应自己那份还要发出去,不能 move 走"; + // 句柄把"不能 move"换成了"不必拷":`shared_ptr` 让两边 + // 同时活着,各持一个引用计数而已。这一份拷贝换来的是之后每次重复请求都省掉 + // 整个 deflate(两个数量级的差价),而现在连这一份也不用付了。 + // // 压完不比原文小就不存:那种 body 本来就压不动,存了也只是占地方。 if (cacheable && resp.body.size() < src_size) { compress_variant v; - v.data.clone(resp.body); + v.data = made; v.last_used = ++compress_variant_clock_; - compress_variants_bytes_ += v.data.size(); + compress_variants_bytes_ += v.bytes(); compress_variants_[vkey] = std::move(v); ++compress_variant_stored_; compress_variant_evict(); diff --git a/src/web/uvcpp_http_server.h b/src/web/uvcpp_http_server.h index aaca75a9..42b818b9 100644 --- a/src/web/uvcpp_http_server.h +++ b/src/web/uvcpp_http_server.h @@ -956,8 +956,18 @@ class UVCPP_API uvcpp_http_server { // `(编码, tag) -> 键` 单射,后缀则不然(tag 自己含分隔符时会撞成同一个键)。 // ------------------------------------------------------------------- struct compress_variant { - uvcpp_buf data; + /// 压缩产物**只有这一份**:表与响应共用同一个句柄(`uvcpp_buf::share()`)。 + /// 命中时把句柄递出去、不再拷一份回来;存表时也只是存同一个句柄。表这边 + /// 是 `shared_ptr` —— 拿不到可写指针,那个 `const` 就是 + /// 「共享出去之后被人改了」的防线(有人要写,`uvcpp_buf` 会先物化出私有的 + /// 一份,表里这份纹丝不动)。 + ::std::shared_ptr data; uint64_t last_used = 0; + /// 这一条占多少字节。淘汰与上限都按它算(以前是 `uvcpp_buf::size()`)—— + /// 换成句柄之后**必须**换过来,否则字节账恒为 0、淘汰只剩条数那一条腿。 + size_t bytes() const { + return this->data != nullptr ? this->data->size() : 0; + } }; std::map compress_variants_; size_t compress_variants_bytes_ = 0; From 0cde15b35986010081ad577fe3f22d488798ca51 Mon Sep 17 00:00:00 2001 From: sercebr Date: Sun, 20 Sep 2026 13:17:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(buf):=20=E5=8F=98=E4=BD=93=E8=A1=A8?= =?UTF-8?q?=E6=8C=89=E5=8F=A5=E6=9F=84=E5=AD=98/=E5=8F=96=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E5=BF=85=E9=A1=BB=E6=88=90=E7=AB=8B=E7=9A=84=E5=87=A0?= =?UTF-8?q?=E6=9D=A1=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新用例 `buf_share_handle_func`(G1~G5,纯缓冲语义,不起连接): 1. 命中那条路上响应体与表里那条**指针相同**(不是"内容相等" —— 后者对 "到底拷没拷"零信息量); 2. 谁写响应体都**不许动到表里那份**,分三种写法各自记该记的账: 直接写/追加 = 一次真物化(`share_discard_count()` +1), `clear()` 之后再写 = 一个字节都没白拷,所以**不记账**; 3. **谁先走都不影响另一份**:表里那条被淘汰时响应还没发完(④ 之前不可能 出问题,那时两边各持一份拷贝),反过来也一样; 4. 空句柄/空手柄的退化与 `clear()` 一致。 `web_app_static_func` 里那条 `s.bytes > 0` 换成**算死**的账:表内字节数 必须正好等于那三次响应的 Content-Length 之和(换句柄之后账还得对)。 --- tests/functional/buf_share_handle_func.cpp | 283 +++++++++++++++++++++ tests/functional/web_app_static_func.cpp | 22 +- 2 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/functional/buf_share_handle_func.cpp diff --git a/tests/functional/buf_share_handle_func.cpp b/tests/functional/buf_share_handle_func.cpp new file mode 100644 index 00000000..e9b43a97 --- /dev/null +++ b/tests/functional/buf_share_handle_func.cpp @@ -0,0 +1,283 @@ +/** + * @file tests/functional/buf_share_handle_func.cpp + * @brief 变体表**按句柄**存/取(#17 第 ④ 步)之后必须成立的那几条契约。 + * + * 第 ④ 步把压缩变体表里那份字节从「自己持有的一份拷贝」换成了「与响应共用的 + * 一个句柄」(`std::shared_ptr`)。于是**同一块字节**上同时 + * 挂着两个持有者:表里那一条,和正在往外发的那个响应。这个文件钉的就是这件事 + * 带来的三条契约 —— 它们都不是"性能",是**正确性**: + * + * 1. **命中那条路上,响应体与表里那条就是同一份字节**(指针相同、持有者数对得 + * 上)。这条是 ④ 的全部意义所在:所谓"共享"如果背后又拷了一份,那仍然是 + * 拷贝,热的压缩响应就还是白付一次整份体的拷贝。判据用**指针相同**,不用 + * "内容相等" —— 后者对"到底拷没拷"零信息量。 + * + * 2. **谁写响应体,谁自己拿到一份私有的**,表里那条逐字节不变。`clear()` 紧接 + * `clone()` 是响应对象复用的**正常**序列(不是错误),所以这条不能靠断言、 + * 只能靠 `uvcpp_buf` 的静默物化。它是 ④ 最容易踩的地方:表与响应现在是同一 + * 个 string,写的人要是拿到了可写指针,坏掉的是**之后每一次命中** —— 而且 + * 是"偶尔发错字节"这种最难查的坏法。防线是两样:表这边是 + * `shared_ptr`(拿不到可写指针),写这边是物化。 + * 所以这里既要钉"表没被写坏",也要钉"物化确实发生了、而且只发生一次" + * (`share_discard_count()` 恰好 +1)。 + * + * 3. **谁先走都不影响另一份。** 淘汰(字节/条数上限)随时可能发生,而"表里那 + * 一条被淘汰时响应还没发完"在 ④ 之前**不可能**出问题 —— 那时两边各持一份 + * 拷贝。现在两者共用同一块字节,所以这一条是句柄**必须**提供的东西:它靠 + * 引用计数把字节撑住,谁先松手都不动内容。反过来(响应先走、表里还在)同理。 + * + * 全是单线程的纯缓冲语义,起不了连接也不需要 —— 契约本身就活在 `uvcpp_buf` + * 这一层,跑真连接只会把同一件事测得更慢。 + */ +#include +#include +#include +#include + +#include "uvcpp/uvcpp_buf.h" + +using namespace uvcpp; + +namespace { + +int g_failures = 0; + +void check(bool cond, const std::string& what) { + if (!cond) { + std::cerr << " [FAIL] " << what << std::endl; + ++g_failures; + } +} + +// ========================================================================= +// G1:命中那条路 —— 表与响应是**同一份字节** +// ========================================================================= +void case_same_bytes() { + std::shared_ptr made = + std::make_shared("gzip-payload"); + const char* p = made->data(); + + // 模拟 ④ 的两条路:压出来之后响应先接住句柄,表里存的是**同一个**句柄。 + uvcpp_buf resp; + resp.share(made); + uvcpp_buf table; + table.share(made); + + check(made.use_count() == 3, "G1: 三个持有者 —— 本地 1 + 表 1 + 响应 1"); + check(resp.get_const_data() == p, "G1: 响应指的就是那份字节(不是拷来的)"); + check(table.get_const_data() == p, "G1: 表里指的也是同一份(指针相同才是没拷)"); + check(resp.get_const_data() == table.get_const_data(), + "G1: 两边一模一样 —— ④ 省掉的就是这两边之间那次拷贝"); + + // `shared_ref()` 递出来的必须是**同一个**句柄(引用计数上去),而不是一份拷贝。 + { + std::shared_ptr back = resp.shared_ref(); + check(back == made, "G1: shared_ref() 递回来的是同一个句柄"); + check(made.use_count() == 4, "G1: 多了一个持有者(是引用计数,不是新的一份字节)"); + } + + check(resp.is_shared() && table.is_shared(), "G1: 两边都是共享视图"); + check(resp.size() == made->size() && table.size() == made->size(), + "G1: 长度都取自那份串"); + check(resp.to_string() == "gzip-payload", "G1: 读出来就是那份压缩产物"); +} + +// ========================================================================= +// G2:写响应体 —— 表里那条**逐字节不变**(④ 最容易踩的地方) +// +// 分三种写法,因为它们的代价**不一样**,"该记几次账"也跟着不一样: +// a) 直接写共享视图(`clone_data` 一步到位):共享的那几个字节必须先被拷成 +// 自有的才谈得上改写 —— 一次真金白银的物化,计数 +1; +// b) `clear()` 之后再写(**响应对象复用的正常序列**):clear 不需要写,只是 +// 把引用放掉(`free_own()`),新内容随后从别处来 —— 一个字节都没白拷, +// 所以计数**不动**; +// c) 往共享视图上**追加**("有人往响应体里塞了个 banner"的形状):原件必须先 +// 拷出来才能接在后面,所以和 a 一样 +1。 +// 三种写法都必须让表里那条纹丝不动 —— 那才是 ④ 的契约。(b 那条"不记账"不是 +// 抠字眼:判据写成 +1 的话,这条用例就变成在钉一个**错的**账。) +// ========================================================================= +void case_write_response_keeps_table() { + const std::string payload = "gzip-payload"; + + // ---- a) 直接写:一次真物化 ---- + { + std::shared_ptr made = + std::make_shared(payload); + const char* p = made->data(); + + uvcpp_buf table; + table.share(made); + uvcpp_buf resp; + resp.share(made); + + const uint64_t before = uvcpp_buf::share_discard_count(); + resp.clone_data("replaced", 8); + + check(*made == payload, "G2a: 写响应没有动到那份串"); + check(table.to_string() == payload, "G2a: 表里那一条逐字节不变"); + check(table.get_const_data() == p, "G2a: 表里那条还在原来那块字节上"); + check(resp.get_const_data() != p, "G2a: 响应拿到了自己私有的一块"); + check(resp.to_string() == "replaced", "G2a: 响应读到的是新内容"); + check(!resp.is_shared(), "G2a: 写完不再是共享视图"); + check(made.use_count() == 2, "G2a: 物化时放掉了响应那份引用(只剩本地 + 表)"); + check(uvcpp_buf::share_discard_count() == before + 1, + "G2a: 一次真物化被记成一次「共享又丢」"); + } + + // ---- b) clear() 之后再写:不记账(没有白拷) ---- + { + std::shared_ptr made = + std::make_shared(payload); + const char* p = made->data(); + + uvcpp_buf table; + table.share(made); + uvcpp_buf resp; + resp.share(made); + + const uint64_t before = uvcpp_buf::share_discard_count(); + resp.clear(); + check(made.use_count() == 2, "G2b: clear() 放掉了响应那份引用(只剩本地 + 表)"); + resp.clone_data("replaced", 8); + + check(*made == payload, "G2b: 写响应没有动到那份串"); + check(table.to_string() == payload, "G2b: 表里那一条逐字节不变"); + check(table.get_const_data() == p, "G2b: 表里那条还在原来那块字节上"); + check(resp.to_string() == "replaced", "G2b: 响应读到的是新内容"); + check(uvcpp_buf::share_discard_count() == before, + "G2b: clear() 不写字节,所以这一次**不该**记账(记的是白拷,这里一个字节都没拷)"); + } + + // ---- c) 追加:原件先被拷出来,然后接在后面 ---- + { + std::shared_ptr made = + std::make_shared(payload); + const char* p = made->data(); + + uvcpp_buf table; + table.share(made); + uvcpp_buf resp; + resp.share(made); + + const uint64_t before = uvcpp_buf::share_discard_count(); + resp.append_data("!!", 2); + + check(resp.to_string() == payload + "!!", "G2c: 追加之后 = 原件 + 追加"); + check(table.to_string() == payload, "G2c: 表里那一条还是原件(追加没串过去)"); + check(table.get_const_data() == p, "G2c: 表里那条还在原来那块字节上"); + check(*made == payload, "G2c: 那份串本身也没变(它是 const,改不了)"); + check(uvcpp_buf::share_discard_count() == before + 1, + "G2c: 追加要先拷出原件,记一次「共享又丢」"); + } +} + +// ========================================================================= +// G3:淘汰先发生(表里那条先走)—— 正在发的响应还得是完好的 +// +// ④ 之前这一条**不可能**出问题(两边各持一份拷贝);换句柄之后它成了 +// 引用计数必须撑住的东西。判据里连"指针没变"一起钉:物化也算拷贝,这里 +// 一次都不该有。 +// ========================================================================= +void case_evict_before_response() { + std::shared_ptr made = + std::make_shared("evicted-target"); + const char* p = made->data(); + + uvcpp_buf table; + table.share(made); + uvcpp_buf resp; + resp.share(made); + + made.reset(); // 真实现里外头没人持着 —— 只剩表和响应 + { + std::shared_ptr both = table.shared_ref(); + check(both.use_count() == 3, + "G3: 外部引用放掉后只剩表 + 响应(加这里取出来这一份)"); + } + + table.clear(); // 淘汰:表里那一条被 erase(这里等价于 clear) + { + std::shared_ptr only = resp.shared_ref(); + check(only.use_count() == 2, "G3: 只剩响应那一个持有者(表那份已经放了)"); + } + check(resp.get_const_data() == p, + "G3: 淘汰之后响应还在原来那块字节上(一次拷贝都没有)"); + check(resp.to_string() == "evicted-target", + "G3: 淘汰表里那一条,正在发的响应仍然完好"); + + resp.clear(); +} + +// ========================================================================= +// G4:反过来 —— 响应先走,表里那条还得完好 +// ========================================================================= +void case_response_first_keeps_table() { + std::shared_ptr made = + std::make_shared("kept-by-table"); + const char* p = made->data(); + + uvcpp_buf table; + table.share(made); + uvcpp_buf resp; + resp.share(made); + + made.reset(); + resp.clear(); // 这条响应发完(或被复用)了 + + check(table.get_const_data() == p, + "G4: 响应走了之后表里那条还在同一块字节上"); + check(table.to_string() == "kept-by-table", "G4: 表里那一条逐字节完好"); + { + std::shared_ptr only = table.shared_ref(); + check(only.use_count() == 2, "G4: 只剩表那一个持有者"); + } +} + +// ========================================================================= +// G5:空句柄/空手柄的退化 +// +// ④ 的实现里,压缩产出长度为 0 时会 `share()` 一个**空的**共享串。它必须与 +// `clear()` 完全一致(空缓冲、不是共享视图)—— 否则 `is_shared()` 为真而 +// 长度为 0 的"空共享"会在别处(比如"命中就把句柄递出去")变成另一种东西。 +// ========================================================================= +void case_empty_share() { + { + std::shared_ptr empty = + std::make_shared(); + uvcpp_buf b; + b.clone_data("old-bytes", 9); + b.share(empty); + check(!b.is_shared(), "G5: 空的共享串 = 不是共享视图"); + check(b.size() == 0 && b.get_const_data() == nullptr, + "G5: 空的共享串 = 与 clear() 一致"); + } + { + std::shared_ptr null_handle; + uvcpp_buf b; + b.clone_data("old-bytes", 9); + b.share(null_handle); + check(!b.is_shared() && b.size() == 0, + "G5: 空手柄也是 clear()(旧的那份要放掉)"); + } +} + +} // namespace + +int main() { + // 计数器是进程级的:这里只关心"某一段里恰好 +1",先清零。 + uvcpp_buf::reset_share_discard_count(); + + case_same_bytes(); + case_write_response_keeps_table(); + case_evict_before_response(); + case_response_first_keeps_table(); + case_empty_share(); + + if (g_failures == 0) { + std::cout << "[buf_share_handle] ALL PASS" << std::endl; + return 0; + } + std::cout << "[buf_share_handle] FAIL (" << g_failures << " checks failed)" + << std::endl; + return 2; +} diff --git a/tests/functional/web_app_static_func.cpp b/tests/functional/web_app_static_func.cpp index 9c4f935a..73f79ada 100644 --- a/tests/functional/web_app_static_func.cpp +++ b/tests/functional/web_app_static_func.cpp @@ -1025,7 +1025,16 @@ void test_compress_variant() { check_eq_i(static_cast(s.stored), 3, "应当三次存入"); check_eq_i(static_cast(s.hits), 1, "应当恰好一次命中"); check_eq_i(static_cast(s.entries), 3, "表里应当三条"); - check(s.bytes > 0, "表内字节数应当 > 0"); + // 表内字节账:变体表按**句柄**存之后(#17 第 ④ 步),账必须跟着换成 `bytes()` + // —— 忘了换的话 `compress_variants_bytes_` 恒为 0,字节那条淘汰腿就**静默** + // 瘸了(条数那条还在,所以"能淘汰"这件事不会立刻露馅,只在大文件上悄悄失效)。 + // 所以这里把账**算死**:三条 entry 的大小之和必须正好等于那三次响应的 + // Content-Length 之和(a1 / m1 / m2 各存了一条)。 + const long long sum_cl = std::stoll(raw_header(a1, "content-length")) + + std::stoll(raw_header(m1, "content-length")) + + std::stoll(raw_header(m2, "content-length")); + check_eq_i(static_cast(s.bytes), sum_cl, + "表内字节数 == 三条变体的压缩长度之和(换句柄之后账还得对)"); // ---- 零拷贝的账单:这条路上**一次物化都不该发生** ---- // @@ -1062,6 +1071,17 @@ void test_compress_variant() { } check_eq_i(static_cast(discarded), 0, "共享进来的文件体在整条响应链上被物化了若干次(见上面那段说明)"); +======= + // 表内字节账:变体表按**句柄**存之后(#17 第 ④ 步),账必须跟着换成 `bytes()` + // —— 忘了换的话 `compress_variants_bytes_` 恒为 0,字节那条淘汰腿就**静默** + // 瘸了(条数那条还在,所以"能淘汰"这件事不会立刻露馅,只在大文件上悄悄失效)。 + // 所以这里把账**算死**:三条 entry 的大小之和必须正好等于那三次响应的 + // Content-Length 之和(a1 / m1 / m2 各存了一条)。 + const long long sum_cl = std::stoll(raw_header(a1, "content-length")) + + std::stoll(raw_header(m1, "content-length")) + + std::stoll(raw_header(m2, "content-length")); + check_eq_i(static_cast(s.bytes), sum_cl, + "表内字节数 == 三条变体的压缩长度之和(换句柄之后账还得对)"); } // ---- 7. 尺寸闸门 ---- From c851ec240bd74c58748cec26b7b2fa1410ba3d98 Mon Sep 17 00:00:00 2001 From: sercebr Date: Sun, 20 Sep 2026 15:00:57 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(web):=20=E5=8F=98=E4=BD=93=E8=A1=A8?= =?UTF-8?q?=E7=9A=84=E5=AD=97=E8=8A=82=E8=B4=A6=E5=BF=85=E9=A1=BB**?= =?UTF-8?q?=E9=9A=8F=E6=B7=98=E6=B1=B0**=E9=99=8D=EF=BC=88=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E9=82=A3=E6=9D=A1=E9=9D=99=E9=BB=98=E8=85=BF=E7=9A=84?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_compress_variant` 把字节账算死了,但它只有三条 entry、一次淘汰都没有 —— "淘汰时忘了减"这条腿仍然没有判据。而这条腿瘸掉是**静默**的: `compress_variants_bytes_` 只涨不减,条数那条腿还在照常淘汰,所以"能淘汰" 这件事照样是绿的,只有大文件会因为字节上限永远触发不了而悄悄不再命中。 新增的用例把**条数上限**跑满(存 1100 条,键不同、体完全一样,于是每条压缩 后都是同一个长度 C),淘汰开始之后: * 条数腿:表里稳定在 1024 条; * 字节腿:账必须 == 剩余条数 × C。 变异验证(删掉 `compress_variant_evict()` 里那句 `compress_variants_bytes_ -= victim->second.bytes();`): bytes = 47300 = 1100 × 43 → 字节腿红 entries = 1024 → 条数腿**仍然绿** test_compress_variant 的 bytes = 124 不变 → 旧判据也仍然绿 正是"旁路判据把主判据的失效盖住"那个形状。判据钉的是那个上限**存在**, 不是一个恰好相等的数。 --- ## 1100 条怎么打得又快又不出岔子 这条用例要真打 1100 条请求,而 CI 那条 `--timeout 30` 是硬线,所以形状是 量出来的、不是随手写的: * **一条请求一条连接**:跑得对,但 Windows 上每条连接约 4 ms,1100 条 = 4.5 秒。本文件其余用例本来就要十几秒 ⇒ 顶穿 30s(**macOS CI 上已经真的 超时过一次**,这个 PR 的第一版就是这么挂的)。 * **一口气灌进一条连接**(HTTP 流水线):0.2 秒,但**会丢请求** —— 服务端读 缓冲是 64 KiB(`internal_alloc_cb` 用 libuv 建议的尺寸),**跨在 65536 上的 那条请求会被丢掉**。本机 100% 复现:1100 条里固定丢第 745 条,其字节区间 65450..65538 正好盖住 65536;给每条加 19 字节填充,丢的就变成新的跨界那条 (586),而且那之后整段都不回。 这是**另一个**问题(已单独报给作者),不该混进这条判据。 * **每批 20 条走一条连接**(批内最后一条带 `Connection: close`):0.37 秒, 每条请求都回、每条都真的存进表。每批只有两 KB 左右,服务端从空缓冲开始 读,碰不到那个 64 KiB 边界。 --- tests/functional/web_app_static_func.cpp | 174 +++++++++++++++++++++-- 1 file changed, 163 insertions(+), 11 deletions(-) diff --git a/tests/functional/web_app_static_func.cpp b/tests/functional/web_app_static_func.cpp index 73f79ada..d570785d 100644 --- a/tests/functional/web_app_static_func.cpp +++ b/tests/functional/web_app_static_func.cpp @@ -417,6 +417,65 @@ raw_result raw_exchange(int port, const std::string& request_bytes, return out; } +/// 在**一条**连接上灌一小批请求,等连接关掉,返回读到的全部字节。 +/// +/// `all` 的**最后一条**必须带 `Connection: close`(前面的保持 keep-alive):靠 +/// "服务端把连接关掉"来判定"这一批都处理完了"。 +/// +/// 为什么是"一小批"而不是"一口气上千条":一口气灌的时候服务端的读缓冲会逼近它的 +/// 64 KiB 上限(`internal_alloc_cb` 用的是 libuv 建议的尺寸),而**跨在那个边界上的 +/// 请求会被服务端丢掉**。本机 100% 复现:1100 条里固定丢第 745 条,它的字节区间 +/// 65450..65538 正好盖住 65536;给每条加 19 字节填充,丢的就变成新的跨界那条 +/// (586)。那是**另一个**问题,不该混进这条判据里 —— 每批只灌两三 KB,服务端从空 +/// 缓冲开始读,永远碰不到那个边界。 +/// +/// 为什么不一request 一连接:Windows 上每条连接的开销约 4 ms,1100 条就是 4.5 秒; +/// 本文件其余用例本来就要十几秒,加起来会顶穿 CI 那条 `--timeout 30`。 +std::string raw_batch(int port, const std::string& all, int wait_ms) { + uvcpp_tcp_client client; + std::atomic connected(false); + std::atomic ended(false); + std::string got; + + int rc = client.connect("127.0.0.1", port, [&](int st) { + if (st != 0) return; + connected.store(true); + client.read_start_events([&](uvcpp_tcp_client&, const net_read_result& r) { + if (r.is_data()) { + got.append(r.data, r.size); + } else { + ended.store(true); + } + }); + client.write(all.data(), all.size(), [](int) {}); + }); + if (rc != 0) return got; + + uvcpp_loop* loop = client.get_loop(); + uvcpp_test::wait_until(loop, [&] { return connected.load(); }, uvcpp_test::kWaitMs); + if (!connected.load()) return got; + + const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now(); + while (!ended.load()) { + loop->run(UV_RUN_NOWAIT); + const long long ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + if (ms >= wait_ms) break; + // yield 而不是 sleep:这里每轮都可能已经能推进,睡下去就白等一个定时器周期。 + std::this_thread::yield(); + } + + std::atomic closed(false); + if (client.get_tcp() != nullptr) { + client.get_tcp()->close([&](uvcpp_handle*) { closed.store(true); }); + } + uvcpp_test::wait_until(loop, [&] { return closed.load(); }, uvcpp_test::kWaitMs); + for (int i = 0; i < 30; ++i) loop->run(UV_RUN_NOWAIT); + + return got; +} + /// 把 `/x HTTP/1.1` 拼成一条完整的头 `Connection: close` 的请求。 std::string raw_head_request(const std::string& method, const std::string& path) { return method + " " + path + @@ -1071,17 +1130,109 @@ void test_compress_variant() { } check_eq_i(static_cast(discarded), 0, "共享进来的文件体在整条响应链上被物化了若干次(见上面那段说明)"); -======= - // 表内字节账:变体表按**句柄**存之后(#17 第 ④ 步),账必须跟着换成 `bytes()` - // —— 忘了换的话 `compress_variants_bytes_` 恒为 0,字节那条淘汰腿就**静默** - // 瘸了(条数那条还在,所以"能淘汰"这件事不会立刻露馅,只在大文件上悄悄失效)。 - // 所以这里把账**算死**:三条 entry 的大小之和必须正好等于那三次响应的 - // Content-Length 之和(a1 / m1 / m2 各存了一条)。 - const long long sum_cl = std::stoll(raw_header(a1, "content-length")) + - std::stoll(raw_header(m1, "content-length")) + - std::stoll(raw_header(m2, "content-length")); - check_eq_i(static_cast(s.bytes), sum_cl, - "表内字节数 == 三条变体的压缩长度之和(换句柄之后账还得对)"); +} + +// ---- 6b. 字节账必须**随淘汰**降(#17 第 ④ 步那条静默腿)---- + +// 上面那条把字节账算死了,但它只有三条 entry、**一次淘汰都没发生** —— 于是 +// "淘汰时忘了减"这条腿仍然没有判据。而这条腿瘸掉是**静默**的: +// `compress_variants_bytes_` 只涨不减,条数那条腿还在照常淘汰,所以"能淘汰" +// 这件事照样是绿的,只有大文件会因为字节上限永远触发不了而悄悄不再命中 —— +// 这正是"旁路判据把主判据的失效盖住"的形状。 +// +// 所以这里专门把**条数上限**跑满:条数与字节两条腿共用一个淘汰循环,条数那腿 +// 触发时走的**就是**字节账该被减掉的那一句。造键不同、**体完全一样**的变体 +// (8 KiB 的 'x'),于是每条压缩后的长度都是同一个 C: +// * 条数腿:表里稳定在条数上限(1024)条; +// * 字节腿:账必须正好等于**剩下那些条**的长度和 = 条数 × C。 +// 少了淘汰那一次 `-=`,账会是"总存入条数 × C" —— 字节那条红,而条数那条**仍然绿**。 +void test_variant_byte_account_on_evict() { + std::cout << "[static] 变体表字节账随淘汰降" << std::endl; + + // 必须**大于** `kCompressVariantMaxEntries`(uvcpp_http_server.cpp 里的 1024, + // 那条注释写的是"条数封碎片")。刻意写死这个值:常量哪天真被改了,这条测试 + // 就该红一次让人回来看 —— 它钉的是那个上限**存在**,不是一个恰好相等的数。 + const int kWantEntries = 1024; + const int kStored = kWantEntries + 76; // 真的淘汰起来,别卡在边界上 + + uvcpp_web_app app; + app.set_port(0); + app.set_log_level(log_level::WARN); + app.set_compression(true); + app.set_compress_min_body_size(64); + + // 体一样、键不一样:`cache_tag` 决定变体的键,所以每条压出来都一样长 —— + // 字节账才能**算死**,而不是"看着差不多"。 + app.get("/vb/:i", [](uvcpp_web_request& req, uvcpp_web_response& resp, + uvcpp_web_next) { + const std::string* i = req.param("i"); + resp.raw().cache_tag = (i != nullptr) ? *i : std::string(); + resp.text(std::string(8 * 1024, 'x')); + resp.end(); + }); + + if (app.start_background() != 0) { + check(false, "字节账随淘汰降:服务启动失败"); + return; + } + const int port = app.bound_port(); + check(port > 0, "字节账随淘汰降:端口有效"); + + // 一条连接灌完(流水线):见 raw_pipeline 的函数头 —— 逐条 raw_exchange 会被 + // Windows 上 15.6ms 的定时器粒度拖到 17 秒,正好顶穿 ctest 给这个 target 的 30s。 + // 小批灌入(见 `raw_batch` 的函数头:为什么不一口气灌、也不一请求一连接)。 + const int kBatch = 20; + std::string first_cl; + int ok_count = 0; + for (int base = 0; base < kStored; base += kBatch) { + // 括号不是装饰:windows.h 把 `min` 定义成宏,`std::min(...)` 会被它拆开。 + const int end = (std::min)(kStored, base + kBatch); + std::string chunk; + chunk.reserve(static_cast(end - base) * 96); + for (int i = base; i < end; ++i) { + chunk += "GET /vb/" + std::to_string(i) + " HTTP/1.1\r\nHost: 127.0.0.1\r\n"; + // 一批里的最后一条让服务端关连接 —— 我们靠那次关闭判定"这批都处理完了"。 + chunk += (i + 1 == end) ? "Connection: close\r\n" + : "Connection: keep-alive\r\n"; + chunk += "Accept-Encoding: gzip\r\n\r\n"; + } + const std::string got = raw_batch(port, chunk, 5000); + if (got.empty()) continue; + // 数这一批回了几条:每条响应的头里恰好一个 `content-length`(gzip 体里不可能 + // 出现这个字面量)。这只是**进度/前置**,判据在下面计数器的账上。 + size_t n = 0, p = 0; + while ((p = got.find("content-length", p)) != std::string::npos) { ++n; ++p; } + ok_count += static_cast(n); + if (first_cl.empty()) first_cl = raw_header(got, "content-length"); + } + check_eq_i(ok_count, kStored, "前置:每一条请求都要有响应"); + + app.stop(); + app.join(); + + // 停干净之后才读表:loop 线程已退出,`std::map` 不再有并发写。 + const uvcpp_http_server::compress_variant_stat s = + app.http_server()->compress_variant_stats(); + + check_eq_i(static_cast(s.stored), + static_cast(kStored), + "前置:每一条都要真的**存进表里**(没压 / 304 都会让这条先红)"); + const long long c = std::stoll(first_cl); + check(c > 0 && c < 8 * 1024, + "前置:压缩后的长度必须真的小于原文(8 KiB 压不动就不该存)"); + + check_eq_i(static_cast(s.entries), + static_cast(kWantEntries), + "条数腿:表里稳定在条数上限那条线上"); + // 这条是主判据。淘汰那句 `-=` 少了、或者账没换到 `bytes()`,"存入条数 × C" + // 与"剩余条数 × C"就会差出 76 条 —— 而上面那条**仍然是绿的**。 + check_eq_i(static_cast(s.bytes), + static_cast(s.entries) * c, + "字节腿:表内字节账 == 剩余条数 × 每条压缩长度" + "(淘汰时少减一次,账就会是总存入条数 × C)"); + std::cout << "[static] 字节账随淘汰降: stored=" << s.stored + << " entries=" << s.entries << " bytes=" << s.bytes + << " C=" << c << std::endl; } // ---- 7. 尺寸闸门 ---- @@ -1453,6 +1604,7 @@ int main() { // 自带一个 App 的一条用例,放在共享 App 停掉之后跑(理由见函数上方)。 test_compress_variant(); + test_variant_byte_account_on_evict(); cleanup_root(); From 60f4e8415ae940a18f4e41404b9ea1d8030bd280 Mon Sep 17 00:00:00 2001 From: sercebr Date: Sun, 20 Sep 2026 15:51:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(ci):=20=E7=BB=99=20test=5Fweb=5Fapp=5F?= =?UTF-8?q?static=5Ffunc=20=E5=8D=95=E7=8B=AC=E4=B8=80=E4=BB=BD=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=A2=84=E7=AE=97=EF=BC=88=E4=B8=8D=E5=8A=A8=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E9=82=A3=E4=B8=AA=2030=20s=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/ci.yml` 里 8 处都是 `--timeout 30`,而这条文件在 CI 上的实测是 ubuntu 16.5 s / macOS 19.1 s / Windows `web` 21.4 s —— 本来就是最重的一条,余量只剩 9 s。它的量级由被测面本身决定(每个请求一条连接、串行走完整条响应链),不是哪条用例 写慢了,想给它减负只能改被测代码。 余量薄到这个程度,表现是**时红时绿**:内容更少的那一版(`efad491`)在 macOS runner 上 卡 30.05 s 红掉,内容更多的 `0d94f2e` 反而 19/19 全过。这比稳定红更难查 —— 它会训练人 「重跑一遍当没事」。 set_tests_properties(test_web_app_static_func PROPERTIES TIMEOUT 90) 只给这一条:命令行那个 30 s 是**默认值**,被属性盖过,所以全局一点没松。这比把整个 job 的 `--timeout` 抬到 60 诚实 —— 那是把每一条用例的挂死检出时间一起抬了。 机制两头都实测过(本机,不是查文档): * `ctest -C Release --timeout 1 -R test_web_app_static_func` ⇒ 跑满 16.89 s **通过**; * 同一命令、把属性暂时写成 `TIMEOUT 2` ⇒ 当场 `52 - test_web_app_static_func (Timeout)`。 `if(TARGET ...)` 是必须的、不是装饰:`UVCPP_BUILD_WEBAPP` 默认 OFF,那种配置下 `web_app_static_func.cpp` 已被上面的过滤器摘掉(本机拿 WEBAPP=OFF 的树实测:配置通过、 目标确实不存在),直接 `set_tests_properties` 会报「找不到这个测试」。 --- tests/functional/CMakeLists.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/functional/CMakeLists.txt b/tests/functional/CMakeLists.txt index 38321402..d894fa78 100644 --- a/tests/functional/CMakeLists.txt +++ b/tests/functional/CMakeLists.txt @@ -82,4 +82,21 @@ foreach(test_src ${FUNC_SOURCES}) add_test(NAME ${exe_name} COMMAND ${exe_name}) endforeach() +# `test_web_app_static_func` 是整棵树里最重的一条(CI 上 ubuntu 16.5 s、Windows +# `web` 21.4 s),而 workflow 给的默认上限是 `--timeout 30`。它的量级由被测面本身 +# 决定 —— 每个请求一条连接、串行走完整条响应链 —— 不是哪条用例写慢了,想减负只能 +# 改被测代码。余量只剩 9 s 的时候,它在 runner 上就是**时红时绿**的(同一个 SHA +# 换个 runner 就过),这比稳定红更难查:它会训练人「重跑一遍当没事」。 +# +# 所以给**这一条**单独一份预算。命令行那个 `--timeout 30` 只是**默认值**,会被这里 +# 的属性盖过(两头都实测过:带属性 + `ctest --timeout 1` 照样跑满并通过;撤掉属性、 +# 同参数当场 `Timeout`),于是其余每条用例的挂死检出时间一点没松 —— 这也比把整个 +# job 的 `--timeout` 抬到 60 诚实,那是把每一条的检出时间一起抬了。 +# +# `if(TARGET ...)` 不是装饰:`UVCPP_BUILD_WEBAPP` 默认 OFF,那种配置下这个目标已被 +# 上面的过滤器摘掉,直接 `set_tests_properties` 会报「找不到这个测试」。 +if(TARGET test_web_app_static_func) + set_tests_properties(test_web_app_static_func PROPERTIES TIMEOUT 90) +endif() +