feat(题目): 运行时错误告诉学生第几行、为什么出错(Python 看回溯,C 看信号),不泄露测试数据

原来学生只看到「运行时错误」四个字:主判题 output: false,Python 回溯没回来;
C 的信号值在 info 里,而 info 只给管理员。

- Python 判出 RE 时,拿第一个失败测试点单独重跑一次(内联测试点 + output: true),
  从回溯取行号(扣掉模板前置行)、异常类型和按句式归的类别。主判题仍不开 output,
  免得每个点最多 16MB 的输出回到 worker
- C / C++ 取失败点的信号和退出码(段错误提示 scanf 漏 &、return 非 0 等)
- 写进 statistic_info.runtime_error;不存异常原文(里面常带测试点输入),名字只在
  出现在学生代码里时才存——raise Exception(input()) 实测只落下类型
- 前端按类型翻成中文、摆出出错那行并在编辑器标红;比赛提交不诊断(ACM 只报对错)
- 没有诊断的 RE 不再说「代码格式写错了」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 22:01:30 +08:00
1 parent 2a4405d2ea
commit db70ab62e8
7 files changed
+391 -11

No files matched your search

+72 -3
View File
@@ -19,6 +19,11 @@ import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql"
import {
nativeRuntimeError,
parsePythonTraceback,
type RuntimeErrorInfo,
} from "./runtime-diagnosis"
import { readInfo } from "../services/test-case"
import { readFile } from "node:fs/promises"
import { resolve as resolvePath } from "node:path"
@@ -63,12 +68,18 @@ function astRulesForLanguage(value: unknown, language: string): AstRule[] {
})
}
/**
* `testCase` 是测试点目录名(正常判题),或者内联的测试点(诊断重跑,见
* diagnoseRuntimeError)。`output` 只在诊断时打开:它让判题机把每个测试点的
* 程序输出原样带回来,正常判题开着的话,死循环打印能把 worker 内存撑爆。
*/
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCaseId: string,
testCase: string | { input: string; output: string }[],
output = false,
) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
@@ -85,8 +96,8 @@ async function requestJudge(
src: code,
max_cpu_time: timeLimit,
max_memory: 1024 * 1024 * memoryLimit,
test_case_id: testCaseId,
output: false,
...(typeof testCase === "string" ? { test_case_id: testCase } : { test_case: testCase }),
output,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
@@ -324,6 +335,48 @@ export async function failAbandonedSubmission(submissionId: string, error: unkno
await markSystemError(submissionId, row.userId, error)
}
/**
* 运行时错误的诊断,结果写进 `statistic_info.runtime_error`(字段说明见契约)。
*
* C / C++ 直接用失败测试点的信号和退出码。Python 要拿回溯,得**重跑那一个测试点**:
* 主判题不开 output(理由见 requestJudge),所以这里读出第一个失败测试点的输入,
* 以内联测试点的形式单独再跑一次、打开 output。同样的代码、同样的输入,抛的是
* 同一个异常。只多跑一个点,而且只在 Python 判出运行时错误时才跑。
*
* 诊断失败(读不到测试点、判题机没回回溯)就不写这个字段,学生看到的和原来一样。
*/
async function diagnoseRuntimeError(
row: {
submission: typeof schema.submission.$inferSelect
problem: typeof schema.problem.$inferSelect
},
source: string,
prependLines: number,
failed: JudgeCase,
): Promise<RuntimeErrorInfo | null> {
if (row.submission.language !== "Python")
return nativeRuntimeError(failed.signal, failed.exit_code)
const info = await readInfo(row.problem.testCaseId)
const inputName = info?.test_cases?.[failed.test_case]?.input_name
if (!inputName) return null
const input = await readFile(
resolvePath(config.testCaseDirectory, row.problem.testCaseId, inputName),
"utf8",
)
const response = await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
[{ input, output: "" }],
true,
)
const output = Array.isArray(response.data) ? response.data[0]?.output : null
if (typeof output !== "string") return null
return parsePythonTraceback(output, row.submission.code, prependLines)
}
export async function judgeSubmission(job: JudgeJobData) {
const [row] = await db
.select({
@@ -404,6 +457,22 @@ export async function judgeSubmission(job: JudgeJobData) {
)?.error_message
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
// 比赛不诊断:ACM 只报对错,和「通过几个测试点」、AI 提示不给比赛提交同一个口径
if (
result === JudgeStatus.RUNTIME_ERROR &&
firstFailure &&
row.submission.contestId === null
) {
const prependLines = template ? template.prepend.split("\n").length : 0
const diagnosis = await diagnoseRuntimeError(row, source, prependLines, firstFailure).catch(
(error: unknown) => {
console.warn(`[judge] 运行时错误诊断失败 ${row.submission.id}:`, error)
return null
},
)
if (diagnosis) statisticInfo.runtime_error = diagnosis
}
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(row.problem.astRules, row.submission.language)
if (rules.length > 0) {
+110
View File
@@ -0,0 +1,110 @@
import type { StatisticInfo } from "@oj2/contract"
/**
* 运行时错误的诊断:把判题机的原始结果收成 `statistic_info.runtime_error`,
* 前端再翻成中文(apps/web/src/oj/problem/utils/runtimeError.ts)。
*
* 原来学生拿到的只有「运行时错误」四个字:主判题是 `output: false`,Python 的回溯
* 根本没回来;C 的信号值倒是有,但在 `info` 里,而 `info` 只给管理员。
*
* Python 的回溯要靠**重跑一次**拿(run.ts 的 diagnoseRuntimeError):只拿第一个
* 失败的测试点、打开 output 单独跑。不给主判题开 output —— 那样每个测试点的完整
* 输出(每个最多 16MB)都会回到 worker,一个死循环打印就能把内存撑爆。
*
* **原文消息一律不存**。Python 的异常消息里常带着测试点的输入,比如
* `invalid literal for int() with base 10: '1 2'`,给学生看就等于把隐藏数据放出去 ——
* 故意写 `raise Exception(input())` 就能一个点一个点地套。所以只存:
* - 异常类型和归好类的 `kind`(按消息的**句式**判,不取消息里的值)
* - NameError / AttributeError 点名的名字,而且只在它**原样出现在学生代码里**时才存
*/
export type RuntimeErrorInfo = NonNullable<StatisticInfo["runtime_error"]>
const IDENTIFIER = /^[A-Za-z_]\w*$/
/** 按消息句式细分。键是异常类型,值是 [句式, kind],从上往下取第一个对上的 */
const KINDS: Record<string, [RegExp, string][]> = {
ValueError: [
[/^invalid literal for int\(\)/, "int-parse"],
[/^could not convert string to float/, "float-parse"],
[/^(not enough|too many) values to unpack/, "unpack"],
[/^math domain error/, "math-domain"],
],
TypeError: [
[/^can only concatenate str/, "str-concat"],
[/^unsupported operand type/, "operand"],
[/cannot be interpreted as an integer/, "not-int"],
[/not supported between instances of/, "compare"],
[/object is not callable/, "not-callable"],
[/object is not subscriptable/, "not-subscriptable"],
[/missing \d+ required positional argument/, "missing-arg"],
[/^(list|string|tuple) indices must be integers/, "index-type"],
],
IndexError: [[/out of range/, "index"]],
}
/** 名字在学生自己的代码里原样出现过才算数,否则它可能来自输入(`eval(input())`) */
function nameFromCode(name: string | undefined, code: string) {
if (!name || !IDENTIFIER.test(name)) return undefined
return new RegExp(`\\b${name}\\b`).test(code) ? name : undefined
}
/**
* 从程序输出里解析 Python 回溯。stdout 和 stderr 在判题机里写的是同一个文件,
* 所以回溯接在正常输出后面;只看末尾,取最后一段 Traceback(链式异常取最外层)。
*
* `prependLines` 是题目模板拼在学生代码前面的行数:判题跑的是
* `${prepend}\n${code}\n${append}`,回溯里的行号要扣掉它才对得上编辑器。
*/
export function parsePythonTraceback(
output: string,
code: string,
prependLines: number,
): RuntimeErrorInfo | null {
const tail = output.slice(-16_000)
const start = tail.lastIndexOf("Traceback (most recent call last):")
if (start < 0) return null
const lines = tail.slice(start).trimEnd().split("\n")
const last = lines[lines.length - 1]!
const head = last.match(/^([A-Za-z_][\w.]*)(?::\s?(.*))?$/)
if (!head) return null
const type = head[1]!.split(".").pop()!
const message = head[2] ?? ""
// 最后一个落在 solution.py 里的栈帧:错误发生在标准库里(statistics.mean([]) 之类)时,
// 最后一帧是库文件,学生要看的是自己调它的那一行
let sourceLine: number | null = null
for (const line of lines) {
const frame = line.match(/^\s*File "([^"]*)", line (\d+)/)
if (frame && frame[1]!.endsWith("solution.py")) sourceLine = Number(frame[2])
}
const codeLines = code.split("\n").length
const lineInCode = sourceLine === null ? null : sourceLine - prependLines
const info: RuntimeErrorInfo = {
line: lineInCode !== null && lineInCode >= 1 && lineInCode <= codeLines ? lineInCode : null,
type,
}
const kind = KINDS[type]?.find(([pattern]) => pattern.test(message))?.[1]
if (kind) info.kind = kind
if (type === "NameError" || type === "UnboundLocalError") {
const match = message.match(/^(?:name|cannot access local variable) '([^']+)'/)
const name = nameFromCode(match?.[1], code)
if (name) info.name = name
const suggestion = message.match(/Did you mean: '([^']+)'\?/)?.[1]
if (suggestion && IDENTIFIER.test(suggestion)) info.suggestion = suggestion
} else if (type === "AttributeError") {
const name = nameFromCode(message.match(/has no attribute '([^']+)'/)?.[1], code)
if (name) info.name = name
}
return info
}
/** C / C++:没有回溯,信号和退出码就是全部线索。两个都为 0 时没什么可说的 */
export function nativeRuntimeError(signal: unknown, exitCode: unknown): RuntimeErrorInfo | null {
const info: RuntimeErrorInfo = {}
if (typeof signal === "number" && signal > 0) info.signal = signal
if (typeof exitCode === "number" && exitCode !== 0) info.exit_code = exitCode
return info.signal || info.exit_code ? info : null
}