Files
OJ2/apps/api/src/runtime.ts
yuetsh ce21a2bb8f 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>
2026-08-07 23:13:57 -06:00

35 lines
1.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolve } from "node:path"
/**
* 运行形态的判定:开发时是 `bun src/main.ts`,生产是 `bun build --compile` 出来的单二进制。
*
* 两者最要命的差别是**路径**。编译产物里 `import.meta.dir` 恒为 `/$bunfs/root`
* Bun 把内嵌文件摊在那个虚拟目录下),于是任何 `resolve(import.meta.dir, "../..")`
* 都会指到文件系统根:`data/test_case` 变成 `/data/test_case`。这种错误不会报错,
* 只会安静地读写错地方,所以必须显式分叉,不能靠"相对路径反正差不多"。
*/
export const isCompiled = import.meta.dir.startsWith("/$bunfs")
/**
* 起一个「自己」的子进程时该用的命令。SQL 判题要 fork 一个可被 SIGKILL 的子进程,
* 见 judge/sql/index.ts。
*
* - 编译后:二进制自己就是入口,`[binary, "sql-child"]`
* - 开发时:`process.execPath` 是 bun得把入口脚本一起带上`[bun, main.ts, "sql-child"]`
*/
export function selfCommand(subcommand: string): string[] {
if (isCompiled) return [process.execPath, subcommand]
return [process.execPath, resolve(import.meta.dir, "main.ts"), subcommand]
}
/**
* 相对路径的解析基准。
*
* - 编译后:按进程 cwd 解析。容器里 workdir 固定,且这些目录本来就该由环境变量显式给出,
* cwd 只是最后的兜底。
* - 开发时:按仓库根解析。因为 `bun run --filter '@oj2/api' dev` 会把 cwd 切到 apps/api/
* 而 docker/compose.dev.yml 挂给判题沙箱的是**仓库根**的 data/test_case —— 按 cwd 解析
* 就会落到 apps/api/data/ 下,两边不是同一个目录,新传的测试点判题时报「找不到测试数据」。
*/
export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, "../../..")