一次 SMB 视频点击引发的 SIGSEGV:当 ffmpeg 和 libvips 各带一份 glib
用户:"我点了 SMB 上的电影,server 直接崩了。"
我:"…让我看看。"
目录
- 现象:一点视频,服务器整个退出
- 第一直觉:又是 LD_LIBRARY_PATH 顺序问题
- 真相:两个 bundle,两份 glib
- 为什么"一份 glib"是天大的事
- 短期回避 vs. 真正修复
- tokimo-lib:把 ffmpeg 和 libvips 关进同一个 Docker
- CI 里如何强制"只有一份 glib"
- 迁移:6 个 commit + 5 个 submodule
- 踩到的小坑
- 总结
现象:一点视频,服务器整个退出
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 的 bundlelibgobject-2.0.so.0← ffmpeg 的 bundlelibgio-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-libvips 在 manylinux_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。
这意味着:
- 一个 Docker base image
- 一个 prefix(
/install) - 串行编译 ffmpeg → libvips(libvips 看到 ffmpeg 已经装好的 glib,不重新编一份)
- 一起打包
老 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
libgobject 和 libgio 都依赖 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/current → bin/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.rs 找 current/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 一致,就该意识到这是早晚要爆的债。
版权属于:一名宅。
本文链接:https://zhaiyiming.com/archives/86.html
转载时须注明出处及本声明