feat(题目): 「运行例子」「自己输入」改用和提交同一台判题机——例子上跑出什么样,提交在这几个例子上就是什么样
Deploy / deploy (push) Canceled after 0s

原来试运行走另一台 Judge0:C 是 GCC 9、编译参数也不一样,Python 的语法错误回来的是运行错误,
没有题目的时间内存限制和代码模板,例子上的结果常和提交对不上。现在走本站判题机
(POST /trial-runs),同一套编译命令、这道题的限制和模板,对不对也由判题机比。

- 不落库、不算提交、不进判题队列:api 进程里自己排队,全站同时 TRIAL_CONCURRENCY 批
  (服务器 2、机房 4),每人最多两批,排满或等超过 30 秒就请学生过一会儿再点
- 死循环不停打印时判题机回包会很大,超过 16MB 就掐断,告诉学生输出太多、停不下来
- Python 语法错误用和提交一样的说明:第几行、中文标点标红、一键换成英文
- 运行时错误和提交用同一个解析,说清楚第几行、为什么(下标越界、int() 转换失败、段错误……);
  比赛里照旧只给按结果码的那一句
- 语法没过、服务没跑成时,标题不再说「N 个例子没对上」
- 后台「生成测试点」一并迁过来,编译警告不会再写进 .out;Judge0 的地址和调用全部删掉

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 08:09:44 +08:00
1 parent 8834536135
commit eb32676de4
27 files changed
+691 -169

No files matched your search

+22 -7
View File
@@ -25,7 +25,7 @@ import { readInfo } from "../services/test-case"
import { readFile } from "node:fs/promises"
import { resolve as resolvePath } from "node:path"
interface JudgeCase {
export interface JudgeCase {
cpu_time: number
memory: number
result: number
@@ -35,17 +35,17 @@ interface JudgeCase {
[key: string]: unknown
}
interface JudgeResponse {
export interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
function statusValue(value: number): JudgeStatusValue {
export function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus))
return statuses.has(value) ? (value as JudgeStatusValue) : JudgeStatus.SYSTEM_ERROR
}
function templateForLanguage(value: unknown, language: string) {
export function templateForLanguage(value: unknown, language: string) {
const template = asRecord(value)[language]
return typeof template === "string" ? template : null
}
@@ -67,16 +67,18 @@ function astRulesForLanguage(value: unknown, language: string): AstRule[] {
/**
* `testCase` 是测试点目录名(正常判题),或者内联的测试点(诊断重跑,见
* diagnoseRuntimeError)。`output` 只在诊断时打开:它让判题机把每个测试点的
* diagnoseRuntimeError)。`output` 只在诊断和试运行时打开:它让判题机把每个测试点的
* 程序输出原样带回来,正常判题开着的话,死循环打印能把 worker 内存撑爆。
*
* 只发请求、不读响应体:试运行(judge/trial.ts)要边读边数字节,超了就掐断。
*/
async function requestJudge(
export async function postJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCase: string | { input: string; output: string }[],
output = false,
{ output = false, signal }: { output?: boolean; signal?: AbortSignal } = {},
) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
@@ -84,6 +86,7 @@ async function requestJudge(
const token = createHash("sha256").update(config.judgeServerToken).digest("hex")
const response = await fetch(new URL("/judge", config.judgeServerUrl), {
method: "POST",
signal,
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
@@ -106,6 +109,18 @@ async function requestJudge(
if (!response.ok) {
throw new Error(`JudgeServer returned HTTP ${response.status}`)
}
return response
}
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCase: string | { input: string; output: string }[],
output = false,
) {
const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, { output })
return (await response.json()) as JudgeResponse
}
+188
View File
@@ -0,0 +1,188 @@
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"
/**
* 试运行:「运行例子」「自己输入」、后台「生成测试点」。
*
* 原来走的是另一台 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" ? parsed.data : "",
}
}
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 } : {}),
}
}),
}
}