Rust in Everything
基于 tauri 2.12.5

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 自己的检查也一样:并发调用会建出两个同名窗口,表里只剩一个。要并发安全,用一把锁把查和建包起来。

💬 关联讨论

发起讨论
加载中...