From 802f06d59d5a3736106cf5a198803cd98c78ab6f Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 20 Aug 2026 11:41:20 +0000 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=BC=82=E6=AD=A5=20runtime?= =?UTF-8?q?=20=E7=9A=84=20worker=20=E7=94=A8=E4=B8=8A=20Runtime=20?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E6=A0=88=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Runner 在父 run 认领委派回执那一刻整进程消失,调用方只看到 connect 超时, runner 日志里两行 stderr 又被日志泵按「非诊断输出」抹掉。放开原始 stderr 后拿到 真相:thread 'tokio-rt-worker' has overflowed its stack。 Runtime 的 agent turn 调用链本来就深,task_queue 早就给自己起的后台线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但静态委派的父 run 唤醒走的是 tauri::async_runtime::spawn,落在 Tauri 全局 runtime 的 worker 上——那个 runtime 由 TokioRuntime::new() 建出,worker 吃 tokio 默认栈(tokio 1.52 起该线程名就叫 tokio-rt-worker)。同一段代码在自家 16 MiB 线程上天天跑完整轮次,换到默认栈就爆, 所以不是无限递归,是那条链从来没被这个线程池的尺寸覆盖过。 进程入口在任何异步派发之前用同一个常量建 runtime 并 async_runtime::set 装上; handle 要求底层 Runtime 常驻,故刻意泄漏。这样十余处 async_runtime::spawn 一次 性都拿到同一份栈预算,而不是逐个改调用点。 回归用例在自建 runtime 上 spawn 一条 3 MiB 深的调用链。注意它的失败形态是整个 测试进程被 abort 而不是断言失败——已实测拿掉 thread_stack_size 后复现的正是线上 那行 tokio-rt-worker has overflowed its stack。 Co-Authored-By: Claude Opus 5 --- .../src/agent/runtime_driver/task_queue.rs | 2 +- .../src-tauri/src/main.rs | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index afab62f94..fec0877aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -7,7 +7,7 @@ use super::*; /// worker 栈」)。历史上只有「首个任务」入口用了专用线程,队列 drain 与恢复重启留在默认 /// 栈上;2026-08-15 实测两条路的栈需求只差一个 poll 帧,因此统一到同一个常量,避免再次 /// 出现「一半入口有保护、另一半没有」。 -pub(in crate::agent) const AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; /// 结构性回归钩子:记录主循环 drain 实际所在的线程名。 /// diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 1a9a9e17f..44fea8882 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1947,8 +1947,70 @@ mod game_chat_release_client_exit_tests { } } +/// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃 +/// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台 +/// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn +/// 派发的活(例如静态委派的父 run 唤醒)都落在这些默认栈的 worker 上,同一段代码 +/// 在那里直接 `thread 'tokio-rt-worker' has overflowed its stack` 把整个进程 abort +/// 掉——现场表现是父 run 认领委派回执那一刻 Agent Runner 无声消失,调用方只看到 +/// 连接超时。这里在任何异步派发之前把全局 runtime 换成同样栈尺寸的实例。 +/// +/// `async_runtime::set` 只接受 handle 且要求底层 Runtime 常驻,所以这里刻意泄漏。 +fn build_agent_runtime_async_runtime() -> Result { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(crate::agent::AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) + .build() + .map_err(|error| format!("创建全局异步 runtime 失败:{error}")) +} + +#[cfg(not(test))] +fn install_agent_runtime_async_runtime_with_deep_stack() { + let runtime = match build_agent_runtime_async_runtime() { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + tauri::async_runtime::set(runtime.handle().clone()); + // 进程生命周期内必须持有,否则 handle 立即失效。 + Box::leak(Box::new(runtime)); +} + +#[cfg(test)] +mod async_runtime_stack_tests { + /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 + fn consume_stack(depth: usize) -> u64 { + let mut frame = [0_u8; 16 * 1024]; + frame[depth % frame.len()] = depth as u8; + let sum = std::hint::black_box(&frame) + .iter() + .map(|byte| u64::from(*byte)) + .sum::(); + if depth == 0 { + sum + } else { + sum + consume_stack(depth - 1) + } + } + + /// 全局异步 runtime 的 worker 必须和 Runtime 自己的后台线程用同一份栈预算。 + /// 掉了 thread_stack_size 时这条不是断言失败而是整个测试进程被 abort——这正是 + /// 线上的失效形态:Agent Runner 在父 run 认领委派回执时无声消失。 + #[test] + fn async_runtime_workers_hold_a_call_chain_that_overflows_the_default_stack() { + const FRAMES: usize = 192; // 192 × 16 KiB = 3 MiB,超出 tokio 默认栈,远低于 16 MiB + let runtime = super::build_agent_runtime_async_runtime().expect("build async runtime"); + let handled = + runtime.block_on(async { tokio::spawn(async { consume_stack(FRAMES - 1) }).await }); + assert!(handled.is_ok(), "{handled:?}"); + } +} + #[cfg(not(test))] fn main() { + install_agent_runtime_async_runtime_with_deep_stack(); let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] if command_sandbox_trampoline::is_trampoline_mode(&args) {