原来试运行走另一台 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:
1 parent
8834536135
commit
eb32676de4
27 files changed
+691
-169
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user