全局异步 runtime 的 worker 用上 Runtime 自己的栈预算

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:41:20 +00:00
parent 5c2d722b12
commit 802f06d59d
2 changed files with 63 additions and 1 deletions
@@ -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 实际所在的线程名。
///
@@ -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::Runtime, String> {
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::<u64>();
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::<Vec<_>>();
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_trampoline_mode(&args) {