Rust in Everything
基于 tauri 2.12.5

插件里注册或移除插件,应用卡死

主线位置:启动主线的「Builder::build 初始化插件」一步,和调用主线的 plugin: 分流。两处都在锁住插件表之后调用插件的代码。

flowchart TB
  lockit["锁住 plugins Mutex"] --> hook["插件 setup 或同步插件命令"]
  hook --> again["app.plugin 或 remove_plugin"]
  again --> lock2["再锁同一把 Mutex"]
  lock2 --> stuck["同一线程永远等自己"]

实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,MODE 选场景。build.rs 用 InlinedPlugin 给应用内的 probe 插件声明两个命令,能力文件里授权 probe:allow-unload、probe:allow-unload-async。卡死的场景由看门狗在 8 秒后 exit(2) 结束。平台 M2 Max、macOS 27、rustc 1.99.0。

现象

一:插件的 setup 里注册它依赖的另一个插件。

fn probe<R: Runtime>() -> TauriPlugin<R> {
  PluginBuilder::new("probe")
    .setup(|app, _api| {
      log("probe plugin setup");
      if mode_env() == "plugin_nested" {
        log("probe setup: app.plugin(inner())");
        app.plugin(inner::<R>())?;
        log("probe setup: inner registered");
      }
      Ok(())
    })
    .invoke_handler(tauri::generate_handler![unload, unload_async])
    .on_drop(|_app| log("probe on_drop"))
    .build()
}

MODE=plugin_nested:

    0ms [main] main
   70ms [main] probe plugin setup
   70ms [main] probe setup: app.plugin(inner())
TIMEOUT
exit=2

窗口从没建出来,Builder::build 没有返回。

二:同步插件命令移除插件自己。 比如一个「卸载这个功能」的命令:

#[tauri::command]
fn unload<R: Runtime>(app: AppHandle<R>) {
  log("plugin command unload (sync) -> remove_plugin(probe)");
  let removed = app.remove_plugin("probe");
  log(format!("remove_plugin returned {removed}"));
}

页面调 invoke('plugin:probe|unload'),MODE=plugin_unload:

    0ms [main] main
   80ms [main] probe plugin setup
  453ms [main] plugin command unload (sync) -> remove_plugin(probe)
TIMEOUT
exit=2

remove_plugin 没有返回。命令在主线程上,主线程卡死,事件循环也就停了:页面、窗口、其他命令全都没反应。

出处

插件表是 AppManager 里的一个 std::sync::Mutex:

  pub(crate) plugins: Mutex<PluginStore<R>>,

注册和移除都要锁它,AppHandle::plugin_boxed 和 AppHandle::remove_plugin:

  pub fn plugin_boxed(&self, mut plugin: Box<dyn Plugin<R>>) -> crate::Result<()> {
    let mut store = self.manager().plugins.lock().unwrap();
    store.initialize(&mut plugin, self, &self.config().plugins)?;
    store.register(plugin);

    Ok(())
  }
  pub fn remove_plugin(&self, plugin: &str) -> bool {
    self.manager().plugins.lock().unwrap().unregister(plugin)
  }

而调用插件代码的地方,在调用期间一直拿着这把锁。build 末尾初始化所有插件是 initialize_plugins,分发插件命令是 run_plugin_invoke_handler:

  pub fn run_plugin_invoke_handler(&self, plugin: &str, invoke: Invoke<R>) -> bool {
    self
      .plugins
      .lock()
      .expect("poisoned plugin store")
      .run_invoke_handler(plugin, invoke)
  }

  pub fn initialize_plugins(&self, app: &AppHandle<R>) -> crate::Result<()> {
    self
      .plugins
      .lock()
      .expect("poisoned plugin store")
      .initialize_all(app, &self.config.plugins)
  }

std::sync::Mutex 不可重入:同一个线程第二次 lock(),标准库文档说「might panic or deadlock」,macOS 上实测是后者。第一个例子里锁是 initialize_plugins 拿的,第二个例子里是 run_plugin_invoke_handler 拿的,再锁的都是同一个主线程。

PluginStore 的其他方法,on_navigation、on_page_load、window_created、webview_created、on_event,调用方(manager/webview.rs、manager/window.rs、app.rs 里的 .plugins.lock())也都是先锁住表再进去(插件 的「持锁调用的代价」一节列了这些钩子)。所以规则很简单:插件的任何钩子、任何同步插件命令里,都不能调 AppHandle::plugin、plugin_boxed、remove_plugin。 remove_plugin 的文档示例本身就是在 std::thread::spawn 出来的线程里调用的。

同步的应用命令不受影响:应用自己的 invoke_handler 不在插件表里,run_invoke_handler 不锁这把锁。

修法

注册放到锁外面。 最简单是让应用在 Builder 上把两个插件都 .plugin(..) 进去;必须在运行时决定的,放在应用的 setup 里(它不在插件表的锁里):

    .setup(|app| {
      if mode_env() == "plugin_nested_fix" {
        log("app setup: app.plugin(inner())");
        app.handle().plugin(inner())?;
        log("app setup: inner registered");
      }
      Ok(())
    })
    0ms [main] main
   72ms [main] probe plugin setup
  342ms [main] app setup: app.plugin(inner())
  342ms [main] inner plugin setup
  342ms [main] app setup: inner registered
REPORT {
 "mode": "plugin_nested_fix",
 "greet": "hello, rie"
}
  562ms [main] ExitRequested { code: Some(0) }
  562ms [main] Exit
exit=0

移除放进 async 命令。 async 插件命令的 extend_api 只是把 future 交给 async_runtime 就返回,锁随即释放;命令体在 tokio 线程上执行时插件表是空闲的:

#[tauri::command]
async fn unload_async<R: Runtime>(app: AppHandle<R>) -> Result<bool, String> {
  log("plugin command unload_async -> remove_plugin(probe)");
  let removed = app.remove_plugin("probe");
  log(format!("remove_plugin returned {removed}"));
  Ok(removed)
}

页面连调两次 plugin:probe|unload_async,MODE=plugin_unload_fix:

    0ms [main] main
   93ms [main] probe plugin setup
  547ms [tokio-rt-worker] plugin command unload_async -> remove_plugin(probe)
  547ms [tokio-rt-worker] probe on_drop
  547ms [tokio-rt-worker] remove_plugin returned true
REPORT {
 "mode": "plugin_unload_fix",
 "r": {
  "ok": true
 },
 "again": {
  "err": "plugin probe not found"
 }
}
  548ms [main] ExitRequested { code: Some(0) }
  548ms [main] Exit
exit=0

remove_plugin 返回 true,插件的 on_drop 在 unregister 里被调用(同样是持锁调用,所以 on_drop 里也不能再碰插件表)。第二次调用时插件已经不在,on_message 回答「plugin probe not found」。

在钩子里确实需要触发注册或移除时,用 tauri::async_runtime::spawn 或 std::thread::spawn 把它挪到别的线程,让当前钩子先返回、把锁放掉。run_on_main_thread 不行:在主线程上调用时它就地执行闭包(见 tauri-runtime-wry 与事件循环),锁还在手里。

怎么确认没中招

  • 搜 .plugin(、.plugin_boxed(、.remove_plugin(,看它们是否出现在 plugin::Builder 的任何闭包(setup、on_*)、Plugin trait 的实现、或同步的插件命令里。
  • 卡死时没有输出,CPU 为 0(第二个例子 ps -o %cpu,state 给出 0.0 SN)。和「同步命令等窗口属性」那类死锁不同,这里只有主线程一个线程卡着,等的是它自己。用 sample <pid> 1 看主线程的栈:AppHandle::remove_plugin → Mutex<PluginStore>::lock → psynch_mutexwait。
  • 插件作者:如果插件要依赖另一个插件,在文档里让用户在 Builder 上一起注册,或者在 setup 里检查依赖的插件是否已存在(它的状态有没有 manage)然后返回错误,而不是自己去注册。

小结

  • AppManager.plugins 是一把 std::sync::Mutex;插件的 setup、所有钩子、同步插件命令、on_drop 都在持锁时被调用。
  • 在这些地方调 AppHandle::plugin / remove_plugin 会在同一线程上再锁一次,主线程卡死,只能被杀掉。
  • 修法:注册写在 Builder 上或应用的 setup 里;移除放进 async 插件命令;钩子里需要时 spawn 到别的线程。

💬 关联讨论

发起讨论
加载中...