From d1aa7cd1fa4c09ff9a52ba0b55cb86ced178b90e Mon Sep 17 00:00:00 2001 From: sercebr Date: Sun, 20 Sep 2026 04:08:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(net):=20=E5=9B=9E=E8=B0=83=E9=87=8C?= =?UTF-8?q?=E6=9E=90=E6=9E=84=20tcp=5Fclient=20=E6=97=B6=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E8=A3=85=E5=AF=B9=E8=B1=A1=E4=B8=8D=E5=86=8D=E6=8C=89=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=95=B0=E7=B4=AF=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #5:连接频繁建立/断开时 RSS 线性涨、不收敛 —— net 层 371 字节/ 连接(20000 连接 +7.07 MB),webapp 0.74–1.17 MB/min。 根因是 ~uvcpp_tcp_client 里那条兜底的判据「循环还在跑 ⇒ 包装对象 (tcp_)一个都不拆」。它区分不了「我正踩在自己的回调里」和「循环只是 恰好跑着」,而服务运行期间后者恒成立,于是每断一次连接就漏一个 uvcpp_tcp 包装对象。 把判据换成「句柄还在不在,以及处在哪一步」: - 句柄已摘(get_handle() == nullptr)→ 当场 delete(net 层那条 churn 路径实测 2000/2000 都走这里); - 句柄还活着、没在关 → read_stop() 之后把包装对象交给它自己的关闭 完成回调去删(webapp 那条路径实测 1000/1000 都走这里)。uv_close 的完成回调在本轮 uv_run 的收尾阶段跑,那时当前这一帧已经解开; callback_close 也已经把回调拷到局部、清空成员、释放了 uv_handle_t 的内存,回调返回之后不再碰 wrapper; - 句柄正在关(关闭回调槽被占)→ 只能留下,实测两条路径上都是 0 次。 read_arg_ 那块不由任何 libuv 请求持有(trampoline_read 不删它), 一并交给同一个关闭回调,它正是 webapp 侧剩下的大头。 实测:net 层 20000 连接斜率 371 字节/连接 → 0(7.79 MB 基线,6 个 采样点全平),webapp 0.74–1.17 → 0.02/0.07 MB/min。 新增 reclaim_stats() 诊断计数(与 close_observer_count 同一用途): 析构走哪条支路从外面看不见,回归用例需要判据。计的是释放真的做成了, 所以把释放动作删掉计数就会停在原地。 新用例 test_reclaim_paths 两条腿各钉一种回调形状,判据两条互相独立: 计数必须随断开次数涨 1 + 收尾后 uv_loop_close() 必须返回 0。 变异验证(tests/tools/run_tcp_client_dtor_mutation.py 新增两条): M6 退回「一个都不拆」→ 两条腿红(不开页堆) M7 句柄还活着也当场 delete → 没抓住,判为等价变异,理由见 tests/tools 的说明与 PR 正文 工具:本机没有 gflags.exe,补一条直接写 IFEO 注册表的路(GlobalFlag + PageHeapFlags=0x1)并照样回查;抓 FAIL 明细的正则改成能匹配带空格的 用例名。 同一个析构里还有一处同形的(上游 d12188c 刚合入的握手超时定时器): 它在「析构发生在定时器回调里」这一支只关句柄、把包装对象留在原地, 而那句注释的理由写的是「同上」—— 上面那句现在不再「留在原地」了, 所以照同一个理由改过来(这一处是推理出来的,不是实测:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到)。 基线:本分支已 rebase 到上游 a02ebf7。上游 master 在这个析构里处理 tcp_ 的代码与 rebase 前逐字相同,所以 master 侧的斜率在同一个新基线 上重测过:7.72 → 9.20 → 10.64 → 11.98 → 13.38 → 14.79 MB(每档 4000 连接),仍是 371 字节/连接;修复侧同机同次为 7.73 → 7.78 ×5 (只在第一次灌连接时涨 0.05 MB 预热),斜率 0。 --- src/net/uvcpp_tcp_client.cpp | 134 +++++++++---- src/net/uvcpp_tcp_client.h | 32 ++++ tests/functional/tcp_client_func.cpp | 202 ++++++++++++++++++++ tests/tools/run_tcp_client_dtor_mutation.py | 141 +++++++++++++- 4 files changed, 469 insertions(+), 40 deletions(-) diff --git a/src/net/uvcpp_tcp_client.cpp b/src/net/uvcpp_tcp_client.cpp index 3b40855f..fac3409f 100644 --- a/src/net/uvcpp_tcp_client.cpp +++ b/src/net/uvcpp_tcp_client.cpp @@ -13,6 +13,7 @@ #include #include // 握手超时(见 tls_arm_handshake_timer) +#include #include #include #include @@ -58,6 +59,22 @@ static void trampoline_close(void* arg) { delete cb; } +// ========================================================================= +// 回收支路计数(诊断用,见 uvcpp_tcp_client::reclaim_stat) +// ========================================================================= + +static std::atomic g_reclaim_released{0}; +static std::atomic g_reclaim_deferred{0}; +static std::atomic g_reclaim_skipped{0}; + +uvcpp_tcp_client::reclaim_stat uvcpp_tcp_client::reclaim_stats() { + reclaim_stat s; + s.released = g_reclaim_released.load(std::memory_order_relaxed); + s.deferred = g_reclaim_deferred.load(std::memory_order_relaxed); + s.skipped = g_reclaim_skipped.load(std::memory_order_relaxed); + return s; +} + // ========================================================================= // Construction / Destruction // ========================================================================= @@ -94,9 +111,9 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { if (alive_token_) *alive_token_ = 1; // ------------------------------------------------------------------- - // **本对象是在它自己的某个回调里被析构的** —— 这一路上面那套收尾动作一件 - // 都不能做,整块交出去。判据是循环还在跑(`run()` 没返回),也就是栈上还 - // 压着某个回调。 + // **本对象可能是在它自己的某个回调里被析构的**(服务端最常见:读回调里 + // "对端断了" → 关闭回调 → 关闭管理器 `delete client`)。这一路上面那套 + // 收尾动作一件都不能做,`loop_` 与那几个 `*_arg_` 堆块只能留给事件循环。 // // 三层悬垂,全部**实测**过(`tcp_client_func.cpp` 的 `delete_in_read_cb`, // 读回调里 `delete victim`;Release 裸跑**一次都不崩** —— 那块内存还好端端 @@ -114,41 +131,87 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { // 3. **`loop_`**:外层那一帧 `uv_run` 返回之后还要用它(`uvcpp_loop::run` // 的嵌套计数也要减)。 // - // 所以这一路**一个都不拆**:loop、tcp、TLS、那些 `*_arg_` 块全部留给循环。 - // 这是**有意的泄漏**,与 `~uvcpp_ws_client` 同一处逐条列过的那条策略 - // ("泄漏一块仍然有效的内存,换掉一个必然发生的 use-after-free"),也与 - // `uvcpp_ws_sessions::abandon()` 同一个形状。 + // **先停读**:句柄还活着、不停读的话循环下一轮还会回头调 `net_read_cb_` + // —— 那已经是释放过的内存。只调底层那句(`tcp_->read_stop()`),**不能**调 + // 本类的 `read_stop()`:后者会把 `net_read_cb_` / `read_arg_` 就地清掉, + // 那正是第 1 层。 + // + // ------------------------------------------------------------------- + // **但包装对象本身不必跟着一起漏** —— 什么时候能删,取决于句柄处在哪一步 + // 而不是"循环是不是在跑"。判据是**句柄还在不在**: + // + // - **句柄已摘**(`get_handle() == nullptr`)⇒ 此刻栈上唯一可能压着的本 + // 客户端回调是 `callback_close` 手里那份**栈上拷贝**的关闭回调 + // (`uvcpp_handle::callback_close` 先把 `handle_close_cb` 拷到局部、 + // 再把成员清空,然后才调用),而它返回之后**不再碰 wrapper**。读 / 写 / + // 连接那几条都不可能:它们的闭包要 `uv_read_start` 之类活着才开得起来, + // 而句柄摘掉正是 `callback_close` 干的、那之后循环里已经没有本句柄。 + // 所以这一支可以**当场 `delete`**。 + // + // - **句柄还活着** ⇒ 栈上可能就是本客户端自己的读 / 写 / 连接回调,而那个 + // 闭包(`net_read_cb_` / `stream_read_cb` / `write_arg_`)就存在 `tcp_` + // 里面,外层还压着 `uvcpp_stream::callback_read` 那一帧。这时不能删, + // 但**也不必漏**:把包装对象交给**它自己的关闭完成回调**去删。 + // `uv_close` 的完成回调在**本轮 `uv_run` 的收尾阶段** + // (`uv__run_closing_handles`)跑 —— 那时当前这一帧早已解开;而 + // `callback_close` 保证调用之后不再碰 wrapper(见上)。 + // 顺带:这一句仍然必须发,它不只是"顺手回收" —— 句柄留在队列上、既不 + // 活跃也没在关,`uv__loop_alive()` 一个条件都不占,`uv_run` 连 while 体 + // 都不进,`uv_loop_close()` 从此永远 EBUSY,`~uvcpp_loop` 只能把整块 + // `uv_loop_t` 泄漏掉(实测 `tests/tools/run_loop_leak_probe.py`: + // `tcp_server` 析构里那些"清不掉"的 `tcp(active=0 closing=0)`、以及 + // webapp 一族 77 个泄漏循环,全走这一条)。 + // + // - **句柄正在关**(别人已经 `uv_close` 过)⇒ 关闭回调那个槽被占了 + // (`handle_close_cb` 只有一个),插不进去,这一支只能留下 —— 这是**唯一 + // 剩下的、真正的泄漏**,而它在实测的两条 churn 路径上都没出现过 + // (`tests/tools` 的探针:net 层 2000 次、webapp 1000 次,`is_closing` + // 全为假)。 + // + // `is_closing()` 那一问不能省:覆写正在执行的那个闭包本身就是第 1 层悬垂。 // - // **先停读**:句柄已经交出去了、还活着,不停读的话循环下一轮还会回头调 - // `net_read_cb_` —— 那已经是释放过的内存。只调底层那句(`tcp_->read_stop()`), - // **不能**调本类的 `read_stop()`:后者会把 `net_read_cb_` / `read_arg_` 就地 - // 清掉,那正是第 1 层。 + // **这一支原先是一个都不删的**,理由是"判据是循环还在跑"。那个判据区分不了 + // "我在自己的回调里"和"循环只是恰好跑着",而服务运行期间后者恒成立 —— + // 于是 `tcp_` 按连接数线性累积(实测 net 层 0.55–0.71 MB/min、webapp + // 0.74–1.17 MB/min,都不收敛)。 // ------------------------------------------------------------------- if (loop_ != nullptr && loop_->is_running()) { - if (tcp_ != nullptr && tcp_->get_handle() != nullptr) { - tcp_->read_stop(); - // **句柄本身还是要关掉**,只有包装对象留下。 - // - // 上面三条说的都是"不能拆包装对象"(`tcp_` / `loop_` / 那些 `*_arg_`), - // 关句柄不在此列:`uv_close` 不动包装对象的生命周期,外层那一帧 - // `uvcpp_stream::callback_read` 手里的 `uvcpp_tcp*` 照样有效,而它写的是 - // `handle_close_cb`(包装对象的成员),不是正在执行的那个读闭包。 - // - // 反过来漏掉这一句就会把**整个循环**赔进去:句柄留在队列上、既不活跃 - // 也没在关 —— `uv__loop_alive()` 算的是「活跃句柄 || 活跃请求 || - // pending_reqs_tail || endgame_handles」,这一种**一个都不占**,于是 - // `uv_run` 连 while 体都不进,`uv_loop_close()` 从此永远 EBUSY, - // `~uvcpp_loop` 只能按既有策略把整块 `uv_loop_t` 泄漏掉。实测 - // (`tests/tools/run_loop_leak_probe.py`,2026-09-17):`tcp_server` - // 的析构里那些"清不掉"的 `tcp(active=0 closing=0)` 就是这里漏出去的, - // webapp 一族 77 个泄漏循环全走这一条。 - // - // `is_closing()` 那一问不能省:本函数也可能是从**关闭回调里**被调到的 - // (`fire_close_callbacks` 的最后一个槽会 `delete` 客户端),那时句柄已经 - // 在关,再 `uv_close` 一次是重复入队;而且此刻正在执行的那个闭包**就是** - // `handle_close_cb` 自己,覆写它就是第 1 层。句柄关完之后 - // `get_handle()` 已经是 nullptr,所以这一步与上面那句合并成同一个守卫。 - if (!tcp_->is_closing()) tcp_->close([](uvcpp_handle*) {}); + if (tcp_ != nullptr) { + const bool handle_detached = (tcp_->get_handle() == nullptr); + if (handle_detached) { + // 见上:句柄摘掉之后,没有谁的帧还压着这个包装对象。 + delete tcp_; + g_reclaim_released.fetch_add(1, std::memory_order_relaxed); + if (read_arg_ != nullptr) { + delete static_cast*>(read_arg_); + read_arg_ = nullptr; + } + } else { + tcp_->read_stop(); + if (!tcp_->is_closing()) { + // 交给自己的关闭完成回调 —— 那时这一帧已经解开(见上)。 + // + // `read_arg_` 那块也一并交出去:它**不由任何 libuv 请求持有** + // (`trampoline_read` 明确不删它 —— 读回调是长期注册,会反复进来; + // 写/连接那几块则由各自的完成回调 `delete`),而读注册上面刚 + // `read_stop` 掉,所以"这一帧解开之后再删"是安全的。漏掉它就是 + // 每次连接漏一个 `std::function` 块 —— 实测那是剩下的大头。 + void* ra = read_arg_; + read_arg_ = nullptr; + tcp_->close([ra](uvcpp_handle* wrapper) { + delete static_cast*>(ra); + delete wrapper; + g_reclaim_deferred.fetch_add(1, std::memory_order_relaxed); + }); + } else { + // 槽被占,只能留下(见上)。 + g_reclaim_skipped.fetch_add(1, std::memory_order_relaxed); + std::fprintf(stderr, + "[uvcpp_tcp_client] 析构时句柄正在关闭:包装对象的" + "关闭回调槽已被占用,只能留在事件循环里\n"); + } + } + tcp_ = nullptr; } #if UVCPP_OPENSSL_ENABLE // TLS 读路径上 `tls_ssl_` 正压在栈上(`tls_feed` / `tls_flush_out` 还在 @@ -188,7 +251,6 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { } #endif loop_ = nullptr; - tcp_ = nullptr; read_arg_ = nullptr; connect_arg_ = nullptr; write_arg_ = nullptr; diff --git a/src/net/uvcpp_tcp_client.h b/src/net/uvcpp_tcp_client.h index d4154e08..37bc4372 100644 --- a/src/net/uvcpp_tcp_client.h +++ b/src/net/uvcpp_tcp_client.h @@ -12,6 +12,7 @@ #ifndef SRC_NET_UVCPP_TCP_CLIENT_H #define SRC_NET_UVCPP_TCP_CLIENT_H +#include #include #include #include @@ -543,6 +544,37 @@ class UVCPP_API uvcpp_tcp_client { /** @brief 当前挂着几个关闭观察者。 */ size_t close_observer_count() const; + /** + * @brief 析构时**包装对象最终归宿**的三条支路各走了多少次(诊断用)。 + * + * 析构发生在事件循环的回调里时,"包装对象"(`uvcpp_tcp` 那个 wrapper) + * 什么时候能释放取决于句柄处在哪一步,三条支路**对外行为完全一样** —— + * 从外面看只是连接断了,看不出走的是哪一条,也看不出有没有走。这个计数 + * 把它变成可观测量,回归用例才有判据: + * + * - `released`:句柄已摘(`get_handle() == nullptr`),当场释放; + * - `deferred`:句柄还活着,交给它自己的关闭完成回调去释放; + * - `skipped` :句柄正在关闭、关闭回调槽被占,只能留下(唯一的真泄漏)。 + * + * 计的是**释放真的做成了**,不是"打算这么做":`released` 记在 `delete` + * 之后、`deferred` 记在那个延迟的 `delete` 里面。所以把释放动作删掉(或退 + * 回不释放的老写法)会让计数**停在原地**,用例当场红 —— 不必等内存涨到能 + * 测出来。 + * + * 判据是 `released + deferred` 随断开次数一起涨(`deferred` 要等收尾拨完 + * 才记上),且 `skipped` 恒为 0。 + * + * 进程级累计,不随单个客户端归零。 + */ + struct reclaim_stat { + uint64_t released; ///< 句柄已摘:当场释放 + uint64_t deferred; ///< 句柄还在:交给关闭完成回调,在那里释放 + uint64_t skipped; ///< 关闭回调槽被占:只能留下(应当恒为 0) + }; + + /** @brief 读一次回收支路计数。见 `reclaim_stat`。 */ + static reclaim_stat reclaim_stats(); + // ----------------------------------------------------------------- // 框架内部:自动读 // ----------------------------------------------------------------- diff --git a/tests/functional/tcp_client_func.cpp b/tests/functional/tcp_client_func.cpp index 56a2fd94..32fdecde 100644 --- a/tests/functional/tcp_client_func.cpp +++ b/tests/functional/tcp_client_func.cpp @@ -780,6 +780,207 @@ static bool test_delete_client_in_read_cb(int port, int hangup_port) { return ok; } +// ========================================================================= +// 析构时**包装对象到底有没有被回收** —— 连接频繁建立/断开那条路 +// ========================================================================= +/** + * 形状:服务跑着,连接不停地来、不停地断。**每一次断开都会析构一个 + * `uvcpp_tcp_client`**,而析构的位置几乎总在某个回调里(对端断开的读回调、 + * 关闭的完成回调)—— 也就是 `loop_->is_running()` 恒为真的地方。 + * + * 改前那条判据是"循环还在跑 ⇒ 一个都不拆",它区分不了"我正踩在自己的回调 + * 里"和"循环只是恰好跑着"。后者在服务运行期间永远成立,于是 `tcp_` 那个 + * 包装对象**一次也没被拆过**:实测 net 层 371 字节/连接(20000 次连接 + * +7.07 MB,斜率不收敛),webapp 0.74–1.17 MB/min。 + * + * 判据为什么不取 RSS:那是**间接**量,慢、噪声大、CI 上没法当断言(涨到能 + * 测出来要几千次连接)。改后的判据是**三条支路各走了多少次** + * (`uvcpp_tcp_client::reclaim_stats()`)—— 析构里那三条支路对外行为完全 + * 一样(连接都断了),从外面看不出来走了哪一条,所以把它记成计数。一次断开 + * 必须让 `released + deferred` 涨 1、且 `skipped` 恒为 0。这样"把回收改回 + * 不回收"就是**确定性**的失败,不用等内存涨起来。 + * + * 另有一道独立判据:收起尾来 `uv_loop_close()` 必须返回 0。漏掉包装对象时 + * 它的 `uv_handle_t` 还挂在 handle_queue 上(既不活跃也没在关 —— `uv_run` + * 连 while 体都不进),`uv_loop_close()` 只能 EBUSY。这条**不需要新计数** + * 就能抓住老写法,是计数器之外的独立证人。 + * + * 两条腿各钉一种"回调返回之后还剩什么"(用**外部循环**,这样析构不会连循环 + * 一起漏掉,收尾后还能问 `uv_loop_close()`): + * + * 腿 1 对端断开,框架管理槽删客户端 —— 句柄还活着 ⇒ `deferred` + * 腿 2 自己 `close()`,完成回调里删 —— 句柄已摘 ⇒ `released` + */ +static bool test_reclaim_paths(int port, int hangup_port) { + std::cout << "[functional tcp_client] reclaim_paths start\n"; + bool ok = true; + + auto pump_until = [](uvcpp_loop* loop, const std::function& done, + int timeout_ms) { + const auto t0 = std::chrono::steady_clock::now(); + while (!done() && + std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count() < timeout_ms) { + loop->run(UV_RUN_NOWAIT); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }; + + // ---- 腿 1:对端断开时删(句柄还活着 ⇒ deferred)---- + { + uvcpp_loop loop; + + uvcpp_test::loop_drain drain_loop(&loop); + loop.init(); + + const uvcpp_tcp_client::reclaim_stat before = + uvcpp_tcp_client::reclaim_stats(); + + uvcpp_tcp_client* victim = new uvcpp_tcp_client(&loop); + bool deleted = false; + + // 框架的管理槽 = "连接一关就把这个客户端删掉",服务端(`uvcpp_tcp_server`) + // 用的正是这一条。它在**读回调里**跑(`fire_close_callbacks()` 的第 1337 行 + // 那一路),此刻句柄还活着。 + victim->set_close_manager([&victim, &deleted]() { + delete victim; + victim = nullptr; + deleted = true; + }); + + int rc = victim->connect("127.0.0.1", hangup_port, [&](int status) { + if (status != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg1 connect " + << uv_err_name(status) << std::endl; + return; + } + // 对端 accept 完立刻 FIN:要读到它才走得到断开那一支。 + victim->read_start_events( + [](uvcpp_tcp_client& /*c*/, const net_read_result& /*r*/) {}); + }); + if (rc != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg1 connect start " + "failed: " << uv_err_name(rc) << std::endl; + delete victim; + return false; + } + + pump_until(&loop, [&] { return deleted; }, 5000); + + // 先把收尾队列拨干净**再读计数**:`deferred` 那一条是记在延迟的 + // `delete` 里的,没拨完就还是 0(那不是漏,是还没到)。 + uvcpp_test::drain(&loop); + + const uvcpp_tcp_client::reclaim_stat after = + uvcpp_tcp_client::reclaim_stats(); + const uint64_t d_deferred = after.deferred - before.deferred; + const uint64_t d_released = after.released - before.released; + const uint64_t d_skipped = after.skipped - before.skipped; + + if (victim != nullptr || !deleted) { + std::cout << "[functional tcp_client] reclaim_paths leg1 FAIL " + "链路没走完:deleted=" << deleted + << " victim=" << static_cast(victim) << std::endl; + ok = false; + } else if (d_deferred != 1 || d_released != 0 || d_skipped != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg1 FAIL 支路不对:" + "deferred+=" << d_deferred << " released+=" << d_released + << " skipped+=" << d_skipped << "(应 1/0/0)" << std::endl; + ok = false; + } else { + std::cout << "[functional tcp_client] reclaim_paths leg1 deferred " + "survived\n"; + } + + // 独立证人:包装对象真被拆干净了,handle_queue 才是空的。老写法(一个 + // 都不拆)漏掉的那个 `uv_handle_t` 会一直挂在队列上 —— 既不活跃也没在 + // 关,`uv_run` 连 while 体都不进,这里只能 EBUSY。 + const int lrc = loop.loop_close(); + if (lrc != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg1 FAIL " + "loop_close rc=" << lrc << "(UV_EBUSY = 还有句柄挂在 " + "handle_queue 上)" << std::endl; + ok = false; + } + } + + // ---- 腿 2:自己关,完成回调里删(句柄已摘 ⇒ released)---- + { + uvcpp_loop loop; + + uvcpp_test::loop_drain drain_loop(&loop); + loop.init(); + + const uvcpp_tcp_client::reclaim_stat before = + uvcpp_tcp_client::reclaim_stats(); + + uvcpp_tcp_client* victim = new uvcpp_tcp_client(&loop); + bool deleted = false; + bool connected = false; + + victim->set_close_manager([&victim, &deleted]() { + delete victim; + victim = nullptr; + deleted = true; + }); + + int rc = victim->connect("127.0.0.1", port, + [&](int status) { connected = (status == 0); }); + if (rc != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg2 connect start " + "failed: " << uv_err_name(rc) << std::endl; + delete victim; + return false; + } + pump_until(&loop, [&] { return connected || deleted; }, 5000); + + if (!connected || victim == nullptr) { + std::cout << "[functional tcp_client] reclaim_paths leg2 FAIL 没连上:" + "connected=" << connected << std::endl; + ok = false; + } else { + // 主动关:完成回调里 `fire_close_callbacks()` 会跑管理槽,而那一刻 + // `uvcpp_handle::callback_close` 已经把 `_handle` 置空了。 + victim->close(); + pump_until(&loop, [&] { return deleted; }, 5000); + + uvcpp_test::drain(&loop); + + const uvcpp_tcp_client::reclaim_stat after = + uvcpp_tcp_client::reclaim_stats(); + const uint64_t d_released = after.released - before.released; + const uint64_t d_deferred = after.deferred - before.deferred; + const uint64_t d_skipped = after.skipped - before.skipped; + + if (victim != nullptr || !deleted) { + std::cout << "[functional tcp_client] reclaim_paths leg2 FAIL " + "链路没走完:deleted=" << deleted + << " victim=" << static_cast(victim) << std::endl; + ok = false; + } else if (d_released != 1 || d_deferred != 0 || d_skipped != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg2 FAIL 支路不对:" + "released+=" << d_released << " deferred+=" << d_deferred + << " skipped+=" << d_skipped << "(应 1/0/0)" << std::endl; + ok = false; + } else { + std::cout << "[functional tcp_client] reclaim_paths leg2 released " + "survived\n"; + } + } + + const int lrc = loop.loop_close(); + if (lrc != 0) { + std::cout << "[functional tcp_client] reclaim_paths leg2 FAIL " + "loop_close rc=" << lrc << std::endl; + ok = false; + } + } + + std::cout << "[functional tcp_client] reclaim_paths done success=" + << (ok ? "true" : "false") << std::endl; + return ok; +} + // ========================================================================= // 析构里**不许有睡眠**:判据是绝对墙钟,不是"A 比 B 快" // ========================================================================= @@ -891,6 +1092,7 @@ int main() { all_ok = test_status_transitions(port) && all_ok; all_ok = test_async_buf_write_no_poison(port) && all_ok; all_ok = test_delete_client_in_read_cb(port, hangup_port) && all_ok; + all_ok = test_reclaim_paths(port, hangup_port) && all_ok; all_ok = test_destructor_has_no_sleep(port) && all_ok; // Stop server diff --git a/tests/tools/run_tcp_client_dtor_mutation.py b/tests/tools/run_tcp_client_dtor_mutation.py index 8e653e90..5b73d0be 100644 --- a/tests/tools/run_tcp_client_dtor_mutation.py +++ b/tests/tools/run_tcp_client_dtor_mutation.py @@ -15,7 +15,7 @@ (收数据时删 / 对端断开时删)。判据只能靠 **PageHeap**(把释放过的块 直接 unmap)—— 裸跑时那块内存还在、读到的还是旧值,所以不崩。 -三个动作各钉一处,所以打五个变异: +三个动作各钉一处,所以打七个变异: 变异 预期 -------------------------------- -------------------------------------------- @@ -27,11 +27,39 @@ 证明这条判据自己不依赖 PageHeap) M5 开头的 `is_running()` 守卫关掉 崩(PageHeap)—— 重入 `uv_run`、 当场 `delete loop_` / `delete tcp_` + M6 退回"包装对象一个都不拆"的老写法 `reclaim_paths` 两条腿 FAIL + (**不开 PageHeap**)—— 计数停在 + 原地,腿 1 的 `loop_close()` 还 EBUSY + M7 句柄还活着也当场 `delete` 崩(PageHeap)—— 外层 + `uvcpp_stream::callback_read` 那一帧 + 还在包装对象里跑 M1/M2 为什么必须分开打:两处**长得很像但钉的不是同一件事**。M1 那条腿里, 回调返回之后只剩 `uvcpp_free_bytes(base)`(局部量);M2 那条腿里,回调返回 之后还有 `fire_close_callbacks()`(全是成员)。 +M6/M7 钉的是第 10 条之后新修的**包装对象回收**(连接频繁建立/断开时的线性 +累积):M6 就是那个缺陷本身(老代码里"循环还在跑 ⇒ 一个都不拆"),M7 是修它 +时最容易踩的新坑(把判据放宽成"能删就删")。M6 特意**不开页堆** —— 它抓住的 +是计数与 `uv_loop_close()`,两条判据都不依赖 PageHeap。 + +**M7 没抓住,判为等价变异**(不是覆盖缺口 —— 写在这里,免得下次把它当成功效 +证据):把"交给关闭完成回调去删"换成"当场 `delete`"之后,6 条腿在整页堆下全绿。 +读过一遍就明白为什么不崩: + + - 包装对象自己的那几个 trampoline(`uvcpp_stream::callback_read`、 + `uvcpp_handle::callback_alloc`、`uvcpp_connect::callback_connect`)**都是先 + 把闭包拷到局部再调**(源码里写明了"回调里 `delete self` 是合法用法"), + 回调返回之后一个字节都不再碰 wrapper; + - `delete tcp_` 落到 `uvcpp_handle::free_handle()`,它按句柄**当时的状态**分派: + 正在关 → 塞个 detached 哨兵就返回(底层内存留给已经排队的 `callback_close`)、 + 活跃或"init 过但没 start" → 自己补一次 `uv_close`、从没 init 过 → 才直接还内存。 + 三条路都不会把"还挂在 `loop->handle_queue` 上"的内存还掉。 + +所以"当场删"与"交给关闭回调删"在**这几条腿**上行为一致,这一条不区分两者。 +留着它的理由不是这几条腿,而是**构造不出来的那些帧**(见 PR 正文末节)—— +这一支要不要简化成无条件 `delete`,请作者定。 + gflags 的两个坑(都踩过): - 在 Git Bash 里直接写 `gflags -p /enable …` 会被 MSYS 的路径转换把 `/enable`、`/full` 变成盘符路径,命令**静默不生效**(退出码还是 0)。 @@ -39,6 +67,12 @@ - 开/关之后必须**回查注册表确认**(`GlobalFlag & 0x02000000`), 跑完必须关掉并再次回查 —— 页堆残留会让后面所有测试都慢一个量级。 +本机**没有 `gflags.exe`**(`Windows Kits\\10\\Debuggers\\x64` 下只有 +dbghelp/dbgcore/srcsrv 那三个可再发行件),所以 `set_pageheap()` 会走 +`set_pageheap_direct()`:直接写同一个 IFEO 键(`GlobalFlag` + +`PageHeapFlags=0x1`,少了后者只是标准页堆、抓不住"释放后读"),写完同样回查。 +两条路的判据完全一样,都是注册表里那个位。 + 用法:python -u tests/tools/run_tcp_client_dtor_mutation.py [--tree build-webapp] """ @@ -105,6 +139,31 @@ " if (loop_ != nullptr && loop_->is_running()) {\n", " if (false && loop_ != nullptr && loop_->is_running()) { /* MUTATION */\n", True), + ("M6 退回不拆包装对象", + CLIENT_CPP, + " if (loop_ != nullptr && loop_->is_running()) {\n" + " if (tcp_ != nullptr) {\n", + " if (loop_ != nullptr && loop_->is_running()) {\n" + " if (false && tcp_ != nullptr) { /* MUTATION: 一个都不拆,退回老写法 */\n", + False), + ("M7 句柄还活着就当场删", + CLIENT_CPP, + " void* ra = read_arg_;\n" + " read_arg_ = nullptr;\n" + " tcp_->close([ra](uvcpp_handle* wrapper) {\n" + " delete static_cast*>(ra);\n" + " delete wrapper;\n" + " g_reclaim_deferred.fetch_add(1, std::memory_order_relaxed);\n" + " });\n", + " void* ra = read_arg_;\n" + " read_arg_ = nullptr;\n" + " /* MUTATION: 不看句柄死活,当场删 */\n" + " delete tcp_;\n" + " if (ra != nullptr) {\n" + " delete static_cast*>(ra);\n" + " }\n" + " g_reclaim_deferred.fetch_add(1, std::memory_order_relaxed);\n", + True), ] RUN_TIMEOUT_S = 300 @@ -160,6 +219,19 @@ def sync_dll(tree): return n +def find_gflags(): + for env in ("ProgramFiles(x86)", "ProgramFiles"): + root = os.environ.get(env) + if not root: + continue + for kits in ("10", "8.1"): + p = os.path.join(root, "Windows Kits", kits, "Debuggers", "x64", + "gflags.exe") + if os.path.exists(p): + return p + return None + + def pageheap_on(exe_path): if winreg is None: return None @@ -183,8 +255,66 @@ def gflags(args): return run([exe] + args, timeout=60) +def set_pageheap_direct(exe_path, on): + """**不依赖 gflags**:直接写 IFEO 键,写完同样回查注册表。 + + 给没装「Debugging Tools for Windows」的机器兜底 —— 本机 + `Windows Kits\\10\\Debuggers\\x64` 下只有 `dbghelp.dll` / `dbgcore.dll` / + `srcsrv.dll`,没有 `gflags.exe`(只有调试器**可再发行**那部分)。 + gflags 做的本来也就是写这两个值,所以这里等价。 + + 两个值缺一不可: + - `GlobalFlag` = `0x02000000`(`FLG_HEAP_PAGE_ALLOCS`)—— 打开页堆; + - `PageHeapFlags` = `0x1`(full)—— **整页堆**:释放即 unmap。 + 少了它只是标准页堆,释放过的块填 `0xFEEEFEEE` 但**页还在**, + 于是"释放后读"读得到、不崩 —— 这个门禁要抓的恰恰就是读。 + + 机制单独验过(`malloc(64) → free → 读` 的探针):裸跑 rc=0,这样设上之后 + 直接 `SIGSEGV`(rc=139),删掉键又回到 rc=0。 + """ + if winreg is None: + return False, "winreg 不可用(非 Windows)" + name = os.path.basename(exe_path) + key = IFEO + "\\" + name + try: + with winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, key, 0, + winreg.KEY_ALL_ACCESS) as k: + if on: + winreg.SetValueEx(k, "GlobalFlag", 0, winreg.REG_SZ, + "0x%08x" % FLG_HEAP_PAGE_ALLOCS) + winreg.SetValueEx(k, "PageHeapFlags", 0, winreg.REG_SZ, "0x1") + else: + for v in ("GlobalFlag", "PageHeapFlags"): + try: + winreg.DeleteValue(k, v) + except FileNotFoundError: + pass + except OSError as e: # 需要管理员权限 + return False, "写注册表失败:%s" % e + if not on: + # 值都删完了就把空键也收掉,别在 IFEO 里留垃圾 + try: + winreg.DeleteKeyEx(winreg.HKEY_LOCAL_MACHINE, key, + winreg.KEY_WOW64_64KEY, 0) + except OSError: + pass + state = pageheap_on(name) + return (state == on), "直接写注册表,page heap=%s" % state + + def set_pageheap(exe_path, on): - """开/关之后**回查注册表**确认 —— gflags 静默失效过一次(见文件头)。""" + """开/关之后**回查注册表**确认 —— gflags 静默失效过一次(见文件头)。 + + 本机没有 `gflags.exe`,所以走 `set_pageheap_direct()`。两条路都会回查, + 任一条只要注册表没跟上就算失败。 + """ + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, IFEO) as k: + pass + except OSError: + return False, "IFEO 键打不开" + if find_gflags() is None: + return set_pageheap_direct(exe_path, on) args = ["-p", "/enable" if on else "/disable", exe_path] if on: args.append("/full") @@ -240,14 +370,17 @@ def main(): n = sync_dll(tree) erc, eout = run([exe_path], cwd=exe_dir) + # 别用 `(\S+ FAIL.*)`:用例名里带空格的(`reclaim_paths leg1 FAIL …`、 + # `delete_in_read_cb leg1 FAIL …`)匹配不上,报出来是空的 —— 判据本来 + # 就只看退出码,这一列只是明细,静默为空会让人以为"没红"。 fails = [s.strip() for s in - re.findall(r"\[functional tcp_client\] (\S+ FAIL.*)", eout)] + re.findall(r"\[functional tcp_client\] (.*FAIL.*)", eout)] timed = [s.strip() for s in re.findall(r"(destructor_no_sleep best=.*)", eout)] if heap: verdict = "**崩了**(抓)" if erc != 0 else "没崩 —— 没抓住!" else: - verdict = ("**计时腿红了**(抓)" if erc != 0 + verdict = ("**有腿红了**(抓)" if erc != 0 else "全绿 —— 没抓住!") summary.append((label, "rc=%s %s" % (erc, verdict))) print(f"\n[{label}] dll 同步 {n} 处;{'PageHeap 开' if heap else '裸跑'}" From 3310068e0044a554cba631dda7c38c3d41f75827 Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sun, 20 Sep 2026 04:17:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(net):=20=E6=8F=A1=E6=89=8B=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E5=AE=9A=E6=97=B6=E5=99=A8=E7=9A=84=E5=8C=85=E8=A3=85?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=9C=A8=E8=B6=85=E6=97=B6=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E9=87=8C=E6=9E=90=E6=9E=84=E6=97=B6=E5=90=8C=E6=A0=B7=E5=9B=9E?= =?UTF-8?q?=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d12188c 在 ~uvcpp_tcp_client 里处理 tls_hs_timer_ 时,把「析构发生在定时器 回调里」那一支写成「只关句柄、包装对象留在原地」,理由写的是「理由同上」 —— 指的是上面 tcp_ 那段。上面那段现在已经不「留在原地」了(包装对象交给 关闭完成回调去删),所以这个「同上」指不通;这里按同一个理由改过来。 与前一个提交不同,这一处**没有实测**:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到,非 TLS 路径上它恒为 0。依据仍是 uvcpp_handle::callback_close 的顺序保证(先拷闭包、清成员、释放 uv_handle_t 内存,之后不再碰 wrapper) 加上 uv_close 的完成回调跑在本轮 uv_run 的收尾阶段。 计数进同一组 reclaim_stat:它的口径是「本客户端在析构时要放掉的包装对象」。 --- src/net/uvcpp_tcp_client.cpp | 15 ++++++++++++--- src/net/uvcpp_tcp_client.h | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/net/uvcpp_tcp_client.cpp b/src/net/uvcpp_tcp_client.cpp index fac3409f..6977a308 100644 --- a/src/net/uvcpp_tcp_client.cpp +++ b/src/net/uvcpp_tcp_client.cpp @@ -239,13 +239,22 @@ uvcpp_tcp_client::~uvcpp_tcp_client() { if (tls_hs_timer_ != nullptr) { tls_hs_timer_->stop(); if (tls_hs_in_cb_) { - // 包装对象留在原地(理由同上),句柄单独关掉 —— 与上面 `tcp_` 那句 - // 同一个形状:`uv_close` 是异步的,完成回调回来时包装对象仍然有效。 + // 与上面 `tcp_` 同一处理:句柄单独关掉,**包装对象交给它的关闭完成 + // 回调去删**。原先这里是"包装对象留在原地",理由是"同上" —— 而上面 + // 那句现在不算在"原地"了,所以这里是照同一个理由改过来,不是新加 + // 的依赖:`uv_close` 是异步的,完成回调跑在本轮 `uv_run` 的收尾阶段, + // 那时定时器自己的回调帧早就解开了。 if (!tls_hs_timer_->is_closing()) { - tls_hs_timer_->close([](uvcpp_handle*) {}); + tls_hs_timer_->close([](uvcpp_handle* wrapper) { + delete wrapper; + g_reclaim_deferred.fetch_add(1, std::memory_order_relaxed); + }); + } else { + g_reclaim_skipped.fetch_add(1, std::memory_order_relaxed); } } else { delete tls_hs_timer_; + g_reclaim_released.fetch_add(1, std::memory_order_relaxed); } tls_hs_timer_ = nullptr; } diff --git a/src/net/uvcpp_tcp_client.h b/src/net/uvcpp_tcp_client.h index 37bc4372..c5e2623a 100644 --- a/src/net/uvcpp_tcp_client.h +++ b/src/net/uvcpp_tcp_client.h @@ -556,6 +556,10 @@ class UVCPP_API uvcpp_tcp_client { * - `deferred`:句柄还活着,交给它自己的关闭完成回调去释放; * - `skipped` :句柄正在关闭、关闭回调槽被占,只能留下(唯一的真泄漏)。 * + * 统计对象是**本客户端在析构时要放掉的全部包装对象**:`tcp_`,以及 + * (`UVCPP_OPENSSL_ENABLE` 下)握手超时用的那个定时器 —— 后者只在 TLS + * 握手超时那一条路上活到析构,非 TLS 路径上恒为 0。 + * * 计的是**释放真的做成了**,不是"打算这么做":`released` 记在 `delete` * 之后、`deferred` 记在那个延迟的 `delete` 里面。所以把释放动作删掉(或退 * 回不释放的老写法)会让计数**停在原地**,用例当场红 —— 不必等内存涨到能