feat(阶段2补课): SQL 判题链路 + 最后两个后台端点

新后端此前完全没有 SQL 判题(旧 judge/sql_runner.py 378 行 + sql_dispatcher.py
113 行无对应实现),阶段 2 纵切时漏了这条与沙箱完全不同的路径。

  judge/sql/engine.ts   判题核心,移植自 sql_runner.py,判定口径逐条对齐
  judge/sql/child.ts    子进程入口
  judge/sql/index.ts    父进程:spawn + 硬超时
  judge/run.ts          language === "SQL" 时分流,不经判题沙箱
  POST admin/sql-test-cases/preview    题目页展示数据预览
  POST admin/sql-test-cases/generate   AI 按标准答案倒推初始化脚本

题目保存时重新生成 sqlDisplay(对齐旧 generate_sql_display):取测试点 1 的初始化
脚本 + 标准答案跑一遍,失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构
与期望结果,宁可不保存也不能存错的。

## 防护换了实现,逐条实测

bun:sqlite 没有 authorizer / progress_handler / setlimit,且实测 Worker.terminate()
杀不掉跑飞的查询(原生代码占着线程)。改用「WASM 引擎 + 独立子进程」:

  ATTACH  → WASM 无宿主文件系统绑定,结构上够不到(比旧的 authorizer 更强)
  查询题只读 → PRAGMA query_only=1
  超时    → 子进程外部 SIGKILL
  单值内存 → 子进程 ulimit -d

八条提交实测:正确→Accepted;列少一个/漏过滤→Wrong Answer;语法错误→Compile Error;
查询题里 INSERT→运行错误并说明;递归 CTE 死循环→CPU 超时;hex(zeroblob(2e8))→内存超限;
attach '/etc/passwd'→打不开。

## 踩到的两个坑(已写进 docs/specs/phase3-coverage.md)

1. ulimit 必须用 -d 不能用 -v。-v 限虚拟地址空间而 JS 引擎预留巨量地址,实测 -v 之下
   Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,父进程读到空串
   误判成超时 —— 6 次里坏 2 次。换 -d 后 12/12 稳定。
2. 子进程写完结果直接 SIGKILL 自己,不走 process.exit()——后者仍有清理会撞限额。

至此 admin/api.ts 已无任何指向旧后端的调用。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:03:41 -06:00
co-authored by Claude Opus 5
parent 007ae8619b
commit e3faa689e7
10 changed files with 913 additions and 26 deletions
+88
View File
@@ -0,0 +1,88 @@
/**
* SQL 判题子进程的入口。
*
* 单独起进程的唯一理由是**能被杀掉**:SQLite 的查询跑在原生代码里,
* 实测 Worker.terminate() 抢占不了,只有 OS 的 SIGKILL 可靠。
* 父进程见 `./index.ts`。
*
* 协议:stdin 读一段 JSON 作业,stdout 写一段 JSON 结果;
* 阶段标记写 stderr,父进程在超时杀掉本进程后据此判断卡在哪一阶段。
*/
import { writeSync } from "node:fs"
import { buildDisplay, runCase, SqlCaseError } from "./engine"
import { JudgeStatus } from "../status"
export type SqlJob =
| {
kind: "judge"
initSql: string
refSql: string
studentSql: string
mode: "query" | "modify"
orderSensitive: boolean
timeLimitMs: number
memoryLimitMb: number
}
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
function markPhase(phase: string) {
process.stderr.write(`@phase:${phase}\n`)
}
/**
* 写完结果立刻硬退出,不走 Bun 的正常 teardown。
*
* 实测在 `ulimit -v` 之下,Bun 退出时的清理有概率撞上地址空间上限而 panic
* SIGILLexit 132)—— 结果其实已经写到 stdout 了,但进程异常终止会让父进程
* 读到空串,进而误判成超时。这个 panic 是不确定的,同样的输入时好时坏,
* 正是最难查的那种。这里跳过 teardown:子进程本来就是一次性的,没有要优雅关闭的资源。
*/
function finish(payload: unknown): never {
const bytes = new TextEncoder().encode(JSON.stringify(payload))
// 直接写 fd 1 并确认写完,再自杀。不用 process.exit():实测它仍会走一段清理,
// 在 ulimit 之下有概率 panicSIGILL,6 次里 2 次),而结果早已写出,
// 父进程却因进程异常终止读到空串、误判成超时 —— 时好时坏,最难查的那种。
let written = 0
while (written < bytes.length) {
written += writeSync(1, bytes, written, bytes.length - written)
}
process.kill(process.pid, "SIGKILL")
throw new Error("unreachable")
}
async function main() {
const raw = await new Response(Bun.stdin.stream()).text()
const job = JSON.parse(raw) as SqlJob
try {
if (job.kind === "display") {
markPhase("display")
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
finish({ ok: true, display })
}
markPhase("judge")
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
mode: job.mode,
orderSensitive: job.orderSensitive,
timeLimitMs: job.timeLimitMs,
memoryLimitMb: job.memoryLimitMb,
})
finish({ ok: true, case: result })
} catch (error) {
if (error instanceof SqlCaseError) {
finish({ ok: false, result: error.result, message: error.detail })
}
// WASM 堆触顶时 emscripten 抛的是普通 Error"Aborted"/"out of memory"),
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
const message = String((error as Error)?.message ?? error)
const memoryish = message.includes("out of memory") || message.includes("Aborted")
finish({
ok: false,
result: memoryish ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.SYSTEM_ERROR,
message: memoryish ? "内存超出限制" : message.slice(0, 200),
})
}
}
await main()