diff --git a/src/net/uvcpp_tcp_client.cpp b/src/net/uvcpp_tcp_client.cpp index 3b40855f..6977a308 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 层。 // - // **先停读**:句柄已经交出去了、还活着,不停读的话循环下一轮还会回头调 - // `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 层悬垂。 + // + // **这一支原先是一个都不删的**,理由是"判据是循环还在跑"。那个判据区分不了 + // "我在自己的回调里"和"循环只是恰好跑着",而服务运行期间后者恒成立 —— + // 于是 `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` 还在 @@ -176,19 +239,27 @@ 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; } #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..c5e2623a 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,41 @@ class UVCPP_API uvcpp_tcp_client { /** @brief 当前挂着几个关闭观察者。 */ size_t close_observer_count() const; + /** + * @brief 析构时**包装对象最终归宿**的三条支路各走了多少次(诊断用)。 + * + * 析构发生在事件循环的回调里时,"包装对象"(`uvcpp_tcp` 那个 wrapper) + * 什么时候能释放取决于句柄处在哪一步,三条支路**对外行为完全一样** —— + * 从外面看只是连接断了,看不出走的是哪一条,也看不出有没有走。这个计数 + * 把它变成可观测量,回归用例才有判据: + * + * - `released`:句柄已摘(`get_handle() == nullptr`),当场释放; + * - `deferred`:句柄还活着,交给它自己的关闭完成回调去释放; + * - `skipped` :句柄正在关闭、关闭回调槽被占,只能留下(唯一的真泄漏)。 + * + * 统计对象是**本客户端在析构时要放掉的全部包装对象**:`tcp_`,以及 + * (`UVCPP_OPENSSL_ENABLE` 下)握手超时用的那个定时器 —— 后者只在 TLS + * 握手超时那一条路上活到析构,非 TLS 路径上恒为 0。 + * + * 计的是**释放真的做成了**,不是"打算这么做":`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 '裸跑'}"