diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 81ecc94..e4c0359 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -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 { + 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) { diff --git a/apps/api/src/judge/runtime-diagnosis.ts b/apps/api/src/judge/runtime-diagnosis.ts new file mode 100644 index 0000000..b4f45b8 --- /dev/null +++ b/apps/api/src/judge/runtime-diagnosis.ts @@ -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 + +const IDENTIFIER = /^[A-Za-z_]\w*$/ + +/** 按消息句式细分。键是异常类型,值是 [句式, kind],从上往下取第一个对上的 */ +const KINDS: Record = { + 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 +} diff --git a/apps/web/src/oj/problem/components/PythonErrorExplain.vue b/apps/web/src/oj/problem/components/PythonErrorExplain.vue index b412de4..a646d07 100644 --- a/apps/web/src/oj/problem/components/PythonErrorExplain.vue +++ b/apps/web/src/oj/problem/components/PythonErrorExplain.vue @@ -63,7 +63,7 @@ onUnmounted(clearErrorMark)