From db70ab62e856e41ab2f316005bb1bb9f9f9bf9d0 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 22:01:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E7=9B=AE):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E9=94=99=E8=AF=AF=E5=91=8A=E8=AF=89=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E7=AC=AC=E5=87=A0=E8=A1=8C=E3=80=81=E4=B8=BA=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E5=87=BA=E9=94=99=EF=BC=88Python=20=E7=9C=8B=E5=9B=9E=E6=BA=AF?= =?UTF-8?q?=EF=BC=8CC=20=E7=9C=8B=E4=BF=A1=E5=8F=B7=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=B3=84=E9=9C=B2=E6=B5=8B=E8=AF=95=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来学生只看到「运行时错误」四个字:主判题 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 --- apps/api/src/judge/run.ts | 75 +++++++++++- apps/api/src/judge/runtime-diagnosis.ts | 110 ++++++++++++++++++ .../problem/components/PythonErrorExplain.vue | 7 +- .../components/RuntimeErrorExplain.vue | 67 +++++++++++ .../problem/components/SubmissionResult.vue | 30 +++-- apps/web/src/oj/problem/utils/runtimeError.ts | 89 ++++++++++++++ packages/contract/src/submission.ts | 24 ++++ 7 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/judge/runtime-diagnosis.ts create mode 100644 apps/web/src/oj/problem/components/RuntimeErrorExplain.vue create mode 100644 apps/web/src/oj/problem/utils/runtimeError.ts 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)