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; 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() + 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..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 + @@ -1025,7 +1084,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, + "表内字节数 == 三条变体的压缩长度之和(换句柄之后账还得对)"); // ---- 零拷贝的账单:这条路上**一次物化都不该发生** ---- // @@ -1064,6 +1132,109 @@ void test_compress_variant() { "共享进来的文件体在整条响应链上被物化了若干次(见上面那段说明)"); } +// ---- 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. 尺寸闸门 ---- void test_size_gate() { @@ -1433,6 +1604,7 @@ int main() { // 自带一个 App 的一条用例,放在共享 App 停掉之后跑(理由见函数上方)。 test_compress_variant(); + test_variant_byte_account_on_evict(); cleanup_root();