Files
OJ2/apps/api/src/judge/trial.ts
T
xuyueandClaude Opus 5.5 96ce9725b2 fix(语法错误): 有模板的题报错行号多 1;else 后面写了条件被说成「少了冒号」,a+b=c 被说成「判断相等要用 ==」
全库 9182 条 Python 编译错误逐条过了一遍翻译表,1014 条的说明变了:

- 行号:判题跑的是「模板 + 代码」,运行时错误扣了模板行数、编译错误没扣,
  「第 N 行」和标红都落在下一行(691 条)。提交和运行例子两条路都扣上,
  gcc 的 main.c:N: 和代码摘录左边的行号一起扣。报错前先打了一条警告时
  File 那行是缩进的,行号原来解析不出来。
- 同一句报错要看出错那一行才分得开:expected ':' 九成是 else a<60:(行尾有冒号),
  「Maybe you meant '=='」只有 if / while 行才是真想判相等,a+b=c、a=96,b=92、
  print(1+1=2) 各有各的说法。
- 选了 Python 却交了 C 代码(294 条),直接说换语言。只凭 printf 不算:
  Python 里 print 手误成 printf 的不少。
- 单独说清楚的:elif: 没写条件、esle / else if、=< 写反、条件之间用逗号、
  漏了 +、√ ² ≤ ≥、顿号、全角 #、emoji(正则少了 u 标志)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:44:09 +08:00

193 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TrialRunResponse } from "@oj2/contract"
import { config } from "../config"
import { postJudge, statusValue, type JudgeCase, type JudgeResponse } from "./run"
import { nativeRuntimeError, parsePythonTraceback } from "./runtime-diagnosis"
import { JudgeStatus, type JudgeStatusValue } from "./status"
import { shiftCompileLines } from "./template"
/**
* 试运行:「运行例子」「自己输入」、后台「生成测试点」。
*
* 原来走的是另一台 Judge0(C 是 GCC 9、编译参数也不一样),例子上的结果偶尔和提交对不上。
* 现在和提交用同一台判题机、同一套编译命令(languages.ts),但**不进 BullMQ**:
* 学生在页面上等着看结果,排进判题队列要和正式提交抢那两个 worker。
*
* 判题机是和正式提交共用的,所以这里自己排一道队:
* - 全站同时最多 `TRIAL_CONCURRENCY` 批在判题机上跑,其余按先来后到等;
* - 一个人同时最多两批(「运行例子」和「自己输入」可以同时点),再多直接拒;
* - 等的人太多、或者等得太久,也直接拒,让学生过一会儿再点,别无限挂着。
*
* 队列在进程内:api 只有一个进程,机房和服务器各有各的 api 和判题机,
* 各排各的正好。
*/
const MAX_WAITING = 60
const WAIT_TIMEOUT_MS = 30_000
const PER_USER_LIMIT = 2
/** 判题机本身有编译、运行的限时,这里只防它卡死不回 */
const JUDGE_TIMEOUT_MS = 60_000
/**
* 判题机回包的总上限。判题机给每组输出留 16MB(见镜像里 judge_client.py 的
* max_output_size),死循环打印能把每组都写满;api 容器只有 512MB,十组全满
* 读进来再 JSON.parse 一遍就是好几百 MB。超了就掐断,告诉学生输出太多了。
*/
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
/** 回给学生的每组输出最多这么长。管理员生成测试点要原样的输出,不截 */
const STUDENT_OUTPUT_LIMIT = 64 * 1024
export class TrialBusyError extends Error {}
export class TrialUserLimitError extends Error {}
let active = 0
const waiting: (() => void)[] = []
const perUser = new Map<number, number>()
function release() {
const next = waiting.shift()
if (next) next()
else active--
}
async function acquire() {
if (active < config.trialConcurrency) {
active++
return
}
if (waiting.length >= MAX_WAITING) throw new TrialBusyError()
await new Promise<void>((resolve, reject) => {
const wake = () => {
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
const index = waiting.indexOf(wake)
if (index >= 0) waiting.splice(index, 1)
reject(new TrialBusyError())
}, WAIT_TIMEOUT_MS)
waiting.push(wake)
})
// 被 release() 叫醒时名额是直接转交过来的,active 不用再加
}
/** 边读边数,超过上限就掐断连接,返回 null */
async function readCapped(response: Response, limit: number) {
if (!response.body) return ""
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > limit) {
await reader.cancel()
return null
}
chunks.push(value)
}
return Buffer.concat(chunks).toString("utf8")
}
function truncate(text: string) {
return text.length > STUDENT_OUTPUT_LIMIT
? `${text.slice(0, STUDENT_OUTPUT_LIMIT)}\n……(输出太长,后面的没有显示)`
: text
}
export async function runTrial(options: {
userId: number
language: string
/** 送去判题机的完整代码(套过题目模板) */
source: string
/** 学生自己写的那段,和模板拼在前面的行数:运行时错误的行号要扣掉模板才对得上编辑器 */
code: string
prependLines: number
timeLimit: number
memoryLimit: number
cases: { input: string; output?: string }[]
/** 管理员生成测试点要完整输出 */
fullOutput: boolean
}): Promise<TrialRunResponse> {
const inFlight = perUser.get(options.userId) ?? 0
if (inFlight >= PER_USER_LIMIT) throw new TrialUserLimitError()
perUser.set(options.userId, inFlight + 1)
try {
await acquire()
try {
return await judgeTrial(options)
} finally {
release()
}
} finally {
const left = (perUser.get(options.userId) ?? 1) - 1
if (left > 0) perUser.set(options.userId, left)
else perUser.delete(options.userId)
}
}
async function judgeTrial(options: Parameters<typeof runTrial>[0]): Promise<TrialRunResponse> {
const response = await postJudge(
options.language,
options.source,
options.timeLimit,
options.memoryLimit,
// 输入补结尾换行,和 checkSamples 一样:题面上的例子常常没有,测试点文件都有
options.cases.map((item) => ({
input: item.input.endsWith("\n") || item.input === "" ? item.input : `${item.input}\n`,
output: item.output ?? "",
})),
{ output: true, signal: AbortSignal.timeout(JUDGE_TIMEOUT_MS) },
)
const body = await readCapped(response, MAX_RESPONSE_BYTES)
if (body === null) return { status: "too-much-output" }
const parsed = JSON.parse(body) as JudgeResponse
if (parsed.err) {
if (parsed.err === "CompileError") {
return {
status: "compile-error",
message:
typeof parsed.data === "string"
? shiftCompileLines(parsed.data, options.prependLines)
: "",
}
}
throw new Error(`JudgeServer error ${parsed.err}: ${JSON.stringify(parsed.data)}`)
}
if (!Array.isArray(parsed.data)) throw new Error("JudgeServer returned an invalid result payload")
const byIndex = new Map(
(parsed.data as JudgeCase[]).map((item) => [Number(item.test_case), item]),
)
return {
status: "done",
cases: options.cases.map((item, index) => {
// 判题机给内联测试点编号是从 1 起的(server.py 里 `index += 1`)
const judged = byIndex.get(index + 1)
if (!judged) return { result: JudgeStatus.SYSTEM_ERROR, output: "" }
let result: JudgeStatusValue = statusValue(judged.result)
// 没给正确输出的组,判题机拿空串比,跑通了也是答案错误 —— 这里只看有没有出错
if (item.output === undefined && result === JudgeStatus.WRONG_ANSWER) {
result = JudgeStatus.ACCEPTED
}
const output = typeof judged.output === "string" ? judged.output : ""
// 运行时错误和提交用同一个解析(judge/runtime-diagnosis.ts):第几行、什么异常。
// 提交那边要重跑一次才拿得到回溯,这里 output 本来就开着,直接解析
const runtimeError =
result === JudgeStatus.RUNTIME_ERROR
? options.language === "Python"
? parsePythonTraceback(output, options.code, options.prependLines)
: nativeRuntimeError(judged.signal, judged.exit_code)
: null
return {
result,
output: options.fullOutput ? output : truncate(output),
...(runtimeError ? { runtimeError } : {}),
}
}),
}
}