a webview with label main already exists
主线位置:启动主线上「按配置建窗口」那一步,以及之后任何一次
WebviewWindowBuilder::build。建窗口时先在 webview 表里查标签,再在窗口表里查,都没有才交给运行时创建,创建完再登记进两张表(Window、Webview、WebviewWindow 的「两张表、两套标签」)。查和登记之间隔着一次跨线程的创建。
sequenceDiagram participant A as 命令调用一 participant B as 命令调用二 participant M as AppManager participant L as 主线程 A->>M: prepare 查标签 settings 不在 B->>M: prepare 查标签 settings 不在 A->>L: 创建窗口 B->>L: 创建窗口 L-->>A: 窗口一 A->>M: attach 登记 settings L-->>B: 窗口二 B->>M: attach 登记 settings 覆盖
本页实验 crate 是 pit1-label:由模板 base 复制,配置里照旧有一个隐藏窗口 main(create 默认为真)。M2 Max、macOS 27、rustc 1.99.0,tauri 2.12.5,debug 构建,--features tauri/custom-protocol,所有窗口隐藏。崩溃的运行只做了一次(下文标出)。
现象
一:setup 里再建一次 main
配置里的窗口在第一次 Ready 时由 tauri 自己建,先于用户的 setup 钩子。钩子里再按同一个标签建:
.setup(move |app| {
let again = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()).visible(false);
if question_mark {
again.build()?;
} else {
println!("setup: build main again -> {:?}", again.build().map(|w| w.label().to_string()));
}
Ok(())
})
不用 ?,把结果打印出来:
setup: build main again -> Err(WebviewLabelAlreadyExists("main"))
用 ?(环境变量 SETUP_Q,崩溃运行):
thread 'main' (2801819) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/app.rs:1444:11:
Failed to setup app: error encountered during setup hook: a webview with label `main` already exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
libc++abi: terminating due to uncaught foreign exception
exit=134
setup 返回的错误没有回到 run() 的 Result,而是变成了 panic,进程以 SIGABRT 退出(134)。崩溃报告里崩溃线程的栈(节选,Rust 符号截断):
{'codes': '0x0000000000000000, 0x0000000000000000', 'rawCodes': [0, 0], 'type': 'EXC_CRASH', 'signal': 'SIGABRT'}
Abort trap: 6
{'libsystem_c.dylib': ['abort() called']}
libsystem_kernel.dylib __pthread_kill
libsystem_pthread.dylib pthread_kill
libsystem_c.dylib abort
libc++abi.dylib __abort_message
libc++abi.dylib demangling_terminate_handler()
libc++abi.dylib std::__terminate(void (*)())
libc++abi.dylib std::terminate()
libobjc.A.dylib objc_exception_rethrow
Foundation -[NSAppleEventManager dispatchRawAppleEvent:withRawReply:handlerRefCon:]
...
pit1-label _RINvMsf_NtCsdpYmnMnerPw_5tauri3appNtB6_3App3runNCNvMsi_B6_INtB6_7BuilderINtCsFqSSV9TGOY_17tauri_runtime_wry3WryNtB8_16E app.rs 1391
pit1-label _RNvMsi_NtCsdpYmnMnerPw_5tauri3appINtB5_7BuilderINtCsFqSSV9TGOY_17tauri_runtime_wry3WryNtB7_16EventLoopMessageEE3runCsjz app.rs 2617
pit1-label _RNvCsjzrk9BFCCy_10pit1_label4main main.rs 54
二:同一个命令点两次
一个「打开设置」的 async 命令,每次都按标签 settings 新建窗口;页面依次调用两次:
#[tauri::command]
async fn open_settings(app: AppHandle) -> Result<String, String> {
WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("index.html".into()))
.visible(false)
.build()
.map(|w| format!("built {}", w.label()))
.map_err(|e| e.to_string())
}
open_settings => ok built settings
open_settings => err a webview with label `settings` already exists
三:同一个命令同时调两次
页面不等第一次返回,Promise.allSettled([invoke('open_settings'), invoke('open_settings')]),再等 2 秒;每个非 main 的页面加载后调 hello 报一次自己的标签和一个随机 id:
setup: build main again -> Err(WebviewLabelAlreadyExists("main"))
page loaded: webview label = prefs, page id = nxqiwk
page loaded: webview label = settings, page id = zzagfe
page loaded: webview label = prefs, page id = dvrt4r
page loaded: webview label = about, page id = x2u30t
open_settings (concurrent) => fulfilled built settings
open_settings (concurrent) => fulfilled built settings
open_prefs (concurrent) => fulfilled built prefs
open_prefs (concurrent) => fulfilled built prefs
open_about (concurrent) => fulfilled built about
open_about (concurrent) => fulfilled reused about
webview_windows = ["about", "main", "prefs", "settings"]
exit=0
open_settings 两次都成功:建出了两个标签都是 settings 的窗口,webview_windows() 里只有一个。open_prefs、open_about 是下文的两种修法,放在同一次运行里对照。page loaded 行的条数每次不同:隐藏窗口里的页面不一定在 report 退出之前加载完,前两次运行里出现过两个 settings 页面都报到的情况。
出处
两个错误类型在 error.rs 里:WindowLabelAlreadyExists(「a window with label {0} already exists」)和 WebviewLabelAlreadyExists(「a webview with label {0} already exists」)。WebviewWindowBuilder::build 先把 webview 部分转成待建的 webview,这时 prepare_webview 查 webview 表:
if self.webviews_lock().contains_key(&pending.label) {
return Err(crate::Error::WebviewLabelAlreadyExists(pending.label));
}
通过之后,Window 的 build_internal 调 prepare_window 查窗口表,再让运行时创建窗口,创建成功才 attach_window、attach_webview,往两张 HashMap 里 insert。同标签的 WebviewWindow 两张表里都有,所以报的总是 webview 那一条。
情形一为什么 abort。 setup 钩子返回 Err 时,make_run_event_loop_callback 里是 panic!("Failed to setup app: {e}")(第 1444 行),这时调用栈在 AppKit 处理「应用启动完成」的 Apple Event 里。panic 往上展开到 -[NSAppleEventManager dispatchRawAppleEvent:...],它把穿过来的 Rust panic 当作外来异常重新抛出(objc_exception_rethrow),没有人接,std::terminate 调 abort。所以在 macOS 上,setup 里的 ? 不会让 run() 返回错误,也不会以 101 退出,而是 134。
情形三为什么建出两个。 prepare_webview 和 prepare_window 各自拿锁、查完就放锁;创建要跨到主线程去做,attach_* 再拿锁插入。async 命令跑在 tokio 的工作线程上,两次调用可以同时走到「查」,都看到表里没有,各自创建,后插入的覆盖先插入的。被覆盖的那个窗口还在、页面还在运行,只是 tauri 的表里没有它了:get_webview_window("settings") 拿到的是后建的那个,先建的那个按标签找不到。本页在 tauri 2.12.5 上复现了这一点;2.12.5 的 CHANGELOG 和提交记录里没有找到相关的修复。
修法
配置里的窗口不要在代码里再建一次。 想在 setup 里自己建(比如要用代码设初始化脚本),在配置里给那个窗口写 "create": false,配置仍然可以通过 WebviewWindowBuilder::from_config 拿来用。setup 里的错误要么就地处理,要么让它 panic 之前先打印出来;在 macOS 上它不会以 Err 回到 main。
按标签复用。 先找,找到就聚焦:
#[tauri::command]
async fn open_prefs(app: AppHandle) -> Result<String, String> {
if let Some(w) = app.get_webview_window("prefs") {
w.set_focus().map_err(|e| e.to_string())?;
return Ok(format!("reused {}", w.label()));
}
WebviewWindowBuilder::new(&app, "prefs", WebviewUrl::App("index.html".into()))
.visible(false)
.build()
.map(|w| format!("built {}", w.label()))
.map_err(|e| e.to_string())
}
依次调用两次:
open_prefs => ok built prefs
open_prefs => ok reused prefs
但它和 tauri 自己的检查一样是「先查后建」:情形三的输出里,同时调两次 open_prefs 照样建出了两个 prefs。
并发时把查和建放进同一把锁。
static OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tauri::command]
async fn open_about(app: AppHandle) -> Result<String, String> {
let _guard = OPEN_LOCK.lock().await;
if let Some(w) = app.get_webview_window("about") {
w.set_focus().map_err(|e| e.to_string())?;
return Ok(format!("reused {}", w.label()));
}
WebviewWindowBuilder::new(&app, "about", WebviewUrl::App("index.html".into()))
.visible(false)
.build()
.map(|w| format!("built {}", w.label()))
.map_err(|e| e.to_string())
}
同时调两次:built about、reused about,只有一个窗口。锁用 tokio 的,因为它要跨过 build() 持有;build() 在 async 命令里会等主线程把窗口建好。
怎么确认没中招
- 搜代码里所有
WebviewWindowBuilder::new(、Window::builder(、WebviewBuilder::new(的标签字面量,和tauri.conf.json里app.windows[].label(没写时是main)对一遍。 - 由用户操作触发的建窗口命令,连点两下、以及不等返回连发两次都要试;看
webview_windows()的长度之外,还要看实际出现了几个窗口,表里的数字不会多。 - setup 钩子里不要对可能失败的建窗口直接用
?,至少在 macOS 上那会变成 abort。
小结
- 窗口和 webview 的标签各自唯一;同名的
WebviewWindow报的是「a webview with label...already exists」。 - 配置里的窗口由 tauri 在 setup 之前建好,代码里再建同名的就是这个错误;setup 里用
?传出它,macOS 上进程 abort(134),不是run()返回Err。 - 先
get_webview_window再建可以复用窗口,但查和登记不是原子的,tauri 自己的检查也一样:并发调用会建出两个同名窗口,表里只剩一个。要并发安全,用一把锁把查和建包起来。