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:
@@ -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
|
||||
* (SIGILL,exit 132)—— 结果其实已经写到 stdout 了,但进程异常终止会让父进程
|
||||
* 读到空串,进而误判成超时。这个 panic 是不确定的,同样的输入时好时坏,
|
||||
* 正是最难查的那种。这里跳过 teardown:子进程本来就是一次性的,没有要优雅关闭的资源。
|
||||
*/
|
||||
function finish(payload: unknown): never {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload))
|
||||
// 直接写 fd 1 并确认写完,再自杀。不用 process.exit():实测它仍会走一段清理,
|
||||
// 在 ulimit 之下有概率 panic(SIGILL,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()
|
||||
Reference in New Issue
Block a user