DuckDB 的 Lua 插件,我写进浏览器里了
8 月介绍 duckdb-luajit 扩展的时候,我在「不支持」那一栏里写了一条:WASM。当时的理解是这扇门关着,LuaJIT 的构建依赖 GNU Make 和 GCC,浏览器里两样都没有。
三个月后,同一批 Lua 库里的 30 个进了浏览器,跑通了 29 个。这篇是过程的记录:一扇被判了死刑的门,一个标错的报错,和最后一个诚实的失败。
● ● ●
先说死刑:LuaJIT 进不去
动手前先查可行性,查到的结论很干脆:LuaJIT 编译不进 WebAssembly。这不是我的判断,是 LuaJIT 作者 Mike Pall 本人在 issue #1112 里的裁定。WASM 缺少 JIT 编译器需要的运行时代码生成能力,去掉 JIT 也不行,因为 LuaJIT 的解释器本身就是手写的机器码汇编,核心的 lj_vm.S 没法被 wasm 链接器链进去。
网上那些「在浏览器里跑 LuaJIT」的演示,扒开看是在浏览器里跑了一个 x86 虚拟机(CheerpX),普通 x86 版 LuaJIT 跑在虚拟化层上。那是模拟出一台电脑,跟把 LuaJIT 移植到 WASM 是两码事。
路只有一条:换运行时。PUC Lua 5.4,就是 Lua 官方的解释器版本,纯 C 写的,没有 JIT,也就没有那些汇编地雷。实测它整个编进 wasm 没有障碍。
哪些库能进浏览器
● ● ●
换了运行时,库要重新分层
原来那批库不是搬起就走。按实测分层:22 个依赖 LuaJIT 的 FFI,WASM 里没有 FFI,一个都搬不动;7 个用了 Lua 5.1 的老符号(unpack、bit32),在 5.4 里要加兼容垫片;剩下纯计算的 20 个零改动直接进,加上 7 个要 DuckDB 回调的、2 个中文分词的,能进浏览器的共 29 个。
这里有个事先没料到的坑:trgm 这种看起来纯 Lua 的库,第一行就报「调用 nil 值」,因为它用了 5.1 的全局 unpack,5.4 里这个函数搬去了 table.unpack。纯 Lua 不等于零改动,中间隔着一层语言断代。
数值层做了逐位验证:同一份 trgm 源码,native 侧和 wasm 侧各跑 39 条用例,输出 diff 为零。中文分词的 jieba(9.4MB 词典)和 pinyin(5.1MB 词典)也做了同样的事,混合串、多音字逐 token 对齐。「重庆」出 chóngqìng 不出 zhōngqìng,两侧一致。
● ● ●
30 个库上线,5 个报 wasm-trap
demo 部署到公网,第一次全量扫描,30 个库挂了 5 个。诡异的地方在于报错:5 个全是 wasm trap。
这个报错很有误导性。wasm trap 一般意味着越界、栈溢出、递归炸了这类致命问题。我把 5 个库的源码翻了一遍,没有递归,全是迭代写法。给回调桥加探针,桥是健康的。同样的库在 native 侧跑,全绿。
三头都对不上,问题只能出在中间那层编译。
● ● ●
编译器的默认值吞了错误
最小化复现用 14 行 C++:try 里 throw 一个异常,catch 之后返回 1。同一份源码编两次,唯一变量是一个编译开关。
不带开关跑,JS 侧收到的是一个数字,5244808,一个 wasm 堆地址。异常从 catch 眼皮底下穿了过去,一路穿透到 JS 边界,变成一个谁也不认识的裸数字。带上 -sDISABLE_EXCEPTION_CATCHING=0 再编,catch 正常工作,返回 1。
根因:emcc 3.1.6 默认关闭异常捕获。Lua 抛的正常错误(比如库加载失败)在 wasm 边界被吞掉,变成裸数字,页面兜底的 catch 接住这个不是 Error 对象的数字,错误地打上了 wasm-trap 标签。
回头看那 5 个「失败」的库,全是被这个机制吞掉的正常 Lua 错误,其中几个其实能跑。修好开关之后重新扫描,29 个通过。
wasm-trap 误标的排查路径
● ● ●
顺手挖出另外两颗雷
这个发现的价值比修一个 demo 大。顺着查同项目的另外两个模块,一个有同样的漏开关问题,一个参数是从旧版类推的,8 个库悄悄回归了。三处统一改成显式开启异常捕获,30 库扫描 29 OK / 1 FAIL,总耗时 344ms(DuckDB-Wasm v1.5.4,真实 Chromium,2026-11-10 实测)。
教训记一笔:编译器默认值也是依赖。升级或者换构建方式时,显式声明的开关会被人检查,「默认值碰巧是对的」这种假设没人检查。
● ● ●
第 30 个库,如实报缺
最后那个失败的库是 to_lance,往 Lance 格式导数据。这次报错是真的:引擎说 Copy Function "lance" 不存在。
追下去发现一个反差:在 DuckDB 里执行 INSTALL lance 返回的是 Success,因为扩展元数据在清单里。但去官方扩展服务器上下载 wasm 二进制,URL 返回 404。同一路径下 icu、json 都是 200,排除了路径写错。lance 的 wasm 版二进制官方从来没有发布过。INSTALL 成功和 LOAD 能用,中间隔着一次真实的下载。
自己编一个行不行?查了依赖链,卡在 Tokio:它需要操作系统线程和 epoll,单线程的 wasm 里没有这套东西。这条路目前走不通,demo 里如实标了缺依赖。报一个跑不通,比假装能跑强。
● ● ●
结尾
三件事留在公网上可以验证。demo 地址在 alitrack.github.io/duckdb-lua-online,双击就能跑,不用装任何东西;构建脚本一条命令,产物和线上部署的文件字节一致(lua7.js 646,143 字节,lua20.js 824,766 字节,两个都验过);那 14 行复现编译器行为的 C++,也放在仓库里。
8 月那篇文章里的「不支持 WASM」,现在可以改写成两句:LuaJIT 进不去,这是官方裁定;Lua 进得去,换个解释器就行。
你在浏览器里跑过 DuckDB-Wasm 吗?踩过哪些静默吞错的坑?(评论区聊聊)
参考来源:
- 01
https://github.com/LuaJIT/LuaJIT/issues/1112 (Mike Pall 关于 WASM 目标的裁定) - 02
https://duckdb.org/docs/extensions/overview (DuckDB 扩展机制) - 03
https://github.com/duckdb/duckdb-wasm (DuckDB-Wasm v1.5.4) - 04
https://emscripten.org/docs/porting/exceptions.html (Emscripten 异常捕获文档) - 05
https://alitrack.github.io/duckdb-lua-online/ (本文 demo)