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

View File

@@ -1,4 +1,10 @@
import { Language, Parser, type Node } from "web-tree-sitter"
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root解析不到 node_modules。见 vendor/jieba.ts
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" }
import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
// web-tree-sitter 自己的运行时 wasmParser.init() 要用
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
export interface AstRule {
engine?: string
@@ -66,16 +72,15 @@ const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!(language in mappings)) return null
if (!initPromise) initPromise = Parser.init()
// locateFile 指到内嵌的 tree-sitter.wasmemscripten 默认按脚本所在目录找,
// 单二进制里那个目录是 /$bunfs/root它自己找不着
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
await initPromise
const cached = languages.get(language)
if (cached) return cached
const packageName = language === "C" ? "tree-sitter-c" : "tree-sitter-python"
const wasmName = language === "C" ? "tree-sitter-c.wasm" : "tree-sitter-python.wasm"
const wasmPath = Bun.resolveSync(`${packageName}/${wasmName}`, import.meta.dir)
const loaded = await Language.load(wasmPath)
const loaded = await Language.load(language === "C" ? cWasmPath : pythonWasmPath)
languages.set(language, loaded)
return loaded
}

View File

@@ -6,7 +6,11 @@
* 父进程见 `./index.ts`。
*
* 协议stdin 读一段 JSON 作业stdout 写一段 JSON 结果;
* 阶段标记写 stderr父进程在超时杀掉本进程后据此判断卡在哪一阶段
* 阶段标记写 stderr父进程据此收紧兜底时限、并判断超时该算谁的
*
* 这个文件**不是**独立入口,而是由 `src/main.ts` 的 `sql-child` 子命令调用。
* 原因:`bun build --compile` 之后磁盘上没有 child.ts 可以让父进程去 spawn
* 只能让二进制自己按 argv 分发到这里。
*/
import { writeSync } from "node:fs"
@@ -60,7 +64,8 @@ function finish(payload: unknown): never {
throw new Error("unreachable")
}
async function main() {
/** 子进程入口。由 `src/main.ts` 的 `sql-child` 子命令调用,不在导入时自动执行 */
export async function runSqlChild() {
const raw = await new Response(Bun.stdin.stream()).text()
const job = JSON.parse(raw) as SqlJob
try {
@@ -93,5 +98,3 @@ async function main() {
})
}
}
await main()

View File

@@ -33,6 +33,7 @@
*/
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
import sqlWasmPath from "sql.js/dist/sql-wasm.wasm" with { type: "file" }
import { readFileSync } from "node:fs"
import { JudgeStatus, type JudgeStatusValue } from "../status"
@@ -56,7 +57,9 @@ let cached: SqlJsStatic | null = null
export async function sqlEngine() {
if (cached) return cached
const binary = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm"))
// 内嵌成资源而非运行时 require.resolve —— 后者在 `bun build --compile` 之后
// 只有在仓库目录里才碰巧能解析出来,换个目录就 Cannot find module。见 vendor/jieba.ts
const binary = readFileSync(sqlWasmPath)
// @types/sql.js 把 wasmBinary 标成 ArrayBuffer实际 emscripten 接受 TypedArray
// 这里传 Uint8Array 是运行时正确的写法,类型上断言掉
cached = await initSqlJs({ wasmBinary: binary as unknown as ArrayBuffer })

View File

@@ -1,5 +1,4 @@
import { resolve } from "node:path"
import { selfCommand } from "../../runtime"
import { JudgeStatus, type JudgeStatusValue } from "../status"
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
import type { SqlJob } from "./child"
@@ -33,6 +32,24 @@ import type { SqlJob } from "./child"
* 卡在 student 才是学生超时TLE
*/
/**
* 递归闸的标记环境变量。
*
* ## 为什么必须有这道闸
*
* 这里 spawn 的是**进程自己**`process.execPath` + 子命令)。正常情况下入口的
* argv 分发会把它引到 `runSqlChild()`,跑完就退出。但只要分发这一环出问题 ——
* 子命令改了名没同步、compose 里 command 写错、有人拿别的入口编了个二进制 ——
* 「起自己」就变成「把整个程序再跑一遍」,而那一遍又会 spawn 一个自己,指数增长。
*
* 这不是假想:开发阶段用一个没有 argv 分发的临时入口编了个二进制,一跑就是
* 递归 fork 105MB 的进程,几秒内触发 global OOM内核把开发机的终端杀了。
* 同样的错误发生在服务器上就是判题机连同数据库一起被拖死。
*
* 闸的逻辑很简单:父进程 spawn 时打上这个标记,带标记的进程一律拒绝再 spawn。
* 递归最多一层就停在一条明确的 SYSTEM_ERROR 上,而不是吃光机器。
*/
const CHILD_MARKER = "OJ2_SQL_CHILD"
/** 子进程数据段上限KB。低于 512MB Bun 自己起不来 */
const CHILD_DATA_LIMIT_KB = 512 * 1024
/** 进程启动 + WASM 初始化 + JSON 收发的余量 */
@@ -70,11 +87,28 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
}
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
const entry = resolve(import.meta.dir, "child.ts")
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
if (process.env[CHILD_MARKER]) {
return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
}
}
// 起的是「自己」:开发时是 bun + main.ts编译后就是二进制自身见 runtime.ts。
// 不能写死 child.ts 的路径 —— 单二进制里那个文件根本不存在。
const self = selfCommand("sql-child")
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口。
// "$@" 原样透传,免得路径里有空格时被词法拆开。
const child = Bun.spawn(
["sh", "-c", `ulimit -d ${CHILD_DATA_LIMIT_KB}; exec "$0" run "$1"`, process.execPath, entry],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
["sh", "-c", `ulimit -d ${CHILD_DATA_LIMIT_KB}; exec "$@"`, "sh", ...self],
{
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, [CHILD_MARKER]: "1" },
},
)
child.stdin.write(JSON.stringify(job))
await child.stdin.end()