feat(阶段5): 让 bun build --compile 的产物真正自足

编译产物拿到没有 node_modules 的目录里跑,原来是一路崩的 —— 而且**在仓库
目录里跑时全都正常**,因为它顺着 cwd 找到了 node_modules,假装没事。
这类问题只会在服务器上第一次启动时暴露。逐个堵掉:

- `import.meta.dir` 在二进制里恒为 `/$bunfs/root`,往上三级就是文件系统根:
  `data/test_case` 悄悄变成 `/data/test_case`,`.env` 去读 `/.env`。
  新增 runtime.ts 显式分叉:编译后按 cwd,开发时按仓库根(后者不能改,
  compose 挂给判题沙箱的是仓库根的 data/test_case)。

- sql.js / tree-sitter 的 wasm、jieba 的 .node 和 4.8MB 词典,
  原来都靠运行时 require.resolve / Bun.resolveSync / __dirname 去 node_modules 里找。
  全部改成 `with { type: "file" }` 内嵌成资源。jieba 尤其绕:它的 index.js
  运行时探测平台再 require 子包,dict.js 用 __dirname 读 dict.txt,两条都
  依赖磁盘布局,所以单独包了 vendor/jieba.ts 直接 require 内嵌的 .node。

- SQL 判题要 spawn 一个能被 SIGKILL 的子进程,原来 spawn 的是 child.ts 的路径,
  编译后那个文件不存在。改成二进制自己按 argv 分发:新增 main.ts 作为唯一入口,
  serve / worker / sql-child 三个子命令,镜像里只需要一份运行时。

## 顺带修掉一个能拖垮生产的坑

「spawn 自己」意味着只要 argv 分发这一环出问题(子命令改名没同步、compose 里
command 写错、拿别的入口编了二进制),「起自己」就变成「把整个程序再跑一遍」,
而那一遍又会 spawn 一个自己 —— 指数增长。

这不是假想:开发时用一个没有分发器的临时入口编了个二进制,一跑就递归 fork
105MB 的进程,几秒触发 global OOM,内核把开发机的终端杀了
(`selftest invoked oom-killer ... Killed process ... Alacritty`)。
同样的错误发生在服务器上就是判题机连同数据库一起拖死。

加了递归闸:父进程 spawn 时打 OJ2_SQL_CHILD 标记,带标记的进程一律拒绝再 spawn,
最多一层就停在一条明确的 SYSTEM_ERROR 上。

## 验证

产物拷进只有它自己一个文件的目录,2G 内存上限下跑,7 项全过:
jieba 分词(自定义词「循环结构」「死循环」命中)、tree-sitter Python3/C 各两条
(含一条**预期失败**的规则做对照,否则「语言没加载成功」和「规则通过」返回值
一模一样,分不出来)、SQL 判题 AC、SQL 只读防护仍拦住 PRAGMA。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:13:57 -06:00
parent 0f94e7ecbd
commit ce21a2bb8f
11 changed files with 231 additions and 31 deletions

55
apps/api/src/vendor/jieba.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
/**
* 直接加载 @node-rs/jieba 的原生模块和词典,绕开这个包自己的加载逻辑。
*
* 为什么不老老实实 `import { Jieba } from "@node-rs/jieba"`
*
* - 它的 `index.js` 在**运行时**探测平台再 `require` 对应的子包;
* - 它的 `dict.js` 用 `__dirname` 去读同目录的 `dict.txt`。
*
* 这两件事在 `bun build --compile` 之后都不成立 —— 编译产物里 `__dirname` 和模块
* 解析根都是 `/$bunfs/root`,子包和 dict.txt 都不在那儿。实测编译后直接报
* `Failed to load native binding`,而且**只在离开仓库目录后才报**(在仓库里跑时
* 它顺着 cwd 找到了 node_modules假装没事是那种在服务器上才炸的坑。
*
* 改成把 `.node` 和 `dict.txt` 用 `with { type: "file" }` 内嵌成资源:编译时它们被
* 塞进二进制,运行时 Bun 把它们摊到 `/$bunfs/root/` 下,`require()` 和 `readFileSync`
* 都能正常拿到。开发模式(不编译)下这个写法拿到的就是 node_modules 里的真实路径,
* 两种模式同一份代码。
*
* 平台写死 linux-x64-gnu部署目标是 debian 基底的容器,本机开发也是 x64 glibc。
* 换平台(比如改用 alpine/musl 基底镜像)必须同步改这里的 import否则编译能过、
* 启动就崩 —— 所以下面加了显式的错误提示。
*/
import addonPath from "@node-rs/jieba-linux-x64-gnu/jieba.linux-x64-gnu.node" with { type: "file" }
import dictPath from "@node-rs/jieba/dict.txt" with { type: "file" }
import { readFileSync } from "node:fs"
export interface JiebaInstance {
cut(text: string, hmm?: boolean): string[]
loadDict(dict: Buffer): void
}
interface JiebaAddon {
Jieba: { withDict(dict: Buffer): JiebaInstance }
}
let addon: JiebaAddon | null = null
function loadAddon(): JiebaAddon {
if (addon) return addon
try {
addon = require(addonPath as unknown as string) as JiebaAddon
} catch (error) {
throw new Error(
`加载 jieba 原生模块失败(${addonPath})。若已更换容器基底或 CPU 架构,` +
`需同步修改 src/vendor/jieba.ts 里写死的 linux-x64-gnu 导入。原始错误:${String(error)}`,
)
}
return addon
}
/** 内置词典dict.txt约 4.8MB。idf.txt 用不到,不内嵌,省二进制体积 */
export function withBuiltinDict(): JiebaInstance {
return loadAddon().Jieba.withDict(readFileSync(dictPath as unknown as string))
}