Rust in Everything
基于 tauri 2.12.5

开发模式启动后 bus error 退出

主线位置:启动主线的最后一步,事件循环第一次发出 Ready。App::run 先跑 setup(建配置里的窗口、调用户的 setup 钩子),再把事件交给 on_event_loop_event 转成 RunEvent::Ready(见 Builder、App 与 AppHandle)。开发模式的 macOS 构建在这次转换里多做一件事:把应用图标设成 Dock 图标。进程死在这一步。

flowchart TB
  ready["第一次 Ready"] --> setupn["setup 建窗口 跑钩子"]
  setupn --> conv["on_event_loop_event"]
  conv --> devq{"dev 且 macOS"}
  devq -->|否| cbk["用户回调收到 Ready"]
  devq -->|是| nsimg["NSImage initWithData"]
  nsimg --> imageio["ImageIO 解码 PNG"]
  imageio --> libq{"libPng 从哪来"}
  libq -->|系统| cbk
  libq -->|Homebrew| bus["跳到 0xbad4007"]

本页实验:M2 Max、macOS 27.0.1、rustc 1.99.0,tauri 2.12.5,debug 构建。crate pit1-dev 由模板 base 复制,没有 devUrl;main 在 setup 钩子和 RunEvent::Ready 里各打一行,页面加载后通过 report 命令报回自己的地址。崩溃的运行一共做了两次(下文都标出),其余都是正常退出的运行。

现象

fn main() {
  // 15 秒看门狗,略
  println!("dev mode: {}", tauri::is_dev());
  let app = tauri::Builder::default()
    .setup(|_app| {
      println!("setup hook done");
      Ok(())
    })
    .invoke_handler(tauri::generate_handler![report])
    .build(tauri::generate_context!())
    .expect("error while building tauri application");
  app.run(|_app, event| {
    if let tauri::RunEvent::Ready = event {
      println!("RunEvent::Ready");
    }
  });
}

不带 --features tauri/custom-protocol 编译(开发模式),在终端里直接运行(崩溃运行一):

dev mode: true
setup hook done
exit=138

同一份代码带 --features tauri/custom-protocol 编译(发布模式):

dev mode: false
setup hook done
RunEvent::Ready
REPORT page loaded from tauri://localhost
exit=0

退出码 138 是 128 + 10,信号 10 是 SIGBUS。Rust 没有机会打印任何东西:这不是 panic,是 CPU 取指令时出错。setup 钩子跑完了,RunEvent::Ready 那一行没出来,所以出事的地方在两者之间。

macOS 为这次崩溃写了一份报告(~/Library/Logs/DiagnosticReports/pit1-dev-dev-*.ips,JSON)。用一个小脚本取出异常、崩溃线程的前 22 帧、两个寄存器和进程里加载的图像库(符号名截到 64 个字符):

EXC_BAD_ACCESS (SIGBUS) EXC_ARM_DA_ALIGN at 0x000000000bad4007
vmRegionInfo: 0xbad4007 is not in any region.  Bytes before following region: 4175364089
thread 0 (com.apple.main-thread):
 0 None           0xbad4007
 1 ImageIO        PNGReadPlugin::InitializePluginData(IIOImageReadSession*, IIODic
 2 ImageIO        IIOReadPlugin::callInitialize(bool&)
 3 ImageIO        IIO_Reader::initImageAtOffset(CGImagePlugin*, unsigned long, uns
...
 7 ImageIO        CGImageSourceCopyPropertiesAtIndexEx
 8 AppKit         ImageSourceOptionsForCGImageSource_index_
 9 AppKit         +[NSBitmapImageRep _imagesWithData:hfsFileType:extension:zone:ex
10 AppKit         +[NSBitmapImageRep imageRepsWithData:]
11 AppKit         -[NSImage initWithData:]
...
16 pit1-dev-dev   _RNvMs7_NtNtCsamhZZBAZQHX_13objc2_app_kit9generated9___NSImageNt
17 pit1-dev-dev   _RINvNtCs5EVsQ9ed0VJ_5tauri3app19on_event_loop_eventINtCsFqSSV9T  app.rs:2744
18 pit1-dev-dev   _RNCINvMsf_NtCs5EVsQ9ed0VJ_5tauri3appNtB8_3App28make_run_event_l  app.rs:1446
19 pit1-dev-dev   _RINvCsFqSSV9TGOY_17tauri_runtime_wry17handle_event_loopNtCs5EVs  lib.rs:4120
...
x2 = handle_error(png_struct_def*, char const*)  x3 = handle_warning(png_struct_def*, char const*)  x16 = 0xbad4007
loaded png/tiff/gif/jpeg images:
   /opt/homebrew/*/libpng16.16.dylib
   /opt/homebrew/*/libtiff.6.dylib
   /opt/homebrew/*/libgif.7.2.0.dylib
   /opt/homebrew/*/libjpeg.8.3.2.dylib

从下往上读:tauri 的 on_event_loop_event(app.rs 第 2744 行)调 NSImage initWithData:,AppKit 交给 ImageIO 读 PNG 的属性,ImageIO 的 PNG 插件在初始化时跳到了地址 0xbad4007。这个地址不在任何映射区域里,也不是 4 字节对齐的,ARM 上取指令报对齐错误,内核把它作为 SIGBUS 送给进程。跳转前 x2、x3 里放的是两个 libpng 错误回调,说明这是在调 libpng 的建表函数;而进程里的 libpng 来自 /opt/homebrew,不是系统的。编辑在模板上最早观察到这个现象时留下的六份 base-*.ips,异常和前几帧与这一份完全相同。

出处

开发模式下,generate_context! 在 macOS 上把图标文件原样嵌进二进制当 app_icon(context_codegen 第 228 行起:先找 bundle.icon 里的 .icns,没有就用 PNG,默认 icons/icon.png);发布模式下这一项是 None(对照表见 generate_context!)。第一次 Ready 时,on_event_loop_event 把它交给 AppKit:

    RuntimeRunEvent::Ready => {
      // set the app icon in development
      #[cfg(all(dev, target_os = "macos"))]
      {
        use objc2::{AllocAnyThread, MainThreadMarker};
        use objc2_app_kit::{NSApplication, NSImage};
        use objc2_foundation::NSData;

        if let Some(icon) = app_handle.manager.app_icon.clone() {
          // TODO: Enable this check.
          let mtm = unsafe { MainThreadMarker::new_unchecked() };
          let app = NSApplication::sharedApplication(mtm);
          let data = NSData::with_bytes(&icon);
          let app_icon = NSImage::initWithData(NSImage::alloc(), &data).expect("creating icon");
          unsafe { app.setApplicationIconImage(Some(&app_icon)) };
        }
      }
      RunEvent::Ready
    }

在 tauri 的源码里,NSImage 只出现在这一处;窗口图标在编译期就由 png crate 解成 RGBA(CachedIcon::new_png),运行时不再解码。所以同一个环境下,发布模式能跑,开发模式在这里崩。

📝NOTE
冷知识:开发模式设 Dock 图标从 tauri 1.0.1 开始

这段代码由 #4385(「feat(core): set macOS app icon in development」)加入,首次发布于 tauri 1.0.1,CHANGELOG 原文只有一句:「Set the application icon in development mode on macOS.」现在的写法来自 2.0.0-rc.13 的 #10924(「feat: Migrate to objc2」,CHANGELOG:「Use objc2 internally and in examples, leading to better memory safety.」),MainThreadMarker::new_unchecked 上那句「TODO: Enable this check.」是那次留下的。

那 Homebrew 的 libpng 是怎么进来的。ImageIO 自带几个图像库,都在框架的 Resources 目录里(dyld_info -dependents /System/Library/Frameworks/ImageIO.framework/ImageIO,节选):

                         /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib
                         /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libTIFF.dylib
                         /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libGIF.dylib
                         /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJP2.dylib
                         /usr/lib/libate.dylib
                         /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libJPEG.dylib

实验所在的 shell 从 shell 启动文件继承了 DYLD_LIBRARY_PATH=/opt/homebrew/lib:/usr/local/lib。man dyld 的说法是「For each dylib that a program uses, the dynamic linker looks for its leaf name in each directory in DYLD_LIBRARY_PATH.」:每个依赖先按路径的最后一段文件名在这些目录里找,找到就用,在默认位置之前。APFS 默认不区分大小写,/opt/homebrew/lib/libPng.dylib 能打开,打开的是 Homebrew 的 libpng.dylib(指向 libpng 1.6.59);libTIFF、libGIF、libJPEG 同理,这和崩溃报告里加载的四个库一一对应(liblzma、libzstd 是 Homebrew 的 libtiff 自己的依赖)。libJP2.dylib、libRadiance.dylib 在 Homebrew 目录里没有同名文件,仍然来自系统。

系统的 libPng.dylib 是 ImageIO 专用的一份,导出的名字带 cg_ 前缀;ImageIO 要的正是这些名字,Homebrew 的 libpng 里没有:

$ dyld_info -imports /System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO | grep create_read_struct
      __cg_png_create_read_struct  (from libPng)
$ dyld_info -exports /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib | grep create_read
        0x000007C8  __cg_png_create_read_struct
$ nm -gU /opt/homebrew/lib/libpng.dylib | grep create_read
0000000000007eec T _png_create_read_struct
0000000000007efc T _png_create_read_struct_2

找不到的符号没有让 dyld 在启动时报错,调用时跳到了 0xbad4007:寄存器 x16(arm64 上经桩函数调用外部符号时放目标地址的寄存器)就是这个值。这个占位地址由 dyld 的哪段逻辑填入,本页没有查到能引用的出处。

排除过的方向:

  • 不是因为没有 devUrl。开发模式下 get_app_url 在 devUrl 为空时退回 tauri://localhost,generate_context! 也照常内嵌 frontendDist。下一节去掉环境变量之后,同一个开发模式二进制加载的就是 tauri://localhost。
  • 不是图标文件的问题。模板的图标是 32×32 的 RGBA PNG,三个块的 CRC 都对、IDAT 解压后正好 32 × (1 + 128) 字节。换成 tauri-cli 应用模板里的 512×512 图标(crates/tauri-cli/templates/app/src-tauri/icons/icon.png)重新编开发模式再跑(崩溃运行二,crate pit1-devicon),崩溃报告的异常和前三帧完全相同。

tauri dev 编出来的也是开发模式(它不打开 custom-protocol),走的是同一段代码;它会继承启动它的 shell 的环境变量。本页没有用 CLI 实测。

修法

让这个进程看不到 DYLD_LIBRARY_PATH。只对这一次运行去掉它:

$ (unset DYLD_LIBRARY_PATH; DYLD_PRINT_LIBRARIES=1 ./pit1-dev-dev 2>&1 | grep -E "[Pp]ng|^REPORT")
dyld[72464]: <2D9734E3-0991-3823-875A-A7A1ABDC63B4> /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib
REPORT page loaded from tauri://localhost

同一个开发模式二进制,加载的是系统的 libPng.dylib,页面从 tauri://localhost 加载,正常退出。完整输出(不加 DYLD_PRINT_LIBRARIES):

dev mode: true
setup hook done
RunEvent::Ready
REPORT page loaded from tauri://localhost
exit=0

长期的修法是把 export DYLD_LIBRARY_PATH=... 从 shell 的启动文件里删掉;某个程序确实需要它时,只在启动那个程序时设置。

带 --features tauri/custom-protocol 编译能让这段图标代码不参与编译,但不是修法:发布模式的进程照样加载了 Homebrew 的 libpng,只是没有代码去调用它。

$ DYLD_PRINT_LIBRARIES=1 ./pit1-dev-prod 2>&1 | grep -E "[Pp]ng|^REPORT"
dyld[72457]: <4CC26026-4CD3-3266-8F77-BA6679FA1D42> /opt/homebrew/Cellar/libpng/1.6.59/lib/libpng16.16.dylib
REPORT page loaded from tauri://localhost

怎么确认没中招

  • 用 shell 自己的 echo $DYLD_LIBRARY_PATH 看,不要用 env | grep DYLD。/usr/bin/env 是受系统完整性保护(SIP)的程序,系统启动它时会去掉所有 DYLD_* 变量,所以它什么也看不到:

    $ echo $DYLD_LIBRARY_PATH
    /opt/homebrew/lib:/usr/local/lib
    $ env | grep DYLD; echo "grep exit=$?"
    grep exit=1
    
  • DYLD_PRINT_LIBRARIES=1 <应用> 2>&1 | grep -E '[Pp]ng' 应该只看到 /System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/libPng.dylib。

  • 已经崩了的话,打开 ~/Library/Logs/DiagnosticReports/<二进制名>-<时间>.ips:EXC_ARM_DA_ALIGN at 0x000000000bad4007、第 1 帧是 PNGReadPlugin::InitializePluginData,就是这个问题。

小结

  • 开发模式(没有 custom-protocol)的 macOS 构建,第一次 Ready 时在 on_event_loop_event 里用 NSImage initWithData: 设 Dock 图标;这是进程里唯一一处 ImageIO 解码。
  • DYLD_LIBRARY_PATH 含 /opt/homebrew/lib 时,dyld 按文件名在那里找到 libPng.dylib(大小写不敏感),ImageIO 拿到 Homebrew 的 libpng,跳到 0xbad4007,进程以 SIGBUS 退出,退出码 138,没有输出。
  • 和 devUrl、图标文件本身无关。去掉这个环境变量,开发模式照常从 tauri://localhost 加载页面。
  • 检查环境变量用 echo,不用 env:SIP 会把 DYLD_* 从受保护程序的环境里去掉。

💬 关联讨论

发起讨论
加载中...