diff --git a/apps/api/package.json b/apps/api/package.json index 6426fe2..10adbb2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,10 +5,11 @@ "type": "module", "scripts": { "dev": "bun run --parallel dev:http dev:worker", - "dev:http": "bun --watch src/index.ts", - "dev:worker": "bun --watch src/worker.ts", - "start": "bun src/index.ts", - "worker": "bun src/worker.ts", + "dev:http": "bun --watch src/main.ts serve", + "dev:worker": "bun --watch src/main.ts worker", + "start": "bun src/main.ts serve", + "worker": "bun src/main.ts worker", + "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "seed:dev": "bun src/scripts/seed-dev.ts", "typecheck": "tsc --noEmit", "db:pull": "drizzle-kit pull" diff --git a/apps/api/src/assets.d.ts b/apps/api/src/assets.d.ts new file mode 100644 index 0000000..62393ba --- /dev/null +++ b/apps/api/src/assets.d.ts @@ -0,0 +1,24 @@ +/** + * `import path from "./x.wasm" with { type: "file" }` 的类型声明。 + * + * 这个写法让 `bun build --compile` 把文件内嵌进单二进制,运行时拿到的是 + * `/$bunfs/root/...` 下的可读路径;不编译时拿到的是磁盘上的真实路径。 + * 二进制必须自足,不能在运行时去 node_modules 里找 —— 详见 vendor/jieba.ts 的注释。 + * + * Bun 的类型里没覆盖这些扩展名,这里补上。值都是**文件路径字符串**。 + */ + +declare module "*.wasm" { + const path: string + export default path +} + +declare module "*.node" { + const path: string + export default path +} + +declare module "*.txt" { + const path: string + export default path +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 2d4339f..9751853 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -2,16 +2,22 @@ import { randomBytes } from "node:crypto" import { readFileSync } from "node:fs" import { isAbsolute, resolve } from "node:path" +import { isCompiled, pathBase } from "./runtime" + /** * Bun 只自动加载「当前工作目录」下的 .env。而本应用的启动方式(`bun run --filter '@oj2/api' dev`) * 会把 cwd 切到 apps/api/,于是仓库根的 .env 读不到 —— 而 .env.example 恰恰教人写在根目录。 * 这里显式补读仓库根的 .env,让文档指引真正生效,且不管从哪个目录启动都一致。 * * 只填充尚未设置的键:真实环境变量与 cwd 下的 .env 优先级更高,不被覆盖。 + * + * 编译成单二进制后不做这件事:生产靠 compose 注入环境变量,而 `import.meta.dir` 在 + * 二进制里是 `/$bunfs/root`,往上三级会去读 `/.env` —— 读到什么都是意外。 */ function loadRepoRootEnv() { + if (isCompiled) return try { - const text = readFileSync(resolve(import.meta.dir, "../../../.env"), "utf8") + const text = readFileSync(resolve(pathBase, ".env"), "utf8") for (const line of text.split("\n")) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#")) continue @@ -35,15 +41,12 @@ loadRepoRootEnv() * 也不要在仓库里写死一个人人都知道的弱默认值。 */ /** - * 相对路径一律按**仓库根**解析,而不是进程 cwd。 + * 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。 * - * 起服务的方式(`bun run --filter '@oj2/api' dev`)会把 cwd 切到 apps/api/, - * 于是 "data/test_case" 落在 apps/api/data/ 下 —— 而 docker/compose.dev.yml 把 - * 仓库根的 data/test_case 挂进判题沙箱。两边不是同一个目录,新传的测试点判题时 - * 会「找不到测试数据」,而且只在真正判题时才暴露。 + * 生产环境**应当**用绝对路径的环境变量把这些目录显式指定掉,相对路径只是开发便利。 */ function repoPath(value: string) { - return isAbsolute(value) ? value : resolve(import.meta.dir, "../../..", value) + return isAbsolute(value) ? value : resolve(pathBase, value) } function judgeServerToken() { diff --git a/apps/api/src/judge/ast.ts b/apps/api/src/judge/ast.ts index 6b636fa..7fc5a4f 100644 --- a/apps/api/src/judge/ast.ts +++ b/apps/api/src/judge/ast.ts @@ -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 自己的运行时 wasm,Parser.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() async function loadLanguage(language: string) { if (!(language in mappings)) return null - if (!initPromise) initPromise = Parser.init() + // locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找, + // 单二进制里那个目录是 /$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 } diff --git a/apps/api/src/judge/sql/child.ts b/apps/api/src/judge/sql/child.ts index 92483b7..27d0bc7 100644 --- a/apps/api/src/judge/sql/child.ts +++ b/apps/api/src/judge/sql/child.ts @@ -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() diff --git a/apps/api/src/judge/sql/engine.ts b/apps/api/src/judge/sql/engine.ts index 3ad3427..a011aef 100644 --- a/apps/api/src/judge/sql/engine.ts +++ b/apps/api/src/judge/sql/engine.ts @@ -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 }) diff --git a/apps/api/src/judge/sql/index.ts b/apps/api/src/judge/sql/index.ts index f95d307..53e46e0 100644 --- a/apps/api/src/judge/sql/index.ts +++ b/apps/api/src/judge/sql/index.ts @@ -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 = { } async function runJob(job: SqlJob, budget: JobBudget): Promise> { - 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() diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..b3b2fa8 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,37 @@ +/** + * 唯一入口。所有角色都从这里按子命令分叉。 + * + * 为什么不保留三个独立入口文件:`bun build --compile` 一次只产出一个二进制, + * 而部署要跑 HTTP 服务、判题 worker,SQL 判题还要 fork 一个能被 SIGKILL 的子进程 + * (见 judge/sql/index.ts)。三个入口就得编三个二进制、镜像里塞三份运行时。 + * 一个二进制 + 子命令,镜像里只有一份,compose 里改 command 就能换角色。 + * + * oj2-api # 等同 serve + * oj2-api serve # HTTP + WebSocket + * oj2-api worker # BullMQ 判题消费者 + * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 + * + * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 + * (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 + */ + +export {} // 只有动态 import 的话 TS 不认这是模块,顶层 await 会报错 + +const command = process.argv[2] ?? "serve" + +switch (command) { + case "serve": + await import("./index") + break + case "worker": + await import("./worker") + break + case "sql-child": { + const { runSqlChild } = await import("./judge/sql/child") + await runSqlChild() + break + } + default: + console.error(`未知子命令:${command}\n可用:serve | worker | sql-child`) + process.exit(2) +} diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts new file mode 100644 index 0000000..9a5b116 --- /dev/null +++ b/apps/api/src/runtime.ts @@ -0,0 +1,34 @@ +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, "../../..") diff --git a/apps/api/src/services/word-frequency.ts b/apps/api/src/services/word-frequency.ts index 016f7c0..fc64ea7 100644 --- a/apps/api/src/services/word-frequency.ts +++ b/apps/api/src/services/word-frequency.ts @@ -1,5 +1,6 @@ -import { Jieba } from "@node-rs/jieba" -import { dict } from "@node-rs/jieba/dict" +// 不直接 import @node-rs/jieba:它的运行时平台探测和 __dirname 词典加载 +// 在 `bun build --compile` 之后都失效,原因见 vendor/jieba.ts +import { withBuiltinDict, type JiebaInstance } from "../vendor/jieba" /** * 流程图评语词云的分词。对齐旧后端 `flowchart/views/admin.py` 的 @@ -35,11 +36,11 @@ const CUSTOM_WORDS = [ * 词典加载有一次性开销(约 100ms),放在模块级会拖慢 API 冷启动, * 而词云只有教师偶尔点一次。改成首次调用时才建。 */ -let instance: Jieba | null = null +let instance: JiebaInstance | null = null function jieba() { if (instance) return instance - const built = Jieba.withDict(dict) + const built = withBuiltinDict() // 对应旧后端的 jieba.add_word(w, freq=9999)。 // @node-rs/jieba@2 没有导出 insertWord/addWord,改用用户词典缓冲区,格式为「词 词频」。 built.loadDict( diff --git a/apps/api/src/vendor/jieba.ts b/apps/api/src/vendor/jieba.ts new file mode 100644 index 0000000..1745abf --- /dev/null +++ b/apps/api/src/vendor/jieba.ts @@ -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)) +}