Rust in Everything
基于 tauri 2.12.5

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 的 SendError panic,或者 called Result::unwrap() on an Err value: SendError 指向 prevent_close,就是调晚了。
  • 测试时让「用户」的回答慢一点(像上面这样 sleep 50 毫秒),立刻就能看出拦没拦住。

小结

  • ExitRequested 和 CloseRequested 都是「回调返回后 try_recv 一次」:回调执行期间调 prevent_* 才算数,之后的调用不起作用,还会因为接收端已丢弃而 unwrap panic。
  • ExitRequestApi、CloseRequestApi 能 Clone,但只在回调期间有效;restart 触发的退出不能拦。
  • 修法:先同步 prevent_exit / prevent_close,再异步决定;要退就 exit(code)(用标志放行第二次 ExitRequested),要关就 destroy()。

💬 关联讨论

发起讨论
加载中...