From e3faa689e7e917dc4d8ae9ca137592126b6306b1 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 17:03:41 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=B6=E6=AE=B52=E8=A1=A5=E8=AF=BE):=20?= =?UTF-8?q?SQL=20=E5=88=A4=E9=A2=98=E9=93=BE=E8=B7=AF=20+=20=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=A4=E4=B8=AA=E5=90=8E=E5=8F=B0=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新后端此前完全没有 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 --- apps/api/package.json | 2 + apps/api/src/judge/run.ts | 104 +++++- apps/api/src/judge/sql/child.ts | 88 +++++ apps/api/src/judge/sql/engine.ts | 467 +++++++++++++++++++++++++++ apps/api/src/judge/sql/index.ts | 88 +++++ apps/api/src/routes/admin/problem.ts | 115 ++++++- apps/web/src/admin/api.ts | 16 +- bun.lock | 8 + docs/specs/phase3-coverage.md | 38 +++ packages/contract/src/admin.ts | 13 + 10 files changed, 913 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/judge/sql/child.ts create mode 100644 apps/api/src/judge/sql/engine.ts create mode 100644 apps/api/src/judge/sql/index.ts diff --git a/apps/api/package.json b/apps/api/package.json index 6ba64ce..6426fe2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,12 +22,14 @@ "hono": "^4.0.0", "ioredis": "^6.0.0", "postgres": "^3.4.0", + "sql.js": "^1.14.1", "tree-sitter-c": "^0.24.1", "tree-sitter-python": "^0.25.0", "web-tree-sitter": "^0.26.11", "zod": "^4.0.0" }, "devDependencies": { + "@types/sql.js": "^1.4.11", "drizzle-kit": "^0.31.10" } } diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index f356d1b..d2d82f3 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -16,12 +16,18 @@ import { type JudgeStatusValue, } from "./status" import { parseProblemTemplate } from "./template" +import { runSqlCase } from "./sql" +import { readInfo } from "../services/test-case" +import { readFile } from "node:fs/promises" +import { resolve as resolvePath } from "node:path" interface JudgeCase { cpu_time: number memory: number result: number test_case: string + /** SQL 判题会带上中文原因,沙箱判题没有这个字段 */ + error_message?: string | null [key: string]: unknown } @@ -350,13 +356,17 @@ export async function judgeSubmission(job: JudgeJobData) { ? `${template.prepend}\n${row.submission.code}\n${template.append}` : row.submission.code - const response = await requestJudge( - row.submission.language, - source, - row.problem.timeLimit, - row.problem.memoryLimit, - row.problem.testCaseId, - ) + // SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集, + // 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。 + const response = row.submission.language === "SQL" + ? await judgeSqlSubmission(row.problem, row.submission.code) + : await requestJudge( + row.submission.language, + source, + row.problem.timeLimit, + row.problem.memoryLimit, + row.problem.testCaseId, + ) let result: JudgeStatusValue let info: unknown = {} @@ -386,6 +396,13 @@ export async function judgeSubmission(job: JudgeJobData) { memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)), score: 0, } + // SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的 + // error_message 里,而前端只读 statistic_info.err_info。把首个失败测试点的 + // 提示提上来,否则学生只看到一个 WA 却不知道原因。对齐旧 sql_dispatcher。 + const failedMessage = cases.find( + (item) => item.result !== JudgeStatus.ACCEPTED && item.error_message, + )?.error_message + if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage if (result === JudgeStatus.ACCEPTED) { const rules = astRulesForLanguage( @@ -454,3 +471,76 @@ export async function judgeSubmission(job: JudgeJobData) { await markSystemError(row.submission.id, row.submission.userId, error) } } + + +/** + * SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构, + * 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。 + * 对齐旧 `judge/sql_dispatcher.py`。 + */ +async function judgeSqlSubmission( + problem: typeof schema.problem.$inferSelect, + studentSql: string, +): Promise { + const sqlConfig = objectValue(problem.sqlConfig) + const mode = sqlConfig.mode + if (mode !== "query" && mode !== "modify") { + throw new Error("题目缺少 SQL 配置(题型)") + } + const answers = Array.isArray(problem.answers) ? problem.answers : [] + const refSql = answers + .map((item) => objectValue(item)) + .find((item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim())?.code + if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案") + + const info = await readInfo(problem.testCaseId) + if (!info) throw new Error("测试点信息读取失败") + if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包") + + // 按 "1","2",… 的数字序遍历,保证测试点顺序稳定 + const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b)) + if (keys.length === 0) throw new Error("题目没有任何测试点") + + const cases: JudgeCase[] = [] + for (const [index, key] of keys.entries()) { + const inputName = info.test_cases![key]!.input_name + const initSql = await readFile( + resolvePath(config.testCaseDirectory, problem.testCaseId, inputName), + "utf8", + ).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) }) + + const outcome = await runSqlCase({ + kind: "judge", + initSql, + refSql, + studentSql, + mode, + orderSensitive: sqlConfig.order_sensitive === true, + timeLimitMs: problem.timeLimit, + memoryLimitMb: problem.memoryLimit, + }) + if (!outcome.ok) { + // 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR + if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message) + // 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点 + cases.push({ + test_case: String(index + 1), + result: outcome.result, + cpu_time: 0, + real_time: 0, + memory: 0, + error_message: outcome.message, + } as unknown as JudgeCase) + break + } + const value = outcome.value + value.test_case = String(index + 1) + // 语法错误与数据无关,首个测试点即可确认,整题按编译错误处理 + // (ACM 不罚时,前端展示 err_info) + if (index === 0 && value.result === JudgeStatus.COMPILE_ERROR) { + return { err: "CompileError", data: value.error_message } + } + cases.push(value as unknown as JudgeCase) + } + return { err: null, data: cases } +} diff --git a/apps/api/src/judge/sql/child.ts b/apps/api/src/judge/sql/child.ts new file mode 100644 index 0000000..04f1561 --- /dev/null +++ b/apps/api/src/judge/sql/child.ts @@ -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() diff --git a/apps/api/src/judge/sql/engine.ts b/apps/api/src/judge/sql/engine.ts new file mode 100644 index 0000000..70337f2 --- /dev/null +++ b/apps/api/src/judge/sql/engine.ts @@ -0,0 +1,467 @@ +/** + * SQL 题判题核心:在 WASM SQLite 里分别执行标准答案和学生 SQL 并比对结果。 + * 移植自旧后端 `judge/sql_runner.py`,判定口径逐条对齐。 + * + * 查询题(mode="query")比对最后一条 SELECT 的结果集; + * 增删改题(mode="modify")比对执行后所有用户表的最终状态。 + * + * ## 防护为什么和旧实现不一样 + * + * 旧实现用 Python sqlite3 的 set_authorizer / set_progress_handler / setlimit 三件套。 + * bun:sqlite 一个都没有,且实测 Worker.terminate() 杀不掉跑飞的查询(原生代码占着线程)。 + * 所以改成「WASM 引擎 + 独立子进程」,逐条替代: + * + * | 旧防护 | 新做法 | + * |---|---| + * | authorizer 禁 ATTACH(防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定,ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS | + * | authorizer 白名单让查询题只读 | `PRAGMA query_only=1`,SQLite 原生只读开关 | + * | progress_handler 墙钟超时 | 子进程外部 SIGKILL(OS 级,比指令计数更硬)+ 语句间 deadline 检查 | + * | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -v`,触顶时 WASM 抛可捕获错误 | + * | max_page_count | 原样保留 | + */ + +import initSqlJs, { type Database, type SqlJsStatic } from "sql.js" +import { readFileSync } from "node:fs" + +import { JudgeStatus, type JudgeStatusValue } from "../status" + +/** 单结果集/单表最大行数,防 CROSS JOIN 撑爆内存 */ +const ROW_LIMIT = 10_000 +/** 题目页展示的行数上限(示例数据/期望结果) */ +const DISPLAY_ROW_LIMIT = 20 +const ERROR_MESSAGE_MAX_LEN = 200 + +/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */ +const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"] + +export class SqlCaseError extends Error { + constructor(readonly result: JudgeStatusValue, readonly detail: string) { + super(detail) + } +} + +let cached: SqlJsStatic | null = null + +export async function sqlEngine() { + if (cached) return cached + const binary = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm")) + // @types/sql.js 把 wasmBinary 标成 ArrayBuffer,实际 emscripten 接受 TypedArray; + // 这里传 Uint8Array 是运行时正确的写法,类型上断言掉 + cached = await initSqlJs({ wasmBinary: binary as unknown as ArrayBuffer }) + return cached +} + +function truncate(message: string) { + return message.length > ERROR_MESSAGE_MAX_LEN + ? `${message.slice(0, ERROR_MESSAGE_MAX_LEN)}...` + : message +} + +// ---------------------------------------------------------------- 值归一化 + +type Canonical = string + +/** + * 值归一化并打类型标签,防止 NULL/"NULL"、1/"1" 碰撞;数值统一比对 + * (1 == 1.0,浮点保留 6 位有效数字)。与旧 `_canonical_value` 同口径。 + */ +function canonicalValue(value: unknown): Canonical { + if (value === null || value === undefined) return "null" + if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}` + if (typeof value === "number") { + if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}` + // Python 的 format(v, ".6g") + return `num:${formatG6(value)}` + } + if (typeof value === "bigint") return `num:${value}` + return `str:${String(value)}` +} + +/** 等价于 Python 的 format(v, ".6g") */ +function formatG6(value: number) { + const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))) + if (exponent < -4 || exponent >= 6) { + return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2") + } + const text = value.toPrecision(6) + return text.includes(".") ? text.replace(/\.?0+$/, "") : text +} + +function canonicalRow(row: unknown[]) { + return row.map(canonicalValue).join("") +} + +// ---------------------------------------------------------------- 执行 + +interface ResultSet { + columns: number + rows: string[] +} + +function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) { + const db = new SQL.Database() + const limit = Math.max(Math.trunc(memoryLimitMb), 1) + db.run("PRAGMA page_size=4096") + // 4096B/页 × 256 页/MB,超限报 "database or disk is full" + db.run(`PRAGMA max_page_count=${limit * 256}`) + return db +} + +/** + * 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。 + * + * 用 sql.js 的 iterateStatements(底层是 sqlite3_prepare_v2 逐条推进), + * 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。 + */ +function executeStatements(db: Database, script: string, deadline: number): ResultSet | null { + let last: ResultSet | null = null + for (const statement of (db as unknown as { + iterateStatements(sql: string): Iterable<{ + step(): boolean + get(): unknown[] + getColumnNames(): string[] + free(): void + }> + }).iterateStatements(script)) { + if (Date.now() > deadline) { + statement.free() + throw new Error("interrupted") + } + try { + const names = statement.getColumnNames() + if (names.length > 0) { + const rows: string[] = [] + while (statement.step()) { + rows.push(canonicalRow(statement.get())) + if (rows.length > ROW_LIMIT) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`) + } + } + last = { columns: names.length, rows } + } else { + while (statement.step()) { /* 无结果集语句,推进到结束 */ } + } + } finally { + statement.free() + } + } + return last +} + +/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */ +function dumpTables(db: Database) { + const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + const state: Record = {} + for (const table of names) { + const quoted = String(table).replaceAll('"', '""') + const result = db.exec(`SELECT * FROM "${quoted}"`) + const first = result[0] + const rows = (first?.values ?? []).map((row) => canonicalRow(row as unknown[])) + if (rows.length > ROW_LIMIT) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`) + } + state[String(table)] = { + // 空表 exec 不返回结果,列数用 table_info 兜底 + columns: first?.columns.length ?? tableColumnCount(db, quoted), + rows: rows.sort(), + } + } + return state +} + +function tableColumnCount(db: Database, quotedTable: string) { + return db.exec(`PRAGMA table_info("${quotedTable}")`)[0]?.values.length ?? 0 +} + +function queryColumn(db: Database, sql: string) { + return (db.exec(sql)[0]?.values ?? []).map((row) => row[0]) +} + +function trustedErrorText(message: string) { + if (message.includes("interrupted")) return "超时" + return truncate(message) +} + +/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */ +function executeTrusted(db: Database, script: string, deadline: number, prefix: string) { + try { + return executeStatements(db, script, deadline) + } catch (error) { + if (error instanceof SqlCaseError) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`) + } + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`) + } +} + +/** 带防护执行学生 SQL,异常映射为学生级 JudgeStatus */ +function runStudent(db: Database, script: string, mode: string, deadline: number) { + // 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单 + if (mode === "query") db.run("PRAGMA query_only=1") + try { + const last = executeStatements(db, script, deadline) + if (mode === "query") return last + return dumpTables(db) + } catch (error) { + if (error instanceof SqlCaseError) throw error + const message = String((error as Error).message) + if (message.includes("interrupted")) { + throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时") + } + if (message.includes("database or disk is full")) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制") + } + // WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限 + if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制") + } + if (message.includes("readonly database")) { + throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)") + } + if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) { + throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message)) + } + throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message)) + } finally { + if (mode === "query") { + try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ } + } + } +} + +function compare( + expected: unknown, + actual: unknown, + mode: string, + orderSensitive: boolean, +) { + if (mode === "query") { + const exp = expected as ResultSet + const act = actual as ResultSet + if (exp.columns !== act.columns) return false + if (orderSensitive) return exp.rows.join("") === act.rows.join("") + return [...exp.rows].sort().join("") === [...act.rows].sort().join("") + } + return JSON.stringify(expected) === JSON.stringify(actual) +} + +export interface RunCaseOptions { + mode: "query" | "modify" + orderSensitive: boolean + timeLimitMs: number + memoryLimitMb: number +} + +export interface CaseResult { + test_case: string + result: JudgeStatusValue + cpu_time: number + real_time: number + memory: number + signal: number + exit_code: number + error: number + output_md5: string + error_message: string | null +} + +/** + * 判一个测试点,返回与外部 judger 单测试点同构的结构。 + * 学生错误(CE/WA/TLE/MLE/RE)体现在返回值里;出题配置错误抛 SqlCaseError(SYSTEM_ERROR)。 + */ +export async function runCase( + initSql: string, + refSql: string, + studentSql: string, + options: RunCaseOptions, +): Promise { + const SQL = await sqlEngine() + // 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞 + const trustedLimitMs = Math.max(options.timeLimitMs * 5, 10_000) + + let expected: unknown + const refDb = newDatabase(SQL, options.memoryLimitMb) + try { + executeTrusted(refDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败") + const last = executeTrusted(refDb, refSql, Date.now() + trustedLimitMs, "标准答案执行失败") + if (options.mode === "query") { + expected = last + } else { + try { + expected = dumpTables(refDb) + } catch (error) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`) + } + } + } finally { + refDb.close() + } + if (options.mode === "query" && expected === null) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集") + } + + const result: CaseResult = { + test_case: "", + result: JudgeStatus.ACCEPTED, + cpu_time: 0, + real_time: 0, + memory: 0, + signal: 0, + exit_code: 0, + error: 0, + output_md5: "", + error_message: null, + } + + const studentDb = newDatabase(SQL, options.memoryLimitMb) + let actual: unknown + let elapsed = 0 + try { + executeTrusted(studentDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败") + const start = Date.now() + try { + actual = runStudent(studentDb, studentSql, options.mode, start + options.timeLimitMs) + } catch (error) { + elapsed = Date.now() - start + const failure = error as SqlCaseError + return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed } + } + elapsed = Date.now() - start + } finally { + studentDb.close() + } + + result.cpu_time = elapsed + result.real_time = elapsed + if (options.mode === "query" && (actual === null || actual === undefined)) { + result.result = JudgeStatus.WRONG_ANSWER + result.error_message = "提交的 SQL 未产生查询结果集" + } else if (!compare(expected, actual, options.mode, options.orderSensitive)) { + result.result = JudgeStatus.WRONG_ANSWER + } + return result +} + +// ---------------------------------------------------------------- 题目页展示数据 + +function displayValue(value: unknown) { + if (value instanceof Uint8Array) return Buffer.from(value).toString("hex") + return value as string | number | null +} + +interface DisplayTable { + name: string + columns: { name: string; type: string }[] + rows: (string | number | null)[][] + total_rows: number + truncated: boolean + dropped?: boolean +} + +/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */ +function dumpDisplayTables(db: Database, only?: Set): DisplayTable[] { + const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + const tables: DisplayTable[] = [] + for (const raw of names) { + const name = String(raw) + if (only && !only.has(name)) continue + const quoted = name.replaceAll('"', '""') + const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({ + name: String(row[1]), + type: String(row[2] ?? ""), + })) + const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0) + const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? []) + .map((row) => (row as unknown[]).map(displayValue)) + tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT }) + } + return tables +} + +/** + * 给查询结果的列名标上类型:按列名回查数据表的声明类型,与数据表展示同源(如 VARCHAR(20))。 + * 表达式/聚合列(COUNT(*)、别名等)在数据表里无同名列,类型留空(前端隐藏)。 + */ +function queryResultColumns(names: string[], tables: DisplayTable[]) { + const types = new Map() + for (const table of tables) { + for (const column of table.columns) types.set(column.name, column.type) + } + return names.map((name) => ({ name, type: types.get(name) ?? "" })) +} + +/** 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题) */ +export async function buildDisplay( + initSql: string, + refSql: string, + mode: "query" | "modify", + memoryLimitMb = 64, +) { + const SQL = await sqlEngine() + const db = newDatabase(SQL, memoryLimitMb) + const deadline = Date.now() + 10_000 + try { + executeTrusted(db, initSql, deadline, "初始化脚本执行失败") + const tables = dumpDisplayTables(db) + + if (mode === "query") { + let expected: unknown = null + try { + for (const statement of (db as unknown as { + iterateStatements(sql: string): Iterable<{ + step(): boolean; get(): unknown[]; getColumnNames(): string[]; free(): void + }> + }).iterateStatements(refSql)) { + try { + const names = statement.getColumnNames() + if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue } + const rows: unknown[][] = [] + while (statement.step()) { + rows.push(statement.get()) + if (rows.length > ROW_LIMIT) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`) + } + } + expected = { + columns: queryResultColumns(names, tables), + rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)), + total_rows: rows.length, + truncated: rows.length > DISPLAY_ROW_LIMIT, + } + } finally { + statement.free() + } + } + } catch (error) { + if (error instanceof SqlCaseError) throw error + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`) + } + if (expected === null) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集") + } + return { tables, expected } + } + + const before = dumpTables(db) + executeTrusted(db, refSql, Date.now() + 10_000, "标准答案执行失败") + const after = dumpTables(db) + const changed = new Set() + for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name) + } + if (changed.size === 0) { + throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置") + } + const changedTables = dumpDisplayTables(db, changed) + // 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」) + const existing = new Set(changedTables.map((table) => table.name)) + for (const table of tables) { + if (changed.has(table.name) && !existing.has(table.name)) { + changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true }) + } + } + return { tables, expected: { changed_tables: changedTables } } + } finally { + db.close() + } +} diff --git a/apps/api/src/judge/sql/index.ts b/apps/api/src/judge/sql/index.ts new file mode 100644 index 0000000..63f1bbc --- /dev/null +++ b/apps/api/src/judge/sql/index.ts @@ -0,0 +1,88 @@ +import { resolve } from "node:path" + +import { JudgeStatus, type JudgeStatusValue } from "../status" +import type { CaseResult } from "./engine" +import type { SqlJob } from "./child" + +/** + * SQL 作业的父进程侧:起一个短命子进程跑,到点 SIGKILL。 + * + * 为什么非要子进程:SQLite 查询在原生代码里执行,实测 Worker.terminate() 抢占不了 + * (递归 CTE 死循环能把整个 worker 卡死),只有 OS 的 SIGKILL 可靠。 + * + * 内存交给 OS:`ulimit -d`(数据段)给子进程封顶,触顶时 WASM 抛可捕获错误, + * 子进程照常回报 MEMORY_LIMIT_EXCEEDED。 + * + * **必须用 -d 不能用 -v。** -v 限的是虚拟地址空间,而 JS 引擎会预留巨量地址, + * 实测 -v 之下 Bun 退出时有概率 panic(SIGILL)—— 结果早已写出但进程异常终止, + * 父进程读到空串误判成超时,时好时坏。-d 限的是实际提交的内存(Linux 4.7 起 + * 也覆盖匿名 mmap),512MB 下正常判题稳定、`hex(zeroblob(2e8))` 被拦。 + */ + +/** 子进程数据段上限(KB)。低于 512MB Bun 自己起不来 */ +const CHILD_DATA_LIMIT_KB = 512 * 1024 +/** 父进程的兜底墙钟。比作业自报的时限宽裕,只负责杀掉真正跑飞的进程 */ +const HARD_TIMEOUT_SLACK_MS = 15_000 + +export interface SqlJobFailure { + ok: false + result: JudgeStatusValue + message: string +} + +type SqlJobOutcome = { ok: true; value: T } | SqlJobFailure + +async function runJob(job: SqlJob, budgetMs: number): Promise> { + const entry = resolve(import.meta.dir, "child.ts") + // 经 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" }, + ) + child.stdin.write(JSON.stringify(job)) + await child.stdin.end() + + const timer = setTimeout(() => child.kill("SIGKILL"), budgetMs + HARD_TIMEOUT_SLACK_MS) + let stdout = "" + let stderr = "" + try { + ;[stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + await child.exited + } finally { + clearTimeout(timer) + } + + if (!stdout.trim()) { + // 子进程没来得及写结果就没了 —— 要么被我们 SIGKILL,要么被内核 OOM 掉。 + // 用 stderr 里的阶段标记区分:卡在受信脚本是出题问题,卡在学生 SQL 是超时。 + const phase = stderr.includes("@phase:") ? stderr.split("@phase:")[1]?.split("\n")[0] : null + if (phase === "display") { + return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案" } + } + return { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" } + } + + try { + const parsed = JSON.parse(stdout) as + | { ok: true; case?: CaseResult; display?: unknown } + | SqlJobFailure + if (!parsed.ok) return parsed + return { ok: true, value: (parsed.case ?? parsed.display) as T } + } catch { + return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" } + } +} + +export function runSqlCase(job: Extract) { + return runJob(job, Math.max(job.timeLimitMs * 5, 10_000)) +} + +export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") { + return runJob<{ tables: unknown[]; expected: unknown }>( + { kind: "display", initSql, refSql, mode }, + 10_000, + ) +} diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index e035091..99587f5 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -6,6 +6,9 @@ import { createProblemRequestSchema, makeProblemPublicRequestSchema, updateProblemRequestSchema, + generateSqlTestCaseRequestSchema, + generateSqlTestCaseResponseSchema, + sqlPreviewRequestSchema, sqlTestCaseScriptSchema, uploadTestCaseResponseSchema, } from "@oj2/contract" @@ -16,8 +19,13 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware" import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, success } from "../../http" +import { buildSqlDisplay } from "../../judge/sql" +import { completeChat } from "../../services/ai" import { contestStatus } from "../../services/contest" import { packTestCaseZip, processTestCaseZip, readInfo, readSqlScripts, TestCaseError } from "../../services/test-case" +import { config } from "../../config" +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers" export const adminProblemRoutes = new Hono() @@ -123,13 +131,7 @@ async function serialize(row: ProblemRow) { }) } -/** - * 非 SQL 题的公共校验,对齐旧 `ProblemBase.common_checks` 的 else 分支。 - * - * SQL 分支在这里**只做前置校验、不生成 sqlDisplay** —— 生成需要跑 SQLite, - * 而新后端还没有 SQL 判题链路(旧 judge/sql_runner.py 378 行无对应实现)。 - * 因此新建 SQL 题会被拒绝,编辑 SQL 题则保留原有 sqlDisplay 不动,不会把已有数据弄坏。 - */ +/** 公共校验,对齐旧 `ProblemBase.common_checks` */ function commonChecks(data: { languages: string[] inputDescription: string @@ -153,8 +155,38 @@ function commonChecks(data: { return { sql: false } } -const SQL_NOT_SUPPORTED = - "新后端尚未实现 SQL 判题链路(题目页展示数据需要跑 SQLite 生成),暂不能新建 SQL 题。已有 SQL 题可以正常编辑,其展示数据保持不变。" +/** + * SQL 题保存时生成题目页展示数据(数据表 + 期望结果)。 + * 对齐旧 `problem/utils.py:generate_sql_display`:取**测试点 1** 的初始化脚本 + 标准答案跑一遍。 + * 失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构与期望结果,宁可不保存也不能存错的。 + */ +async function generateSqlDisplay( + testCaseId: string, + answers: Record[], + sqlConfig: Record, +): Promise<{ error: string } | { display: unknown }> { + const info = await readInfo(testCaseId) + if (!info) return { error: "测试点信息读取失败,请重新上传测试点" } + if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" } + const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b)) + if (keys.length === 0) return { error: "题目没有任何测试点" } + const inputName = info.test_cases![keys[0]!]?.input_name + if (!inputName) return { error: "测试点信息损坏,请重新上传测试点" } + let initSql: string + try { + initSql = await readFile(resolve(config.testCaseDirectory, testCaseId, inputName), "utf8") + } catch { + return { error: `测试点脚本 ${inputName} 读取失败` } + } + const refSql = answers.find( + (item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim(), + )?.code + if (typeof refSql !== "string") return { error: "题目缺少 SQL 标准答案" } + const mode = sqlConfig.mode === "modify" ? "modify" as const : "query" as const + const outcome = await buildSqlDisplay(initSql, refSql, mode) + if (!outcome.ok) return { error: `SQL 展示数据生成失败: ${outcome.message}` } + return { display: outcome.value } +} function problemValues(data: ReturnType, isSql: boolean) { return { @@ -257,7 +289,12 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { } const checked = commonChecks(parsed.data) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) - if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED) + let sqlDisplay: unknown = null + if (checked.sql) { + const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) + if ("error" in built) return failure(c, 400, "invalid-problem", built.error) + sqlDisplay = built.display + } const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem) .where(and(eq(schema.problem.displayId, parsed.data._id), isNull(schema.problem.contestId))).limit(1) @@ -266,7 +303,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { const now = new Date().toISOString() const created = await db.transaction(async (tx) => { const [row] = await tx.insert(schema.problem).values({ - ...problemValues(parsed.data, false), + ...problemValues(parsed.data, checked.sql), contestId: null, createdById: c.get("user")!.id, createTime: now, @@ -275,7 +312,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { acceptedNumber: 0, statisticInfo: {}, isPublic: false, - sqlDisplay: null, + sqlDisplay, }).returning() await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags) return row! @@ -308,12 +345,19 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => { )).limit(1) if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists") + // SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上 + let sqlDisplay: unknown = null + if (checked.sql) { + const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) + if ("error" in built) return failure(c, 400, "invalid-problem", built.error) + sqlDisplay = built.display + } + const updated = await db.transaction(async (tx) => { const [row] = await tx.update(schema.problem).set({ ...problemValues(parsed.data, checked.sql), + sqlDisplay, lastUpdateTime: new Date().toISOString(), - // SQL 题的 sqlDisplay 原样保留:重新生成需要跑 SQLite,新后端还没有那条链路。 - // 不动它比生成一个错的更安全 —— 它直接决定题目页给学生看的表结构与期望结果。 }).where(eq(schema.problem.id, id)).returning() await setTags(tx as unknown as typeof db, id, parsed.data.tags) return row! @@ -411,7 +455,12 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio } const checked = commonChecks(parsed.data) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) - if (checked.sql) return failure(c, 501, "sql-not-supported", SQL_NOT_SUPPORTED) + let sqlDisplay: unknown = null + if (checked.sql) { + const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) + if ("error" in built) return failure(c, 400, "invalid-problem", built.error) + sqlDisplay = built.display + } const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem) .where(and(eq(schema.problem.displayId, parsed.data._id), eq(schema.problem.contestId, contestId))).limit(1) @@ -420,7 +469,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio const now = new Date().toISOString() const created = await db.transaction(async (tx) => { const [row] = await tx.insert(schema.problem).values({ - ...problemValues(parsed.data, false), + ...problemValues(parsed.data, checked.sql), contestId, createdById: user.id, createTime: now, @@ -594,3 +643,37 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as return failure(c, 500, "test-case-error", "测试点脚本读取失败") } }) + +/** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */ +adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => { + const parsed = sqlPreviewRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") + } + const outcome = await buildSqlDisplay(parsed.data.initSql, parsed.data.refSql, parsed.data.mode) + if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message) + return success(c, outcome.value) +}) + +/** AI 按标准答案倒推表结构、生成一份自洽的初始化脚本 */ +adminProblemRoutes.post("/sql-test-cases/generate", requireProblemPermission, async (c) => { + const parsed = generateSqlTestCaseRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") + } + try { + const sql = await completeChat( + `你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的 +SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。 +请你推断出该标准答案所需要的表结构,生成一份自洽的 SQLite 兼容初始化脚本, +包含 CREATE TABLE 和若干条 INSERT 语句,插入的数据要足够让标准答案跑出有意义的结果 +(比如查询题要有能被筛选出来和被过滤掉的行;增删改题要有能被改动和不受影响的行)。 +请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`, + `题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`, + ) + return success(c, generateSqlTestCaseResponseSchema.parse({ sql })) + } catch (error) { + console.error("SQL test case generation failed", error) + return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") + } +}) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 716060c..1b726b1 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -1,4 +1,3 @@ -import http from "utils/http" import api2 from "utils/api2" import { legacyResponse } from "utils/legacy" import { toProblemListItem } from "admin/transforms" @@ -223,7 +222,13 @@ export function previewSQLTestcase(data: { ref_sql: string mode: "query" | "modify" }) { - return http.post("admin/sql_test_case_preview", data) + return legacyResponse( + api2.post("admin/sql-test-cases/preview", { + initSql: data.init_sql, + refSql: data.ref_sql, + mode: data.mode, + }), + ) } // 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序) @@ -238,7 +243,12 @@ export function generateSQLTestcase(data: { ref_sql: string mode: "query" | "modify" }) { - return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data) + return legacyResponse<{ sql: string }>( + api2.post("admin/sql-test-cases/generate", { + refSql: data.ref_sql, + mode: data.mode, + }), + ) } /** 组件里的题目对象是 snake_case,出站转成新后端要的 camelCase */ diff --git a/bun.lock b/bun.lock index 8e53b6b..553824f 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,14 @@ "hono": "^4.0.0", "ioredis": "^6.0.0", "postgres": "^3.4.0", + "sql.js": "^1.14.1", "tree-sitter-c": "^0.24.1", "tree-sitter-python": "^0.25.0", "web-tree-sitter": "^0.26.11", "zod": "^4.0.0", }, "devDependencies": { + "@types/sql.js": "^1.4.11", "drizzle-kit": "^0.31.10", }, }, @@ -633,6 +635,8 @@ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "https://registry.npmjs.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], + "@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/event-emitter": ["@types/event-emitter@0.3.5", "https://registry.npmjs.com/@types/event-emitter/-/event-emitter-0.3.5.tgz", {}, "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ=="], @@ -655,6 +659,8 @@ "@types/retry": ["@types/retry@0.12.2", "https://registry.npmjs.com/@types/retry/-/retry-0.12.2.tgz", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="], + "@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmjs.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], @@ -1321,6 +1327,8 @@ "source-map-support": ["source-map-support@0.5.21", "https://registry.npmjs.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "sql.js": ["sql.js@1.14.1", "https://registry.npmjs.com/sql.js/-/sql.js-1.14.1.tgz", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], + "ssr-window": ["ssr-window@4.0.2", "https://registry.npmjs.com/ssr-window/-/ssr-window-4.0.2.tgz", {}, "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ=="], "standard-as-callback": ["standard-as-callback@2.1.0", "https://registry.npmjs.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], diff --git a/docs/specs/phase3-coverage.md b/docs/specs/phase3-coverage.md index acfe15e..8eb3f9d 100644 --- a/docs/specs/phase3-coverage.md +++ b/docs/specs/phase3-coverage.md @@ -178,3 +178,41 @@ end $$; ``` 症状很隐蔽:读全部正常,只有**写**才炸,而且是导入后第一次写才炸。 + +--- + +## SQL 判题链路(阶段 2 补课,2026-08-07) + +阶段 4 做题目管理时才发现:新后端**完全没有 SQL 判题**。旧后端有 +`judge/sql_runner.py`(378 行)+ `sql_dispatcher.py`(113 行),走的是与沙箱完全 +不同的路径(跑 SQLite 比结果集)。阶段 2 纵切时只打通了沙箱那条线,漏了这条。 + +### 防护为什么换了实现 + +旧实现靠 Python sqlite3 的三件套。`bun:sqlite` 一个都没有,实测: + +| | 结论 | +|---|---| +| `setAuthorizer` / `setProgressHandler` / `setLimit` | 均无 | +| `PRAGMA max_page_count` | 有效 | +| `Worker.terminate()` 能否停掉跑飞的查询 | **不能** —— 递归 CTE 死循环卡死整个 worker,只能从外面杀进程 | +| `node:sqlite` | 该 Bun 版本不可用 | + +因此改成「WASM 引擎(sql.js)+ 独立子进程」,逐条替代: + +| 旧防护 | 新做法 | 实测 | +|---|---|---| +| authorizer 禁 ATTACH | WASM 无宿主文件系统绑定,**结构上**够不到 | `attach '/etc/passwd'` → `unable to open database` | +| authorizer 白名单让查询题只读 | `PRAGMA query_only=1` | 查询题里 INSERT → 运行错误并说明 | +| progress_handler 墙钟超时 | 子进程外部 SIGKILL | 递归 CTE 死循环 → CPU 超时 | +| `setlimit(LIMIT_LENGTH)` | 子进程 `ulimit -d` | `hex(zeroblob(2e8))` → 内存超限 | + +ATTACH 这条比旧实现**更强**:旧的靠 authorizer 拦,新的是够不到。 + +### 两个踩过的坑 + +1. **`ulimit` 必须用 `-d` 不能用 `-v`。** `-v` 限虚拟地址空间,而 JS 引擎预留巨量地址; + 实测 `-v` 之下 Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止, + 父进程读到空串误判成超时 —— 6 次里坏 2 次,时好时坏。换 `-d`(实际提交内存, + Linux 4.7 起也覆盖匿名 mmap)后 12/12 稳定。 +2. 子进程写完结果**直接 SIGKILL 自己**,不走 `process.exit()` —— 后者仍有一段清理会撞限额。 diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 3205a40..4eefabf 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -608,3 +608,16 @@ export const sqlTestCaseScriptSchema = z.object({ name: z.string(), content: z.string(), }) + +export const sqlPreviewRequestSchema = z.object({ + initSql: z.string().min(1).max(1024 * 1024), + refSql: z.string().min(1).max(1024 * 1024), + mode: z.enum(["query", "modify"]), +}) + +export const generateSqlTestCaseRequestSchema = z.object({ + refSql: z.string().min(1).max(64 * 1024), + mode: z.enum(["query", "modify"]), +}) + +export const generateSqlTestCaseResponseSchema = z.object({ sql: z.string() })