refactor(判题机): 换成 go-judge,空闲内存从几百 MB 降到 16 MB

原来的 JudgeServer 按核数起 gunicorn worker、每份提交再按核数开进程池,空闲内存跟着核数走
(2 核 52MB、16 核 305MB)。换成 go-judge 后空闲 16MB,同一批提交判题中 80–100MB(原来约 370MB)。

go-judge 只管在沙箱里跑命令,编译、逐点运行、比对输出、定状态码搬到 judge/sandbox.ts,
口径逐条照搬 JudgeServer,出参形状不变。拿生产库真实提交新旧逐点对比:C 2000 份 1989 一致、
C++ 891 份 890 一致、Python 2000 份全一致;不一致的是 system("pause")(现在不再判运行时错误)、
未定义行为、endl 死循环打印(运行时错误变运行超时),见 docker/judge/README.md。

- 判题容器改成 privileged(go-judge 要自己建 cgroup),镜像 oj2-judge-3
- 心跳改由 worker 每 5 秒问判题机 /version 代记,删掉 /api/judge-server/heartbeat
- 前端统一用 stripJudgePath 去掉判题机工作目录(新的 /w/ 和旧的 /judger/run/)
- 提交前编译检查只编译不运行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 15:03:57 +08:00
1 parent 8427475337
commit 37e971b6b4
30 files changed
+881 -592

No files matched your search

+4
View File
@@ -1,5 +1,6 @@
import { randomBytes } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { hostname } from 'node:os'
import { isAbsolute, resolve } from 'node:path'
import { z } from 'zod'
@@ -61,6 +62,8 @@ const envSchema = z.object({
REDIS_URL: requiredWhenCompiled('redis://localhost:6380'),
SESSION_TTL_SECONDS: positiveInt(7 * 24 * 60 * 60),
JUDGE_SERVER_URL: text('http://localhost:8081'),
// 后台「判题机列表」里这台叫什么,见 judge/heartbeat.ts。不设就用本机的主机名
JUDGE_SERVER_NAME: text(hostname()),
// dev 不设时生成一次性随机值,见 judgeServerToken()
JUDGE_SERVER_TOKEN: isCompiled ? z.preprocess(blankAsUnset, z.string({ error: '编译形态下必须设置' })) : z.preprocess(blankAsUnset, z.string().optional()),
JUDGE_CONCURRENCY: positiveInt(2),
@@ -130,6 +133,7 @@ export const config = {
sessionTtlSeconds: env.SESSION_TTL_SECONDS,
judgeServerUrl: env.JUDGE_SERVER_URL,
judgeServerToken: judgeServerToken(),
judgeServerName: env.JUDGE_SERVER_NAME,
judgeConcurrency: env.JUDGE_CONCURRENCY,
/** 试运行(运行例子 / 自己输入)同时在判题机上跑几批,见 judge/trial.ts */
trialConcurrency: env.TRIAL_CONCURRENCY,
-2
View File
@@ -8,7 +8,6 @@ import { config } from './config'
import { adminRoutes } from './routes/admin'
import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account'
import { judgeServerRoutes } from './routes/judge-server'
import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content'
import { classroomRoutes } from './routes/classroom'
@@ -43,7 +42,6 @@ app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes)
app.route('/api', judgeServerRoutes)
app.route('/api/admin', adminRoutes)
app.onError((error, c) => {
+77
View File
@@ -0,0 +1,77 @@
import { cpus, freemem, totalmem } from 'node:os'
import { eq } from 'drizzle-orm'
import { config } from '../config'
import { db, schema } from '../db'
import { sandboxVersion } from './sandbox'
/**
* 后台「判题机列表」和概览页「N 台在线」的数据来源。
*
* 原来的 JudgeServer 自己每 5 秒往 api 报一次心跳;go-judge 不会主动报,改成 worker
* 每 5 秒去问一次判题机的 /version,问得通就替它记一笔。放在 worker 里而不是 api:
* 判题是 worker 在调判题机,worker 挂了判题机再好也判不了,这时候列表里显示离线才对。
*
* 两个站点共用一个库、各有各的判题机,所以每台按 JUDGE_SERVER_NAME 记一行(compose 里
* 分别是「服务器」和「机房」)。CPU、内存是 worker 所在这台机器的(和判题机是同一台),
* 原来 JudgeServer 报的也是整机的数。
*/
const INTERVAL_MS = 5000
/** 两次采样之间整机 CPU 的忙碌比例,第一次没有上一次可比,记 0 */
let previous: { idle: number; total: number } | null = null
function cpuUsage() {
let idle = 0
let total = 0
for (const cpu of cpus()) {
idle += cpu.times.idle
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle
}
const last = previous
previous = { idle, total }
if (!last || total <= last.total) return 0
return Math.round((1 - (idle - last.idle) / (total - last.total)) * 1000) / 10
}
async function beat() {
const version = await sandboxVersion(AbortSignal.timeout(INTERVAL_MS))
const now = new Date().toISOString()
const values = {
judgerVersion: `go-judge ${version}`,
cpuCore: cpus().length,
memoryUsage: Math.round((1 - freemem() / totalmem()) * 1000) / 10,
cpuUsage: cpuUsage(),
lastHeartbeat: now,
serviceUrl: config.judgeServerUrl,
}
const [existing] = await db
.select({ id: schema.judgeServer.id })
.from(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, config.judgeServerName))
.limit(1)
if (existing) {
await db.update(schema.judgeServer).set(values).where(eq(schema.judgeServer.id, existing.id))
} else {
await db.insert(schema.judgeServer).values({ ...values, hostname: config.judgeServerName, createTime: now, taskNumber: 0, isDisabled: false })
}
}
export function startJudgeHeartbeat() {
let failing = false
const tick = () =>
beat().then(
() => {
if (failing) console.log('[judge] 判题机又连上了')
failing = false
},
(error: unknown) => {
// 连不上就不记,后台过几秒自己显示离线。只在刚断的那一下打一行日志,别每 5 秒刷屏
if (!failing) console.warn('[judge] 判题机连不上:', error instanceof Error ? error.message : error)
failing = true
},
)
void tick()
const timer = setInterval(tick, INTERVAL_MS)
return () => clearInterval(timer)
}
+48 -40
View File
@@ -12,8 +12,26 @@
* 0019 迁移把数据并成了一个值。
*
* SQL 题不走这里,走 `judge/sql/`;流程图题走 AI 评分。
*
* 命令里的文件名都是相对沙箱工作目录(/w)的。编译错误因此是 `main.c:3:5: error`,
* 不带目录;Python 的回溯会被解释器补成绝对路径 `/w/solution.py`,前端显示前去掉。
* 限额(CPU 毫秒、墙钟毫秒、字节)的口径照搬 JudgeServer,怎么换算见 sandbox.ts。
*/
const defaultEnv = ['LANG=en_US.UTF-8', 'LANGUAGE=en_US:en', 'LC_ALL=en_US.UTF-8']
export interface LanguageConfig {
compile: {
/** 学生代码写成的文件名 */
srcName: string
/** 编译产物。Python 没有产物,「编译」只是 py_compile 查一遍语法,运行的还是源文件 */
exeName: string
command: string[]
maxCpuTime: number
maxRealTime: number
maxMemory: number
}
run: {
command: string[]
}
}
/**
* gcc-14 起这五类老写法从 warning 提成了 error,而 `-w` 只关警告、压不住 error:
@@ -27,57 +45,47 @@ const defaultEnv = ['LANG=en_US.UTF-8', 'LANGUAGE=en_US:en', 'LC_ALL=en_US.UTF-8
* 哪天决定「就是要学生写规范」,是删掉这几个开关,不是改镜像 —— 删之前先拿
* docs/c-tutorials/verify-code.sh 全量过一遍教程。
*/
const cLooseErrors =
'-Wno-error=implicit-function-declaration -Wno-error=int-conversion -Wno-error=incompatible-pointer-types -Wno-error=return-mismatch -Wno-error=implicit-int'
const cLooseErrors = [
'-Wno-error=implicit-function-declaration',
'-Wno-error=int-conversion',
'-Wno-error=incompatible-pointer-types',
'-Wno-error=return-mismatch',
'-Wno-error=implicit-int',
]
export const languageConfigs: Record<string, Record<string, unknown>> = {
export const languageConfigs: Record<string, LanguageConfig> = {
C: {
template: '',
compile: {
src_name: 'main.c',
exe_name: 'main',
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 256 * 1024 * 1024,
compile_command: `/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 ${cLooseErrors} {src_path} -lm -o {exe_path}`,
},
run: {
command: '{exe_path}',
seccomp_rule: { 'Standard IO': 'c_cpp', 'File IO': 'c_cpp_file_io' },
env: defaultEnv,
srcName: 'main.c',
exeName: 'main',
command: ['/usr/bin/gcc', '-DONLINE_JUDGE', '-O2', '-w', '-fmax-errors=3', '-std=c17', ...cLooseErrors, 'main.c', '-lm', '-o', 'main'],
maxCpuTime: 3000,
maxRealTime: 10000,
maxMemory: 256 * 1024 * 1024,
},
run: { command: ['main'] },
},
'C++': {
template: '',
compile: {
src_name: 'main.cpp',
exe_name: 'main',
max_cpu_time: 10000,
max_real_time: 20000,
max_memory: 1024 * 1024 * 1024,
compile_command: '/usr/bin/g++ -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c++20 {src_path} -lm -o {exe_path}',
},
run: {
command: '{exe_path}',
seccomp_rule: { 'Standard IO': 'c_cpp', 'File IO': 'c_cpp_file_io' },
env: defaultEnv,
srcName: 'main.cpp',
exeName: 'main',
command: ['/usr/bin/g++', '-DONLINE_JUDGE', '-O2', '-w', '-fmax-errors=3', '-std=c++20', 'main.cpp', '-lm', '-o', 'main'],
maxCpuTime: 10000,
maxRealTime: 20000,
maxMemory: 1024 * 1024 * 1024,
},
run: { command: ['main'] },
},
Python: {
template: '',
compile: {
src_name: 'solution.py',
exe_name: 'solution.py',
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 128 * 1024 * 1024,
compile_command: '/usr/bin/python3 -m py_compile {src_path}',
},
run: {
command: '/usr/bin/python3 -BS {exe_path}',
seccomp_rule: 'general',
env: defaultEnv,
srcName: 'solution.py',
exeName: 'solution.py',
command: ['/usr/bin/python3', '-m', 'py_compile', 'solution.py'],
maxCpuTime: 3000,
maxRealTime: 10000,
maxMemory: 128 * 1024 * 1024,
},
run: { command: ['/usr/bin/python3', '-BS', 'solution.py'] },
},
}
+8 -109
View File
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto'
import { astRuleSchema, type ContestSubmissionInfo, type StatisticInfo } from '@oj2/contract'
import { and, eq, inArray } from 'drizzle-orm'
@@ -13,30 +11,17 @@ import { checkAst, type AstRule } from './ast'
import { publishSubmissionUpdate } from './events'
import { asRecord } from '../routes/helpers'
import type { JudgeJobData } from './job'
import { judgeConfigFor } from './languages'
import { isAccepted, JudgeStatus, type JudgeStatusValue } from './status'
import { parseProblemTemplate, shiftCompileLines } from './template'
import { withJudgeSlot } from './slots'
import { runSqlCase } from './sql'
import { nativeRuntimeError, parsePythonTraceback, type RuntimeErrorInfo } from './runtime-diagnosis'
import { compileError, judge, type JudgeCase, type JudgeResponse } from './sandbox'
import { readInfo } from '../services/test-case'
import { readFile } from 'node:fs/promises'
import { resolve as resolvePath } from 'node:path'
export interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
/** SQL 判题会带上中文原因,沙箱判题没有这个字段 */
error_message?: string | null
[key: string]: unknown
}
export interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
export type { JudgeCase, JudgeResponse }
export function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus))
@@ -63,60 +48,12 @@ function astRulesForLanguage(value: unknown, language: string): AstRule[] {
})
}
/**
* `testCase` 是测试点目录名(正常判题),或者内联的测试点(诊断重跑,见
* diagnoseRuntimeError)。`output` 只在诊断和试运行时打开:它让判题机把每个测试点的
* 程序输出原样带回来,正常判题开着的话,死循环打印能把 worker 内存撑爆。
*
* 只发请求、不读响应体:试运行(judge/trial.ts)要边读边数字节,超了就掐断。
*/
export async function postJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCase: string | { input: string; output: string }[],
{ output = false, signal }: { output?: boolean; signal?: AbortSignal } = {},
) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
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,
},
body: JSON.stringify({
language_config: languageConfig,
src: code,
max_cpu_time: timeLimit,
max_memory: 1024 * 1024 * memoryLimit,
...(typeof testCase === 'string' ? { test_case_id: testCase } : { test_case: testCase }),
output,
io_mode: {
io_mode: 'Standard IO',
input: 'input.txt',
output: 'output.txt',
},
}),
})
if (!response.ok) {
throw new Error(`JudgeServer returned HTTP ${response.status}`)
}
return response
}
/**
* 这份代码在判题机上编不编得过。给提交前的自动格式化用:clang-format 碰上编译不过的
* C 代码会把行并起来(`int n;` 和下一行、少了分号的两行),学生回头看到的就不是自己
* 写的代码了,报错的行号也对不上他记得的位置。api 容器里没有 gcc,而判题机的编译器、
* 编译参数本来就是判的标准,所以借它编一次。
*
* 判题机只有 /judge 一个入口,得带一组测试点:给一组空输入,跑的那一下限 1 秒。
* 判题机连不上、超时,一律当编得过 —— 那就和原来一样照常格式化。
*
* 这一下不进判题队列,但和运行例子共用交互池的名额(judge/slots.ts):服务器只有两核,
@@ -126,44 +63,12 @@ export async function postJudge(
export async function compileFails(language: string, source: string) {
const deadline = AbortSignal.timeout(8000)
try {
return await withJudgeSlot('interactive', { holdMs: 30_000, signal: deadline }, async () => {
const response = await postJudge(language, source, 1000, 256, [{ input: '', output: '' }], {
signal: deadline,
})
const body = (await response.json()) as JudgeResponse
return body.err === 'CompileError'
})
return await withJudgeSlot('interactive', { holdMs: 30_000, signal: deadline }, async () => (await compileError(language, source, deadline)) !== null)
} catch {
return false
}
}
/**
* 判题机回包的总上限。判题机给每组输出留 16MB(见镜像里 judge_client.py 的
* max_output_size),死循环打印能把每组都写满;api、worker 容器都只有 512MB,
* 几组全满读进来再 JSON.parse 一遍就是好几百 MB。超了就掐断。
*/
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
/** 边读边数,超过上限就掐断连接,返回 null */
export 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')
}
/**
* 判题机自己有编译、运行的限时,这里只防它卡死不回。不设的话一次卡死就永久占住一个
* worker(服务器上一共两个),而 BullMQ 会一直给它续锁、不当它卡住,全班停在「等待评分」。
@@ -185,15 +90,9 @@ async function requestJudge(
const timeoutMs = judgeTimeoutMs(timeLimit)
try {
// 超时从拿到名额才开始算:排队等名额不算判题机没回应
return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, async () => {
const response = await postJudge(language, code, timeLimit, memoryLimit, testCase, {
output,
signal: AbortSignal.timeout(timeoutMs),
})
const body = await readCapped(response, MAX_RESPONSE_BYTES)
if (body === null) throw new Error('判题机返回的内容太大')
return JSON.parse(body) as JudgeResponse
})
return await withJudgeSlot('judge', { holdMs: timeoutMs + 30_000 }, () =>
judge(language, code, timeLimit, memoryLimit, testCase, { output, signal: AbortSignal.timeout(timeoutMs) }),
)
} catch (error) {
// 这句会写进 err_info,学生能看到,别把 "The operation timed out." 原样给他
if (error instanceof DOMException && error.name === 'TimeoutError') {
@@ -409,7 +308,7 @@ export async function failAbandonedSubmission(submissionId: string, error: unkno
* 运行时错误的诊断,结果写进 `statistic_info.runtime_error`(字段说明见契约)。
*
* C / C++ 直接用失败测试点的信号和退出码。Python 要拿回溯,得**重跑那一个测试点**:
* 主判题不开 output(理由见 requestJudge),所以这里读出第一个失败测试点的输入,
* 主判题不带回输出(每个点最多 16MB,见 sandbox.ts),所以这里读出第一个失败测试点的输入,
* 以内联测试点的形式单独再跑一次、打开 output。同样的代码、同样的输入,抛的是
* 同一个异常。只多跑一个点,而且只在 Python 判出运行时错误时才跑。
*
@@ -566,7 +465,7 @@ export async function judgeSubmission(job: JudgeJobData) {
}
} else {
if (!Array.isArray(response.data)) {
throw new Error('JudgeServer returned an invalid result payload')
throw new Error('判题机回的结果看不懂')
}
const cases = [...response.data].sort((left, right) => Number(left.test_case) - Number(right.test_case))
info = { err: null, data: cases }
+360
View File
@@ -0,0 +1,360 @@
import { createHash } from 'node:crypto'
import { config } from '../config'
import { readInfo, rstrip } from '../services/test-case'
import { judgeConfigFor, type LanguageConfig } from './languages'
import { JudgeStatus, type JudgeStatusValue } from './status'
/**
* 判题沙箱(go-judge,见 docker/judge/)的客户端:编译、逐个测试点运行、比对输出、定状态码。
*
* go-judge 只管「在沙箱里跑一条命令、量出用了多少」,判题流程是这里拼起来的。2026-10 之前
* 判题机是 QingdaoU/JudgeServer,流程在它的 judge_client.py 里;这里是**逐条照搬**它的
* 口径,出参也保持它的形状(`{ err, data: JudgeCase[] }`、每个点的 cpu_time / memory /
* signal / exit_code …)—— 12 万条历史提交的 `info` 就是这个形状,前端和统计都按它读。
* 换过来时拿生产库的真实提交逐份对比过新旧两边的判定,结论见 docker/judge/README.md。
*
* 照搬的口径:
* - **输出比对**:整段输出 rstrip(只剥 ASCII 空白,和 Python 的 bytes.rstrip 一样)后取
* md5,和测试点 info 里的 `stripped_output_md5` 比。
* - **stdout 和 stderr 合在一起比**:JudgeServer 把两者重定向到同一个文件。Python 的
* SyntaxWarning、C 往 stderr 打的东西都算进输出,所以这里两个 fd 接同一个收集器。
* - **内存**:地址空间限到题目内存的两倍(RLIMIT_AS),跑完峰值超过题目内存判超限。
* malloc 先成功一阵、到两倍才失败,所以内存爆了多半判「内存超限」而不是运行时错误。
* - **输出上限 16MB**(镜像的 -output-limit):死循环打印写满吃 SIGXFSZ,判运行时错误、
* 信号 25,前端对它有专门的中文说明。
* - **墙钟是 CPU 时限的 3 倍**;状态码的先后顺序照 libjudger 的 runner.c,见 verdict()。
*
* 和 JudgeServer 不一样的地方(都是沙箱换了带来的,判定上只会更宽松):
* - 学生程序不能再开进程或线程(procLimit 1)。原来是 seccomp 直接杀,信号 31;现在
* fork 失败、system("pause") 返回 -1,程序照样往下跑。
* - 内存读数是 cgroup 的峰值,比原来的 ru_maxrss 小(C 的空程序 0.25MB,原来 1.5MB)。
* - CPU 时间含内核态,原来只算用户态。
*/
/** 判题机容器里测试点的挂载点(compose 里 `…/test_case:/test_case:ro`),镜像的 -src-prefix 也是它 */
const SANDBOX_TEST_CASE_DIR = '/test_case'
/** 镜像的 -output-limit。改了这里要一起改 docker/judge/Dockerfile */
const OUTPUT_LIMIT = 16 * 1024 * 1024
/** JudgeServer 给编译器输出留的上限 */
const COMPILE_OUTPUT_LIMIT = 20 * 1024 * 1024
/** JudgeServer 的 max_stack */
const STACK_LIMIT = 128 * 1024 * 1024
/**
* 一份提交同时跑几个测试点。JudgeServer 是按核数开进程池(服务器两核 = 两个),这里取一样的数。
* 整台判题机同时跑几个由 go-judge 自己的 -parallelism 管(默认等于核数),多出来的在它那里排队,
* 排队不算墙钟。
*/
const CASE_PARALLELISM = 2
/**
* 打开 `output` 时所有测试点的输出加起来的上限。原来是判题机整个回包的上限(每组输出
* 最多 16MB,死循环打印能把每组都写满),api、worker 容器都只有 512MB。
*/
export const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
const PATH = 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
/** 编译只给 PATH,和 JudgeServer 一样(它的 compile_config 没有 env) */
const COMPILE_ENV = [PATH]
const RUN_ENV = [PATH, 'LANG=en_US.UTF-8', 'LANGUAGE=en_US:en', 'LC_ALL=en_US.UTF-8']
export interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
/** SQL 判题会带上中文原因,沙箱判题没有这个字段 */
error_message?: string | null
[key: string]: unknown
}
export interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
/** 打开 output 时输出超过 MAX_OUTPUT_BYTES。试运行回「输出太多」,诊断重跑当作没诊断出来 */
export class TooMuchOutputError extends Error {
constructor() {
super('判题机返回的内容太大')
}
}
/** go-judge 的 /run 回的单条结果。时间是纳秒,内存是字节 */
interface SandboxResult {
status: string
exitStatus: number
error?: string
time: number
memory: number
runTime: number
files?: Record<string, string>
fileIds?: Record<string, string>
}
interface SandboxCase {
key: string
stdin: { src: string } | { content: string }
expectedMd5: string
}
function headers() {
return { authorization: `Bearer ${config.judgeServerToken}` }
}
async function runCommand(command: Record<string, unknown>, signal?: AbortSignal) {
const response = await fetch(new URL('/run', config.judgeServerUrl), {
method: 'POST',
signal,
headers: { ...headers(), 'content-type': 'application/json' },
body: JSON.stringify({ cmd: [command] }),
})
if (!response.ok) throw new Error(`判题机返回 HTTP ${response.status}:${(await response.text()).slice(0, 200)}`)
const body = (await response.json()) as unknown
if (!Array.isArray(body) || !body[0]) throw new Error(`判题机回的结果看不懂:${JSON.stringify(body).slice(0, 200)}`)
return body[0] as SandboxResult
}
async function readFile(fileId: string, signal?: AbortSignal) {
const response = await fetch(new URL(`/file/${fileId}`, config.judgeServerUrl), { signal, headers: headers() })
if (!response.ok) throw new Error(`判题机取文件返回 HTTP ${response.status}`)
return new Uint8Array(await response.arrayBuffer())
}
/** 用完的文件删掉。删不掉也不要紧,判题机 10 分钟后自己清(-file-timeout) */
function deleteFile(fileId: string) {
void fetch(new URL(`/file/${fileId}`, config.judgeServerUrl), { method: 'DELETE', headers: headers() }).catch(() => {})
}
const md5 = (buffer: Uint8Array) => createHash('md5').update(buffer).digest('hex')
/**
* 状态码,照 libjudger runner.c 的判定顺序:退出码非 0 → 运行时错误;段错误时内存超了算
* 内存超限、否则运行时错误;其余信号 → 运行时错误,然后依次用内存、墙钟、CPU 覆盖。
* 后面的覆盖前面的,所以超时优先于内存超限优先于运行时错误。
*/
function verdict(sandbox: SandboxResult, cpuTime: number, realTime: number, signal: number, limits: { cpu: number; real: number; memory: number }) {
if (sandbox.status === 'Internal Error' || sandbox.status === 'File Error') return JudgeStatus.SYSTEM_ERROR
const exitCode = signal ? 0 : sandbox.exitStatus
let result: JudgeStatusValue = JudgeStatus.ACCEPTED
if (exitCode !== 0) result = JudgeStatus.RUNTIME_ERROR
if (signal === 11) {
result = sandbox.memory > limits.memory ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.RUNTIME_ERROR
} else {
if (signal !== 0) result = JudgeStatus.RUNTIME_ERROR
if (sandbox.memory > limits.memory || sandbox.status === 'Memory Limit Exceeded') result = JudgeStatus.MEMORY_LIMIT_EXCEEDED
if (realTime > limits.real) result = JudgeStatus.REAL_TIME_LIMIT_EXCEEDED
if (cpuTime > limits.cpu) result = JudgeStatus.CPU_TIME_LIMIT_EXCEEDED
}
// 被 go-judge 按时限杀掉的,读数可能正好卡在限上、没有「超过」
if (sandbox.status === 'Time Limit Exceeded' && result !== JudgeStatus.CPU_TIME_LIMIT_EXCEEDED && result !== JudgeStatus.REAL_TIME_LIMIT_EXCEEDED) {
result = cpuTime >= limits.cpu ? JudgeStatus.CPU_TIME_LIMIT_EXCEEDED : JudgeStatus.REAL_TIME_LIMIT_EXCEEDED
}
return result
}
/**
* 收出来的信号:go-judge 按时限杀是 SIGKILL,写满输出上限单独报一个状态,其实就是 SIGXFSZ。
* 被 seccomp 拦下(Dangerous Syscall)按 SIGSYS 记,和 JudgeServer 一样判运行时错误 ——
* go-judge 默认的 seccomp 规则是让系统调用返回错误而不是杀进程,正常碰不到,防的是它改默认
*/
function signalOf(sandbox: SandboxResult) {
if (sandbox.status === 'Signalled' || sandbox.status === 'Time Limit Exceeded') return sandbox.exitStatus
if (sandbox.status === 'Output Limit Exceeded') return 25
if (sandbox.status === 'Dangerous Syscall') return 31
return 0
}
async function compile(language: LanguageConfig, source: string, signal?: AbortSignal) {
const { compile } = language
const result = await runCommand(
{
args: compile.command,
env: COMPILE_ENV,
files: [{ content: '' }, { name: 'stdout', max: COMPILE_OUTPUT_LIMIT }, { name: 'stdout', max: COMPILE_OUTPUT_LIMIT }],
cpuLimit: compile.maxCpuTime * 1e6,
clockLimit: compile.maxRealTime * 1e6,
memoryLimit: compile.maxMemory * 2,
addressSpaceLimit: true,
stackLimit: STACK_LIMIT,
procLimit: 64,
copyIn: { [compile.srcName]: { content: source } },
copyOut: ['stdout'],
copyOutCached: [compile.exeName],
},
signal,
)
const exeId = result.fileIds?.[compile.exeName]
const cpuTime = Math.floor(result.time / 1e6)
const realTime = Math.floor(result.runTime / 1e6)
const failed =
verdict(result, cpuTime, realTime, signalOf(result), { cpu: compile.maxCpuTime, real: compile.maxRealTime, memory: compile.maxMemory }) !==
JudgeStatus.ACCEPTED
if (!failed && exeId) return { exeId }
if (exeId) deleteFile(exeId)
// 编译器自己的报错原样给;没有报错(编译超时、被杀)就是 JudgeServer 那句英文
const output = (result.files?.stdout ?? '').trim()
const { files: _files, fileIds: _fileIds, ...info } = result
return { error: output || `Compiler runtime error, info: ${JSON.stringify(info)}` }
}
async function runCase(
language: LanguageConfig,
exeId: string,
item: SandboxCase,
limits: { cpu: number; memory: number },
output: boolean,
signal?: AbortSignal,
): Promise<{ judged: JudgeCase; outputBytes: number }> {
const real = limits.cpu * 3
const result = await runCommand(
{
args: language.run.command,
env: RUN_ENV,
files: [item.stdin, { name: 'stdout', max: OUTPUT_LIMIT }, { name: 'stdout', max: OUTPUT_LIMIT }],
cpuLimit: limits.cpu * 1e6,
clockLimit: real * 1e6,
memoryLimit: limits.memory * 2,
addressSpaceLimit: true,
stackLimit: STACK_LIMIT,
procLimit: 1,
copyIn: { [language.compile.exeName]: { fileId: exeId } },
copyOutCached: ['stdout'],
},
signal,
)
const cpuTime = Math.floor(result.time / 1e6)
const realTime = Math.floor(result.runTime / 1e6)
const signalNumber = signalOf(result)
let status: JudgeStatusValue = verdict(result, cpuTime, realTime, signalNumber, { cpu: limits.cpu, real, memory: limits.memory })
const stdoutId = result.fileIds?.stdout
let outputMd5: string | null = null
let text: string | null = null
let outputBytes = 0
if (stdoutId) {
try {
// 只有正常跑完的才比对;输出只在要的时候读回来
if (status === JudgeStatus.ACCEPTED || output) {
const bytes = await readFile(stdoutId, signal)
outputBytes = bytes.length
if (status === JudgeStatus.ACCEPTED) {
outputMd5 = md5(rstrip(bytes))
if (outputMd5 !== item.expectedMd5) status = JudgeStatus.WRONG_ANSWER
}
if (output) text = new TextDecoder().decode(bytes)
}
} finally {
deleteFile(stdoutId)
}
} else if (status === JudgeStatus.ACCEPTED) {
status = JudgeStatus.SYSTEM_ERROR
}
return {
outputBytes,
judged: {
cpu_time: cpuTime,
real_time: realTime,
memory: result.memory,
signal: signalNumber,
exit_code: signalNumber ? 0 : result.exitStatus,
error: 0,
result: status,
test_case: item.key,
output_md5: outputMd5,
output: text,
},
}
}
/**
* `testCase` 是测试点目录名(正常判题),或者内联的测试点(试运行、诊断重跑、样例重跑,
* 编号从 1 起)。`output` 让每个点带回程序的原样输出;只有试运行和诊断要,正常判题不开。
* `memoryLimit` 是 MB,`timeLimit` 是毫秒,都是题目上的值。
*
* 学生代码的问题(编不过)走 `err: 'CompileError'`;题目或判题机的问题(读不到测试点)走
* 别的 `err`;判题机连不上、超时直接抛错。和 JudgeServer 的三种出口一一对应。
*/
export async function judge(
language: string,
source: string,
timeLimit: number,
memoryLimit: number,
testCase: string | { input: string; output: string }[],
{ output = false, signal }: { output?: boolean; signal?: AbortSignal } = {},
): Promise<JudgeResponse> {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
let cases: SandboxCase[]
if (typeof testCase === 'string') {
const info = await readInfo(testCase)
if (!info?.test_cases) return { err: 'JudgeClientError', data: 'Test case not found' }
cases = Object.entries(info.test_cases).map(([key, item]) => ({
key,
stdin: { src: `${SANDBOX_TEST_CASE_DIR}/${testCase}/${item.input_name}` },
expectedMd5: item.stripped_output_md5,
}))
} else {
cases = testCase.map((item, index) => ({
key: String(index + 1),
stdin: { content: item.input },
expectedMd5: md5(rstrip(new TextEncoder().encode(item.output))),
}))
}
const compiled = await compile(languageConfig, source, signal)
if ('error' in compiled) return { err: 'CompileError', data: compiled.error }
try {
const limits = { cpu: timeLimit, memory: memoryLimit * 1024 * 1024 }
const results: JudgeCase[] = []
let outputBytes = 0
let next = 0
let stopped = false
const worker = async () => {
while (!stopped && next < cases.length) {
const item = cases[next++]!
const ran = await runCase(languageConfig, compiled.exeId, item, limits, output, signal)
outputBytes += ran.outputBytes
// 一组写满 16MB 就到上限了:那一组是被截断的,再多读也只是把 worker 撑大
if (output && outputBytes >= MAX_OUTPUT_BYTES) {
stopped = true
throw new TooMuchOutputError()
}
results.push(ran.judged)
}
}
try {
await Promise.all(Array.from({ length: Math.min(CASE_PARALLELISM, cases.length) }, worker))
} finally {
stopped = true
}
return { err: null, data: results.sort((left, right) => Number(left.test_case) - Number(right.test_case)) }
} finally {
deleteFile(compiled.exeId)
}
}
/** 只编译不运行,编不过时给出编译器的报错。判题机不认的语言当编得过 */
export async function compileError(language: string, source: string, signal?: AbortSignal) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig) return null
const compiled = await compile(languageConfig, source, signal)
if ('error' in compiled) return compiled.error
deleteFile(compiled.exeId)
return null
}
/** 判题机在不在、是哪个版本。worker 每 5 秒问一次,写进后台的「判题机列表」(judge/heartbeat.ts) */
export async function sandboxVersion(signal?: AbortSignal) {
const response = await fetch(new URL('/version', config.judgeServerUrl), { signal, headers: headers() })
if (!response.ok) throw new Error(`判题机返回 HTTP ${response.status}`)
const body = (await response.json()) as { buildVersion?: unknown }
return typeof body.buildVersion === 'string' ? body.buildVersion : 'unknown'
}
+23 -19
View File
@@ -1,8 +1,9 @@
import type { TrialRunResponse } from '@oj2/contract'
import { config } from '../config'
import { MAX_RESPONSE_BYTES, postJudge, readCapped, statusValue, type JudgeCase, type JudgeResponse } from './run'
import { statusValue } from './run'
import { nativeRuntimeError, parsePythonTraceback } from './runtime-diagnosis'
import { judge, TooMuchOutputError, type JudgeCase, type JudgeResponse } from './sandbox'
import { withJudgeSlot, type JudgePool } from './slots'
import { JudgeStatus, type JudgeStatusValue } from './status'
import { shiftCompileLines } from './template'
@@ -120,22 +121,25 @@ async function judgeTrial(options: Parameters<typeof runTrial>[0]): Promise<Tria
}
async function judgeTrialNow(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' }
let parsed: JudgeResponse
try {
parsed = await judge(
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) },
)
} catch (error) {
if (error instanceof TooMuchOutputError) return { status: 'too-much-output' }
throw error
}
const parsed = JSON.parse(body) as JudgeResponse
if (parsed.err) {
if (parsed.err === 'CompileError') {
return {
@@ -143,15 +147,15 @@ async function judgeTrialNow(options: Parameters<typeof runTrial>[0]): Promise<T
message: typeof parsed.data === 'string' ? shiftCompileLines(parsed.data, options.prependLines) : '',
}
}
throw new Error(`JudgeServer error ${parsed.err}: ${JSON.stringify(parsed.data)}`)
throw new Error(`判题机报错 ${parsed.err}: ${JSON.stringify(parsed.data)}`)
}
if (!Array.isArray(parsed.data)) throw new Error('JudgeServer returned an invalid result payload')
if (!Array.isArray(parsed.data)) throw new Error('判题机回的结果看不懂')
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`)
// 内联测试点的编号从 1 起(judge/sandbox.ts)
const judged = byIndex.get(index + 1)
if (!judged) return { result: JudgeStatus.SYSTEM_ERROR, output: '' }
let result: JudgeStatusValue = statusValue(judged.result)
-75
View File
@@ -1,75 +0,0 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { Hono, type Context } from 'hono'
import { z } from 'zod'
import { config } from '../config'
import { db, schema } from '../db'
import { failure, readJson } from '../http'
const heartbeatSchema = z.object({
hostname: z.string().min(1).max(128),
judger_version: z.string().min(1).max(32),
cpu_core: z.number().int().positive(),
memory: z.number().min(0).max(100),
cpu: z.number().min(0).max(100),
action: z.literal('heartbeat'),
service_url: z.string().min(1).max(256),
})
export const judgeServerRoutes = new Hono()
function tokenMatches(value: string | undefined) {
if (!value) return false
const expected = createHash('sha256').update(config.judgeServerToken).digest('hex')
const actualBuffer = Buffer.from(value)
const expectedBuffer = Buffer.from(expected)
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
}
async function heartbeat(c: Context) {
if (!tokenMatches(c.req.header('X-Judge-Server-Token'))) {
return failure(c, 403, 'invalid-judge-token', 'Invalid token')
}
const parsed = await readJson(c, heartbeatSchema)
if (!parsed.success) {
return failure(c, 400, 'invalid-heartbeat', 'Invalid heartbeat payload')
}
const now = new Date().toISOString()
const [existing] = await db
.select({ id: schema.judgeServer.id })
.from(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, parsed.data.hostname))
.limit(1)
const common = {
ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || null,
judgerVersion: parsed.data.judger_version,
cpuCore: parsed.data.cpu_core,
memoryUsage: parsed.data.memory,
cpuUsage: parsed.data.cpu,
lastHeartbeat: now,
serviceUrl: parsed.data.service_url,
}
if (existing) {
await db.update(schema.judgeServer).set(common).where(eq(schema.judgeServer.id, existing.id))
} else {
await db.insert(schema.judgeServer).values({
...common,
hostname: parsed.data.hostname,
createTime: now,
taskNumber: 0,
isDisabled: false,
})
}
// JudgeServer 1.6.1 healthcheck still reads the legacy { error, data } envelope.
return c.json({ error: null, data: null })
}
judgeServerRoutes.post('/judge-server/heartbeat', heartbeat)
judgeServerRoutes.post('/judge-server/heartbeat/', heartbeat)
+2 -2
View File
@@ -33,8 +33,8 @@ export interface TestCaseEntry {
output_name: string
}
/** 等价于 Python 的 bytes.rstrip():只剥尾部 ASCII 空白 */
function rstrip(buffer: Uint8Array) {
/** 等价于 Python 的 bytes.rstrip():只剥尾部 ASCII 空白。判题比对输出也用它(judge/sandbox.ts) */
export function rstrip(buffer: Uint8Array) {
const whitespace = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c])
let end = buffer.length
while (end > 0 && whitespace.has(buffer[end - 1]!)) end -= 1
+4
View File
@@ -1,6 +1,7 @@
import { Worker } from 'bullmq'
import { config } from './config'
import { startJudgeHeartbeat } from './judge/heartbeat'
import { judgeQueueName, type JudgeJobData } from './judge/job'
import { failAbandonedSubmission, judgeSubmission } from './judge/run'
import { flowchartQueueName, type FlowchartJobData } from './flowchart/job'
@@ -45,7 +46,10 @@ flowchartWorker.on('ready', () => console.log('Flowchart worker ready (concurren
flowchartWorker.on('failed', (job, error) => console.error(`Flowchart job ${job?.id ?? 'unknown'} failed`, error))
flowchartWorker.on('error', (error) => console.error('Flowchart worker error', error))
const stopHeartbeat = startJudgeHeartbeat()
async function shutdown() {
stopHeartbeat()
await worker.close()
await flowchartWorker.close()
process.exit(0)