prevent_exit 没拦住退出
主线位置:启动主线的终点,事件循环。
AppHandle::exit或关掉最后一个窗口时,事件循环发出RunEvent::ExitRequested,回调返回后立刻决定是否退出。
sequenceDiagram participant L as 事件循环 participant C as run 回调 participant T as 异步任务 L->>C: ExitRequested 带 tx C->>T: spawn 去问用户 C-->>L: 回调返回 L->>L: try_recv 为空 决定退出 T->>T: 50ms 后 prevent_exit Note over T: 进程已经没了 或 send 失败
实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建。页面调 report,report 里 app.exit(0)(MODE=close_* 时改为关掉主窗口)。平台 M2 Max、macOS 27、rustc 1.99.0。
现象
想在退出前问用户「有未保存的修改,确定退出吗?」,而对话框、检查未保存数据都是异步的,于是把 api 克隆进一个任务里,问完再决定拦不拦:
app.run(move |handle, event| match event {
RunEvent::ExitRequested { code, api, .. } => {
log(format!("ExitRequested {{ code: {code:?} }}"));
match run_mode.as_str() {
"exit_late" => {
let api = api.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
log("user said: stay, calling prevent_exit()");
api.prevent_exit();
log("prevent_exit() returned");
});
}
// ...
}
}
RunEvent::Exit => log("Exit"),
_ => {}
});
MODE=exit_late:
0ms [main] main
70ms [main] probe plugin setup
REPORT {
"mode": "exit_late",
"greet": "hello, rie"
}
511ms [main] ExitRequested { code: Some(0) }
511ms [main] Exit
exit=0
ExitRequested 之后紧跟着就是 Exit,进程退出码 0。任务里的那几行一行都没打印:进程在 50 毫秒之前就已经结束了。
如果晚到的调用赶在进程结束之前执行,还会多一个 panic。Builder、App 与 AppHandle 的实验 app-life 在另一个线程上 1 毫秒后调 prevent_exit:
881ms [main] plugin on_event ExitRequested { code: Some(0) }
881ms [main] app run callback ExitRequested { code: Some(0) }
881ms [main] plugin on_event Resumed
881ms [main] app run callback Resumed
883ms [<unnamed>] calling prevent_exit() after the callback returned
thread '<unnamed>' (2377323) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/app.rs:92:55:
called `Result::unwrap()` on an `Err` value: SendError { .. }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
885ms [main] plugin on_event Exit
885ms [main] app run callback Exit
exit=0
关窗口也是一样。on_window_event 里收到 CloseRequested,异步问完再 prevent_close,MODE=close_late:
"close_late" => {
log("CloseRequested: ask asynchronously, prevent_close later");
let api = api.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
log("user said: keep the window, calling prevent_close()");
api.prevent_close();
log("prevent_close() returned");
});
}
464ms [main] report: closing the main window
464ms [main] CloseRequested: ask asynchronously, prevent_close later
468ms [main] ExitRequested { code: None }
471ms [main] Exit
exit=0
窗口 4 毫秒后就关了;它是唯一的窗口,于是又触发 ExitRequested { code: None },应用退出。
出处
ExitRequestApi 就是一个 std::sync::mpsc::Sender 加上退出码:
#[derive(Debug, Clone)]
pub struct ExitRequestApi {
tx: Sender<ExitRequestedEventAction>,
code: Option<i32>,
}
impl ExitRequestApi {
/// Prevents the app from exiting.
///
/// **Note:** This is ignored when using [`AppHandle#method.restart`].
pub fn prevent_exit(&self) {
if self.code != Some(RESTART_EXIT_CODE) {
self.tx.send(ExitRequestedEventAction::Prevent).unwrap();
}
}
}
接收端在 tauri-runtime-wry 的事件循环里。exit(code) 发来的 Message::RequestExit 是这样处理的:
Message::RequestExit(code) => {
let (tx, rx) = channel();
callback(RunEvent::ExitRequested {
code: Some(code),
tx,
});
let recv = rx.try_recv();
let should_prevent = matches!(recv, Ok(ExitRequestedEventAction::Prevent));
if !should_prevent {
*control_flow = ControlFlow::ExitWithCode(code);
}
}
callback 返回后只 try_recv 一次,不等;之后 rx 出了作用域被丢掉。回调返回时 Prevent 还没进 channel,就退出;之后再 send,接收端已经没了,send 返回 Err(SendError),prevent_exit 里的 unwrap() 在调用它的线程上 panic(app.rs:92)。关掉最后一个窗口时的 ExitRequested { code: None } 在 TaoWindowEvent::Destroyed 分支里,写法相同。
关窗口走 on_close_requested,同一个形状:先调各窗口事件监听器(Builder::on_window_event 注册的处理函数属于这一类)和 run 回调,然后
if let Ok(true) = rx.try_recv() {
} else {
on_window_close(window_id, windows);
}
CloseRequestApi::prevent_close 同样是 self.0.send(true).unwrap()。
两个 API 都实现了 Clone,看起来像是可以带走、稍后再用,其实只在回调执行期间有效。还有一种情况 prevent_exit 本来就无效:restart 用的退出码 RESTART_EXIT_CODE(i32::MAX),上面 if self.code != Some(RESTART_EXIT_CODE) 直接跳过。退出流程的全貌见 Builder、App 与 AppHandle。
修法
先在回调里同步拦下,再去问;问完决定退出时自己调 exit。第二次 ExitRequested 会再进一次回调,用一个标志放行:
static EXIT_OK: AtomicBool = AtomicBool::new(false);
"exit_fix" => {
if !EXIT_OK.load(Ordering::SeqCst) {
api.prevent_exit();
log("prevent_exit() now, ask asynchronously");
let h = handle.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
log("user said: quit, calling exit(3)");
EXIT_OK.store(true, Ordering::SeqCst);
h.exit(3);
});
} else {
log("exit already confirmed");
}
}
MODE=exit_fix:
0ms [main] main
72ms [main] probe plugin setup
REPORT {
"mode": "exit_fix",
"greet": "hello, rie"
}
452ms [main] ExitRequested { code: Some(0) }
452ms [main] prevent_exit() now, ask asynchronously
503ms [tokio-rt-worker] user said: quit, calling exit(3)
503ms [main] ExitRequested { code: Some(3) }
503ms [main] exit already confirmed
505ms [main] Exit
exit=3
第一次 ExitRequested 被拦下,应用继续运行;50 毫秒后「用户」同意,exit(3) 再走一遍流程,这次放行,进程以 3 退出。用户选「不退出」时什么都不用做,应用已经被拦住了。
关窗口同理,拦下之后用 destroy() 关(destroy 不再发 CloseRequested):
"close_fix" => {
if !CLOSE_OK.load(Ordering::SeqCst) {
api.prevent_close();
log("CloseRequested: prevent_close() now, ask asynchronously");
let w = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
log("user said: close it, calling destroy()");
CLOSE_OK.store(true, Ordering::SeqCst);
let _ = w.destroy();
});
} else {
log("CloseRequested: already confirmed");
}
}
448ms [main] report: closing the main window
448ms [main] CloseRequested: prevent_close() now, ask asynchronously
500ms [tokio-rt-worker] user said: close it, calling destroy()
503ms [main] ExitRequested { code: None }
509ms [main] Exit
exit=0
窗口在「用户」回答之后才关;CloseRequested: already confirmed 没有出现,说明 destroy 确实没有再触发 CloseRequested。
JS 侧的 onCloseRequested 本来就是这个形状:页面监听了 close-requested 时,Rust 先替它拦下关窗;JS 的处理函数可以 await,结束后没有 preventDefault() 就由 @tauri-apps/api 调 destroy()(packages/api/src/window.ts)。所以在前端问用户不会撞上这个坑,代价是要有 destroy 的权限,见 Window、Webview、WebviewWindow。
怎么确认没中招
- 在
ExitRequested/CloseRequested的处理代码里,prevent_exit/prevent_close必须出现在回调函数体的同步路径上,不能在spawn、.await、另一个线程或另一个回调里。克隆api往外传,几乎都是这个坑。 - 终端里出现
app.rs:92的SendErrorpanic,或者called Result::unwrap() on an Err value: SendError指向prevent_close,就是调晚了。 - 测试时让「用户」的回答慢一点(像上面这样
sleep50 毫秒),立刻就能看出拦没拦住。
小结
ExitRequested和CloseRequested都是「回调返回后try_recv一次」:回调执行期间调prevent_*才算数,之后的调用不起作用,还会因为接收端已丢弃而unwrappanic。ExitRequestApi、CloseRequestApi能Clone,但只在回调期间有效;restart触发的退出不能拦。- 修法:先同步
prevent_exit/prevent_close,再异步决定;要退就exit(code)(用标志放行第二次ExitRequested),要关就destroy()。