diff --git a/apps/api/src/judge/sql/engine.ts b/apps/api/src/judge/sql/engine.ts index a011aef..0cd6409 100644 --- a/apps/api/src/judge/sql/engine.ts +++ b/apps/api/src/judge/sql/engine.ts @@ -162,6 +162,41 @@ function leadingKeyword(statement: PreparedStatement) { return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? "" } +/** + * 让题目的 `memoryLimit` 对学生真正生效的记账器。 + * + * 旧实现用 `setlimit(SQLITE_LIMIT_LENGTH, memoryLimit)` 把单值长度贴着题目内存限。 + * sql.js 的 wasm **没导出** `sqlite3_limit`(已核对导出表),复刻不了,于是改成 + * 取行时按字节记账:单值超限、或整个结果集累计超限,都按 MLE 拒掉。 + * + * 不这么做的话题目写 64MB 也没意义:唯一的硬顶是子进程那个固定 512MB 的 + * `ulimit -d`(见 index.ts),64MB 的题学生实际能吃到 8 倍。 + * `max_page_count` 管的是库文件页数,管不住「一个 SELECT 拼出一个巨大的值」。 + */ +class ByteBudget { + private used = 0 + + constructor(private readonly maxBytes: number) {} + + charge(row: unknown[]) { + for (const value of row) { + const bytes = + value instanceof Uint8Array + ? value.byteLength + : typeof value === "string" + ? Buffer.byteLength(value) + : 8 // 数字和 NULL 按定长算,撑不出内存 + if (bytes > this.maxBytes) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制") + } + this.used += bytes + if (this.used > this.maxBytes) { + throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制") + } + } + } +} + /** * 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。 * @@ -169,12 +204,14 @@ function leadingKeyword(statement: PreparedStatement) { * 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。 * * `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。 + * `budget` 只在跑学生 SQL 时传,受信脚本不记账。 */ function executeStatements( db: Database, script: string, deadline: number, guard?: (statement: PreparedStatement) => void, + budget?: ByteBudget, ): ResultSet | null { let last: ResultSet | null = null for (const statement of iterate(db, script)) { @@ -185,7 +222,9 @@ function executeStatements( if (names.length > 0) { const rows: string[] = [] while (statement.step()) { - rows.push(canonicalRow(statement.get())) + const row = statement.get() + budget?.charge(row) + rows.push(canonicalRow(row)) if (rows.length > ROW_LIMIT) { throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`) } @@ -202,14 +241,17 @@ function executeStatements( } /** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */ -function dumpTables(db: Database) { +function dumpTables(db: Database, budget?: ByteBudget) { 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[])) + const rows = (first?.values ?? []).map((row) => { + budget?.charge(row as unknown[]) + return canonicalRow(row as unknown[]) + }) if (rows.length > ROW_LIMIT) { throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`) } @@ -257,6 +299,8 @@ function runStudent( ) { // 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单 if (mode === "query") db.run("PRAGMA query_only=1") + // 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH) + const budget = new ByteBudget(Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024) try { const last = executeStatements(db, script, deadline, (statement) => { // query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0` @@ -268,9 +312,9 @@ function runStudent( // 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍 applyLimits(db, memoryLimitMb) if (mode === "query") db.run("PRAGMA query_only=1") - }) + }, budget) if (mode === "query") return last - return dumpTables(db) + return dumpTables(db, budget) } catch (error) { if (error instanceof SqlCaseError) throw error const message = String((error as Error).message) diff --git a/docs/specs/phase4-review-sql-sandbox.md b/docs/specs/phase4-review-sql-sandbox.md index 9482e98..c0936db 100644 --- a/docs/specs/phase4-review-sql-sandbox.md +++ b/docs/specs/phase4-review-sql-sandbox.md @@ -14,7 +14,27 @@ > - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换 > 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。 > 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR,不再甩给学生 TLE。 -> - **未修**:M-1 的「固定 512MB 与题目 `memoryLimit` 脱钩」、M-2、M-3。 +> **补充(2026-08-08,Minor 收尾)** +> +> - **M-1 已全部修完。** 第二半(`max_page_count` 学生可调大)在 I-1 拦 PRAGMA 时就一并 +> 修掉了。第一半改成引擎侧按字节记账(`ByteBudget`):单值超题目内存限、或结果集 +> 累计超限,都按 MLE 拒。旧实现的 `setlimit(SQLITE_LIMIT_LENGTH)` 复刻不了 —— +> sql.js 的 wasm 没导出 `sqlite3_limit`。实测**同一句 SQL 在 4MB 的题上被拦、 +> 在 64MB 的题上放行**,题目的 `memoryLimit` 现在对学生是真约束。 +> (测试尺寸特意选在 8MB 而不是 100MB:wasm 堆触顶时的兜底报的是同一句错误信息, +> 用大值测根本分不清是谁拦的 —— 第一版测试就是这么误判的。) +> - **M-3 已由阶段 5 的分阶段超时顺带解决**:预览从 25s 降到 **13005ms** 实测。 +> - **M-2 不修**(见下)。 + +> **M-2 的处理:不修,理由写在这里** +> +> 要做「强制两个测试点的期望结果不同」,得在建题时把每个测试点都跑一遍标准答案再比对。 +> 技术上可行,但它会把一类**合法出题**也挡掉(比如刻意用两组不同数据验证同一个边界、 +> 结果恰好相同),而代价是老师建题时被一条看不懂的错误拦住。评审也确认这与旧实现行为 +> 一致、非回归。 +> +> 更合适的做法是「提示」而不是「拒绝」,但那要动契约和后台前端。留给以后, +> 现在记在这里,免得下次评审又把它当成新发现。 > - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。 > 另记:stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` / > `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。