一次 SMB 视频点击引发的 SIGSEGV:当 ffmpeg 和 libvips 各带一份 glib

用户:"我点了 SMB 上的电影,server 直接崩了。"
我:"…让我看看。"


目录


现象:一点视频,服务器整个退出

Tokimo 是个浏览器桌面 OS,后端 Rust + Axum,前端点开 SMB 网盘里的电影会经过这条链路:

前端点击 .mkv
   ↓
后端 SMB 驱动取流
   ↓
后台 job:libvips 抽缩略图 + ffmpeg 出 hover 预览
   ↓
WebSocket 推回前端

往常一切正常。直到某天用户报:点电影 = server 进程直接退。不是 panic,不是 500,是整个进程从父进程的眼皮底下消失

$ make dev
…
[server] Listening on 0.0.0.0:5678
…
(用户点视频)
[server] (no logs, no panic, no graceful shutdown — just gone)
$

dmesg 里赤裸裸一行:

[xxx] traps: tokimo-server[1234] general protection fault ip:7f… sp:7f…
       error:0 in libgobject-2.0.so.0[7f…]

libgobject 段错误。眼熟。


第一直觉:又是 LD_LIBRARY_PATH 顺序问题

我们之前为了这事儿调过一次 LD_LIBRARY_PATH 的顺序——两个 bundle 都带了一套 glib,loader 按谁先出现就拿谁的。所以 scripts/dev/rust-run.ts 里有这么一段防御:

// libvips 的 lib 目录必须在 ffmpeg 之前
// 否则 libvips 会拿到 ffmpeg 那一份的 glib,符号偏移对不上
if (existsSync(libvipsLibDir)) extras.push(libvipsLibDir);
if (existsSync(ffmpegLibDir)) extras.push(ffmpegLibDir);

字面意思看着就很魔幻——两个独立组件的初始化顺序,竟然要靠 npm 脚本里的字符串顺序来保证。当时已经知道这是个炸弹,但没炸就先放着了。

现在炸了。第一反应是顺序在哪退化了。打了一圈日志:

tracing::info!("LD_LIBRARY_PATH = {}", env::var("LD_LIBRARY_PATH").unwrap_or_default());
// LD_LIBRARY_PATH = /…/bin/libvips/current/lib:/…/bin/ffmpeg/current/lib:…

顺序对的。但还是炸。

那就更深一层——看实际加载到进程里的那个 libgobject 究竟来自哪里:

$ cat /proc/$(pgrep tokimo-server)/maps | grep -E "libglib|libgobject|libgio"
7f1234… r-xp … /home/lab/.../bin/libvips/current/lib/libglib-2.0.so.0
7f5678… r-xp … /home/lab/.../bin/ffmpeg/current/lib/libgobject-2.0.so.0
7f9abc… r-xp … /home/lab/.../bin/libvips/current/lib/libgio-2.0.so.0

🚨

  • libglib-2.0.so.0 ← libvips 的 bundle
  • libgobject-2.0.so.0ffmpeg 的 bundle
  • libgio-2.0.so.0 ← libvips 的 bundle

GLib 三件套来自两个不同的 bundle

LD_LIBRARY_PATH 顺序根本没法保证三件套整齐落到同一个 bundle——loader 是按"找到哪个 SONAME 就用哪个"来工作的,不会"看在 X 来自 A 的份上 Y 也优先从 A 选"。


真相:两个 bundle,两份 glib

事情是这样的:

我们的 tokimo-package-ffmpeg 在 GitHub Actions Ubuntu 22.04 runner 上 build:

runs-on: ubuntu-latest    # = Ubuntu 22.04

它把 ffmpeg 二进制 + 所有 ffmpeg 依赖(包括一份 glib)打包成 install-linux.tar.zst

我们的 tokimo-package-libvipsmanylinux_2_28(Rocky Linux 8)镜像里 build——为了 libvips 的兼容性需要更老的 glibc:

FROM quay.io/pypa/manylinux_2_28_x86_64

它也把 libvips 二进制 + 所有 libvips 依赖(包括另一份 glib)打包成同名 install-linux.tar.zst

两个 bundle 都来自 apt/dnf,但 distro 不同、版本不同、编译选项不同。装到 Tokimo 主仓库的 bin/ffmpeg/current/lib/bin/libvips/current/lib/ 之后:

bin/
├── ffmpeg/current/lib/
│   ├── libglib-2.0.so.0       ← Ubuntu 22.04 编的,假设是 2.72
│   ├── libgobject-2.0.so.0    ← 同上
│   ├── libgio-2.0.so.0        ← 同上
│   └── libavcodec.so, …
└── libvips/current/lib/
    ├── libglib-2.0.so.0       ← Rocky 8 编的,假设是 2.56
    ├── libgobject-2.0.so.0    ← 同上
    ├── libgio-2.0.so.0        ← 同上
    └── libvips.so, …

只要 Tokimo 进程同时把两个目录加进 LD_LIBRARY_PATH,loader 就会按 SONAME 各自挑——挑出来的三件套几乎注定不来自同一个 bundle


为什么"一份 glib"是天大的事

GLib / GObject / GIO 是 C 世界里实现"OOP"的运行时。它在进程里维护一张全局的 GType 注册表:每个类型有一个唯一 ID、一组 vtable 指针、初始化回调。

它是进程级单例,靠 static 变量存在 libgobject 内部。

当你把"glib 来自 bundle A、gobject 来自 bundle B"这种异源组合塞到同一个进程里:

  • bundle A 的 glib 内部的 g_quark_from_string() 状态机,跟 bundle B 的 gobject 期待的版本不一致
  • bundle B 的 g_object_new() 要去 bundle A 的 GType 表里查类型,结构体偏移对不上
  • 一切都还勉强能跑——直到某个调用真正 deref 那个偏移错的指针

SIGSEGV / general protection fault

ffmpeg 启动时不会用到 GObject(它只用了 libavutil 自带的轻量 hash),libvips 启动时只用自带的 GType——单独跑都没事。

直到我们点击 SMB 视频,触发同一个 job 同时调用 ffmpeg 抽帧 + libvips 做缩略图:两边对 GType 的访问交叉了,破碎的注册表立刻 trap


短期回避 vs. 真正修复

短期:调 LD_LIBRARY_PATH 顺序

之前我们靠"libvips 在 ffmpeg 前面"勉强活着。但这只是降低概率

  • libvips 的 SONAME 全在前面 → 三件套大概率全选 libvips 的 → glib 一致
  • 但 ffmpeg 自己 dlopen 的某个插件可能反过来 → 偶发崩

这种"靠字符串顺序保证 ABI 一致"的方案,在多线程 / dlopen / 跨平台面前根本不可靠。

真正修复:让两个组件共享同一份 glib 编译产物

唯一的根因解法是:ffmpeg 和 libvips 在同一个 build 里、链同一份 glib

这意味着:

  1. 一个 Docker base image
  2. 一个 prefix(/install
  3. 串行编译 ffmpeg → libvips(libvips 看到 ffmpeg 已经装好的 glib,不重新编一份)
  4. 一起打包

老 repo 的两个独立 CI 改不了这事——它们各自的 base image 都不同,glib 的 ABI 早从源头就分叉了。

得有个新 repo。


tokimo-lib:把 ffmpeg 和 libvips 关进同一个 Docker

新建 tokimo-lab/tokimo-lib,单一职责:统一构建 ffmpeg + libvips bundle

tokimo-lib/
├── VERSION                  # 1.0.2
├── docker/
│   └── Dockerfile.linux     # Ubuntu 22.04 + 所有 build deps
├── scripts/
│   ├── build-linux.sh       # 串行:先 ffmpeg,再 libvips(共享 PREFIX=/install)
│   ├── build-macos.sh
│   ├── build-windows.sh
│   ├── bundle-runtime-deps.sh   # ldd 收集运行时依赖 + patchelf rpath
│   └── verify-bundle.sh         # ★ 关键:验证只有一份 glib
└── .github/workflows/
    └── release.yml          # tag 触发,3 平台并行 build & publish

构建出的 bundle 顶层布局:

bin/        ffmpeg, ffprobe, vips, vipsthumbnail, …
lib/        libavcodec.so, libvips.so, libglib-2.0.so.0 (★ exactly ONE), …
include/    ffmpeg + vips 公共头
META.txt    版本、host、commit、构建时间

下游消费者只要解压这一个 tarball,所有 native 库就齐了。

deps.toml 从两条变一条:

-[deps.ffmpeg]
-version = "nightly"
-repo    = "tokimo-lab/tokimo-package-ffmpeg"
-…
-[deps.libvips]
-version = "8.18.2-tokimo.4"
-repo    = "tokimo-lab/tokimo-package-libvips"
-…
+[deps.tokimo-lib]
+version = "1.0.2"
+repo    = "tokimo-lab/tokimo-lib"
+# Bundles ffmpeg + libvips together — single glib copy, no LD ordering hazards.
+[deps.tokimo-lib.assets.linux-x86_64]
+file   = "tokimo-lib-linux-x86_64.tar.zst"
+sha256 = "02a1619…"

scripts/dev/rust-run.ts 里那段心虚的"libvips 必须在 ffmpeg 前面"注释也终于可以删了:

// 单一 bundle 同源 glib,加载顺序不再敏感
const tokimoLibDir = `${binRoot}/tokimo-lib/${version}/${host}/lib`;
if (existsSync(tokimoLibDir)) extras.push(tokimoLibDir);

CI 里如何强制"只有一份 glib"

光让 ffmpeg + libvips 在一个 Docker 里编还不够——保不齐哪天有人改了 build 脚本,又把第二份 glib 偷偷塞回 bundle 里。需要在 CI 里机械化地保证不变量

scripts/verify-bundle.sh 干这事,分两步:

第 1 步:检查每个 SONAME 唯一

# 对每个常见关键 SONAME,确保 install/lib 里只有一个文件提供它
for soname in libglib-2.0.so.0 libgobject-2.0.so.0 libgio-2.0.so.0; do
  count=$(find install/lib -name "$soname" -o \
                          -lname "*$soname" 2>/dev/null | wc -l)
  if [ "$count" -ne 1 ]; then
    echo "❌ multiple providers of $soname:"
    find install/lib -name "$soname" -o -lname "*$soname"
    exit 1
  fi
done

第 2 步:检查三件套互相 NEEDED 的是同一份 glib

libgobjectlibgio 都依赖 libglib。我们要的是它们 NEEDED 列表里写的就是 libglib-2.0.so.0,配合上一步的"只有一份 libglib",等价于"它们用的是同一份 glib"。

for child in libgobject-2.0.so.0 libgio-2.0.so.0; do
  needed=$(readelf -d "install/lib/$child" | awk '/NEEDED.*libglib-2\.0/{print $NF}')
  if [[ "$needed" != *"libglib-2.0.so.0"* ]]; then
    echo "❌ $child does not NEED libglib-2.0.so.0 (got: $needed)"
    exit 1
  fi
done

⚠️ 我们第一版写的是"glib/gobject/gio 三个 .so 的整个 NEEDED 列表必须完全相同"——结果 v1.0.1 立刻挂了。原因显而易见但一开始没想到:libgobject 不直接 NEED libpcre / libm(那是 libglib 自己的责任),三个文件的 NEEDED 集合本就不该相同。改成"它们都 NEED 同一个 libglib SONAME,且 libglib 全 bundle 只有一份"才是正确语义。

这次踩雷催出了 v1.0.2。

CI 通过后才允许 release。从此任何把第二份 glib 塞回 bundle 的改动都会在 CI 上被打回


迁移:6 个 commit + 5 个 submodule

主仓库 + 4 个 submodule + 1 个 standalone repo 都要动。按"原子提交"原则拆开:

Repo 改动
tokimo-lib (新) v1.0.0 → v1.0.1 → v1.0.2 三轮迭代(其中 v1.0.1 是 verify 脚本写错了)
tokimo 主仓库 deps.toml 切换 + scripts/dev/rust-run.ts 路径合并 + Dockerfile + media_tools.rs
tokimo-package-ffmpeg submodule build.rs 路径常量从 bin/ffmpeg/currentbin/tokimo-lib/current
tokimo-package-image submodule build.rs + vips.rs 同上(保留 TOKIMO_DEP_LIBVIPS_DIR env override)
tokimo-package-hls submodule hw_detect.rs 硬编码路径同步(探测 ffmpeg 编解码能力用)
tokimo-package-libvips standalone README banner 标 [DEPRECATED] + 老 release 标 pre-release

每一处都单独 commit + push,submodule 全部就位后主仓库才 bump 指针。

最后清理:把老 tokimo-package-ffmpeg / tokimo-package-image 两个 submodule 退化成纯 FFI 绑定 crate——删掉自己的 build CI,改成在 CI 里下载 tokimo-lib 的 release bundle 跑 fmt + clippy + test。从此"native 二进制构建"和"安全 Rust FFI 绑定"两件事职责分离


踩到的小坑

修完上线时还遇到一个尴尬的小 bug。新 bundle 顶层就是 bin/lib/include/META.txt,没像旧 bundle 那样多套一层 install/

# 老 bundle (install-linux.tar.zst)
install/bin/ffmpeg
install/lib/libvips.so

# 新 bundle (tokimo-lib-linux-x86_64.tar.zst)
bin/ffmpeg
lib/libvips.so
META.txt

deps.toml 里照搬了老 entry 的 strip_components = 1

[deps.tokimo-lib]
strip_components = 1   # ← 错!

效果是把 bin/lib/include 这层目录也剥掉,结果所有文件被扁平化到同一层:

$ ls bin/tokimo-lib/current/
META.txt  ffmpeg  ffprobe  libavcodec.so  libvips.so  …  # 全平了

build.rscurrent/lib/ 自然找不到,编译爆 panic。改成 strip_components = 0 后 layout 才正确。

教训:tar 内部结构和 strip_components 配套,复制 entry 时不能只抄不看


总结

之前 现在
native bundle 数 2(ffmpeg + libvips 各一份) 1(tokimo-lib)
进程内 glib 拷贝数 2(来自不同 distro) 1(CI 强制)
LD_LIBRARY_PATH 顺序敏感
SMB 视频点击崩溃 ❌ 必崩 ✅ 不崩
老 repo 自己 build CI 各自 build & publish 退化为纯 FFI 绑定 + 消费 tokimo-lib

一个看似"加载顺序"的小问题,根本原因是两套独立 CI 各自带了一份 ABI 关键的运行时库——这种问题再多打 5 个 LD 顺序补丁也修不掉,必须在源头把构建过程合并。

代价是新建一个 repo + 三轮 release 调试 + 5 个 submodule 联动迁移。回报是从此 LD_LIBRARY_PATH 不再是定时炸弹,CI 强制保证不变量,未来加新的 native 依赖(OpenCV?Tesseract?)也只要按同样的模式塞进 tokimo-lib 即可。

最后:

  • 能在 CI 里机械化检查的不变量,就在 CI 里检查——不要靠 code review 或者文档约定;
  • 看到自己写的字符串顺序在保证 ABI 一致,就该意识到这是早晚要爆的债。

项目源码:tokimo-lab/tokimo · tokimo-lab/tokimo-lib