插件里注册或移除插件,应用卡死
主线位置:启动主线的「
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_*)、Plugintrait 的实现、或同步的插件命令里。 - 卡死时没有输出,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到别的线程。