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:
1 parent
8427475337
commit
37e971b6b4
30 files changed
+867
-578
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ JUDGE_SERVER_URL=http://localhost:8081
|
||||
# TOKEN=$(openssl rand -hex 32)
|
||||
# echo "JUDGE_SERVER_TOKEN=$TOKEN" >> .env # 后端读这里
|
||||
# echo "OJ2_JUDGE_TOKEN=$TOKEN" >> docker/.env # 判题机容器读这里,两边必须一致
|
||||
# 留空的话后端会随机生成一个并在启动日志里告警,判题机心跳会被 403 挡掉。
|
||||
# 留空的话后端会随机生成一个并在启动日志里告警,判题请求会被 401 挡掉。
|
||||
JUDGE_SERVER_TOKEN=
|
||||
JUDGE_CONCURRENCY=2
|
||||
# 试运行(运行例子 / 自己输入)同时在判题机上跑几批,和正式提交共用判题机
|
||||
|
||||
@@ -105,14 +105,18 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
|
||||
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
|
||||
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
|
||||
|
||||
### 判题镜像是自己构建的
|
||||
### 判题机是 go-judge,判题流程在后端
|
||||
|
||||
`compose.*.yml` 里的 `oj2-judge-2` **不在任何 registry 上**:上游
|
||||
QingdaoU/JudgeServer 停更在 2024-04(官方镜像的 `latest` 和 `1.6.1` 是同一份,
|
||||
编译器停在 gcc-13),新工具链只能自己编。`docker/judge/` 里是只改版本的 Dockerfile
|
||||
分叉 + 构建脚本 + 冒烟测试,判题逻辑一行没动。
|
||||
`compose.*.yml` 里的 `oj2-judge-3` **不在任何 registry 上**:`docker/judge/` 把
|
||||
[go-judge](https://github.com/criyle/go-judge) 的二进制和 gcc / python 装进一个镜像,本机构建。
|
||||
go-judge 只管「在沙箱里跑一条命令」,**编译、逐个测试点运行、比对输出、定状态码都在
|
||||
`apps/api/src/judge/sandbox.ts`**,口径逐条照搬了 2026-10 之前的 QingdaoU/JudgeServer
|
||||
(输出 rstrip 后比 md5、stdout 和 stderr 合在一起比、地址空间限两倍内存、16MB 输出上限 …),
|
||||
出参也还是它的形状 —— 12 万条历史提交的 `info` 就是这个形状。动那个文件前先读它顶部的注释。
|
||||
|
||||
- 构建、分发、改名的步骤见 `docker/judge/README.md`。**服务器和机房各有各的判题沙箱,两边都要装。**
|
||||
- 判题机容器是 `privileged` 的(go-judge 要自己建 cgroup 和 namespace),别「顺手」改回 `read_only` + `cap_drop`。
|
||||
- 判题机不再主动报心跳,后台「判题机列表」是 worker 每 5 秒去问一次记下的(`judge/heartbeat.ts`)。
|
||||
- 编译/运行命令在 `apps/api/src/judge/languages.ts`,不在镜像里。gcc-14 把隐式函数
|
||||
声明等提成了 error(`-w` 压不住),那边的 `cLooseErrors` 几个 `-Wno-error=` 就是
|
||||
为此加的 —— 删掉它们等于让一批历史题解和 C 教程示例集体 CE。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,7 +121,9 @@ async function judgeTrial(options: Parameters<typeof runTrial>[0]): Promise<Tria
|
||||
}
|
||||
|
||||
async function judgeTrialNow(options: Parameters<typeof runTrial>[0]): Promise<TrialRunResponse> {
|
||||
const response = await postJudge(
|
||||
let parsed: JudgeResponse
|
||||
try {
|
||||
parsed = await judge(
|
||||
options.language,
|
||||
options.source,
|
||||
options.timeLimit,
|
||||
@@ -132,10 +135,11 @@ async function judgeTrialNow(options: Parameters<typeof runTrial>[0]): Promise<T
|
||||
})),
|
||||
{ output: true, signal: AbortSignal.timeout(JUDGE_TIMEOUT_MS) },
|
||||
)
|
||||
const body = await readCapped(response, MAX_RESPONSE_BYTES)
|
||||
if (body === null) return { status: 'too-much-output' }
|
||||
} 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue'
|
||||
import type { StatisticInfo, TrialRunResponse } from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import { trialRun } from 'utils/judge'
|
||||
import { stripJudgePath, trialRun } from 'utils/judge'
|
||||
import { explainCCompileError } from 'oj/problem/utils/cError'
|
||||
import { explainPythonCompileError } from 'utils/pythonError'
|
||||
import { explainRuntimeError } from 'oj/problem/utils/runtimeError'
|
||||
@@ -63,9 +63,8 @@ const lineCount = computed(() => value.value.split('\n').length)
|
||||
|
||||
const TOO_MUCH_OUTPUT = '程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。'
|
||||
|
||||
/** 判题机的临时目录名对学生没有意义,只留文件名 */
|
||||
function cleanRaw(text: string) {
|
||||
return text.replace(/\/judger\/run\/[^/"]+\//g, '').trim()
|
||||
return stripJudgePath(text).trim()
|
||||
}
|
||||
|
||||
function compileOutcome(raw: string): Outcome {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findChinesePunctuation } from 'oj/problem/utils/chinesePunctuation'
|
||||
import { applyFixes, clearErrorMark, currentCode, showErrorMark, showPunctuationMarks } from 'oj/problem/utils/errorMark'
|
||||
import { explainCCompileError } from 'oj/problem/utils/cError'
|
||||
import { explainPythonCompileError } from 'utils/pythonError'
|
||||
import { stripJudgePath } from 'utils/judge'
|
||||
|
||||
/**
|
||||
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
|
||||
@@ -32,8 +33,7 @@ const explanation = computed(() =>
|
||||
: explainCCompileError(props.errInfo, codeStore.code.value, props.language),
|
||||
)
|
||||
|
||||
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
|
||||
const rawError = computed(() => props.errInfo.replace(/\/judger\/run\/[^/"]+\//g, ''))
|
||||
const rawError = computed(() => stripJudgePath(props.errInfo))
|
||||
|
||||
/**
|
||||
* 报错只点名第一处。别的行也有中文标点的话,一句话带过(设计稿「语法没过」:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useThemeVars } from 'naive-ui'
|
||||
import { HINT_MIN_FAILURES, hintLevelLabel, isSqlProblem } from '@oj2/contract'
|
||||
import type { JudgeCaseResult } from '@oj2/contract'
|
||||
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
||||
import { stripJudgePath } from 'utils/judge'
|
||||
import { submissionCaseResults, submissionMemoryFormat, submissionPartialCases, submissionTimeFormat } from 'utils/functions'
|
||||
import type { Submission } from 'utils/types'
|
||||
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
|
||||
@@ -52,11 +53,6 @@ const {
|
||||
sendHintFeedback,
|
||||
} = useSubmissionStore().hint
|
||||
|
||||
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
|
||||
function stripJudgePath(text: string) {
|
||||
return text.replace(/\/judger\/run\/[^/"]+\//g, '')
|
||||
}
|
||||
|
||||
/** 编译错误交给 CompileErrorExplain 翻成中文:Python、C、C++ 各有一张翻译表 */
|
||||
const compileError = computed(() => {
|
||||
const submission = props.submission
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import { stripJudgePath } from 'utils/judge'
|
||||
import type { StatisticInfo } from '@oj2/contract'
|
||||
import CompileErrorExplain from './CompileErrorExplain.vue'
|
||||
import RuntimeErrorExplain from './RuntimeErrorExplain.vue'
|
||||
@@ -53,8 +54,7 @@ const message = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
|
||||
const rawOutput = computed(() => props.output.replace(/\/judger\/run\/[^/"]+\//g, ''))
|
||||
const rawOutput = computed(() => stripJudgePath(props.output))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { explainCCompileError } from 'oj/problem/utils/cError'
|
||||
import { explainPythonCompileError } from 'utils/pythonError'
|
||||
import { stripJudgePath } from 'utils/judge'
|
||||
import { explainRuntimeError } from 'oj/problem/utils/runtimeError'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import type { Submission } from 'utils/types'
|
||||
@@ -13,10 +14,6 @@ export interface SubmissionExplain {
|
||||
raw: string
|
||||
}
|
||||
|
||||
function stripJudgePath(text: string) {
|
||||
return text.replace(/\/judger\/run\/[^/"]+\//g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
|
||||
* (pythonError.ts / cError.ts / runtimeError.ts)。提交列表右栏和独立的提交详情页共用。
|
||||
|
||||
@@ -16,3 +16,12 @@ export function trialRun(payload: TrialRunRequest) {
|
||||
export function isRunnableLanguage(language: LANGUAGE): language is RunnableLanguage {
|
||||
return language === 'C' || language === 'C++' || language === 'Python'
|
||||
}
|
||||
|
||||
/**
|
||||
* 报错和输出里判题机的工作目录对学生没有意义,只留文件名。两种写法:
|
||||
* 2026-10 之前的判题机(JudgeServer)是 `/judger/run/<32 位随机串>/`,库里的老提交都是这种;
|
||||
* 现在的 go-judge 是 `/w/`,只有 Python 会带出来(`File "/w/solution.py"`)。
|
||||
*/
|
||||
export function stripJudgePath(text: string) {
|
||||
return text.replace(/\/judger\/run\/[^/"]+\/|\/w\/(?=solution\.py\b)/g, '')
|
||||
}
|
||||
@@ -53,10 +53,6 @@ DATA_DIR=
|
||||
# **NPM 的记录要打开 Websockets Support**,否则学生那边「判题中…」永远不动。
|
||||
WEB_PORT=
|
||||
|
||||
# 判题机的运行状态目录(log/、run/),不设是 `$DATA_DIR/judge_server`。
|
||||
# 换目录记得先 `mkdir -p .../{log,run}`。
|
||||
JUDGE_STATE_DIR=
|
||||
|
||||
# --- 只有机房那套需要 ---
|
||||
# 机房没有本地库,连服务器的 5445。不填时默认指向服务器(默认值在 compose.school.yml 里),
|
||||
# 换服务器才要填。服务器那套不读这两个。
|
||||
|
||||
+2
-3
@@ -36,9 +36,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
# 判题机心跳每秒一次;静态资源量大且带永久缓存。两者都不记访问日志。
|
||||
# 路径跟着新后端改了:judge_server_heartbeat → judge-server/heartbeat
|
||||
@nolog path /api/judge-server/heartbeat /api/judge-server/heartbeat/ /assets/* /emoji/*
|
||||
# 静态资源量大且带永久缓存,不记访问日志
|
||||
@nolog path /assets/* /emoji/*
|
||||
log_skip @nolog
|
||||
|
||||
handle /api/* {
|
||||
|
||||
@@ -87,33 +87,21 @@ services:
|
||||
retries: 10
|
||||
|
||||
oj-judge:
|
||||
# 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
|
||||
# 上游停更在 2024-04,registry 上的 1.6.1 == latest,没有新版可拉。
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
image: oj2-judge-2
|
||||
image: oj2-judge-3
|
||||
container_name: oj-judge
|
||||
restart: always
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- SETPCAP
|
||||
- MKNOD
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_CHROOT
|
||||
- SETFCAP
|
||||
- FSETID
|
||||
# go-judge 要自己建 namespace 和 cgroup 来关学生程序,只能给特权,理由见 docker/judge/README.md
|
||||
privileged: true
|
||||
# go-judge 的文件存储(编译产物、程序输出)放这里,见 docker/judge/Dockerfile 的 -dir
|
||||
tmpfs:
|
||||
- /tmp
|
||||
volumes:
|
||||
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
|
||||
# 判题机的运行状态目录,默认跟着 DATA_DIR 走
|
||||
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
|
||||
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
|
||||
environment:
|
||||
SERVICE_URL: http://oj-judge:8080
|
||||
# 心跳路径跟着新后端改了:judge_server_heartbeat/ → judge-server/heartbeat
|
||||
BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat
|
||||
TOKEN: ${OJ2_JUDGE_TOKEN:?请在 docker/.env 里设置 OJ2_JUDGE_TOKEN}
|
||||
ES_AUTH_TOKEN: ${OJ2_JUDGE_TOKEN:?请在 docker/.env 里设置 OJ2_JUDGE_TOKEN}
|
||||
# 同一时刻最多六份在编译(正式判题 2、试运行 2、提交前编译检查 2),g++ 编一份 iostream
|
||||
# 峰值约 70MB、bits/stdc++.h 约 195MB,再加学生程序本身。512m 不够撑满,超了是容器里
|
||||
# 随机杀进程、学生看到系统错误
|
||||
@@ -144,6 +132,8 @@ services:
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?请在 docker/.env 里设置 REDIS_PASSWORD}@oj-redis:6379
|
||||
JUDGE_SERVER_URL: http://oj-judge:8080
|
||||
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
|
||||
# 后台「判题机列表」里这一台的名字(worker 替判题机记心跳,见 judge/heartbeat.ts)
|
||||
JUDGE_SERVER_NAME: 服务器
|
||||
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
|
||||
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-2}
|
||||
AI_KEY: ${AI_KEY:-}
|
||||
|
||||
+9
-21
@@ -21,9 +21,9 @@
|
||||
# echo "OJ2_JUDGE_TOKEN=$TOKEN" >> docker/.env # docker compose 读这里
|
||||
# echo "JUDGE_SERVER_TOKEN=$TOKEN" >> .env # 后端读这里,两边必须一致
|
||||
#
|
||||
# 两个变量名不同但值必须相同:TOKEN 是判题机镜像认的名字,JUDGE_SERVER_TOKEN 是后端认的名字。
|
||||
# 忘了设 OJ2_JUDGE_TOKEN 的话 docker compose 会直接报错退出;忘了设 JUDGE_SERVER_TOKEN
|
||||
# 的话后端会随机生成一个并在启动日志里告警,判题机心跳会被 403 挡掉。
|
||||
# 两个变量名不同但值必须相同:ES_AUTH_TOKEN 是判题机(go-judge)认的名字,JUDGE_SERVER_TOKEN
|
||||
# 是后端认的名字。忘了设 OJ2_JUDGE_TOKEN 的话 docker compose 会直接报错退出;忘了设
|
||||
# JUDGE_SERVER_TOKEN 的话后端会随机生成一个并在启动日志里告警,判题请求会被 401 挡掉。
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -60,37 +60,25 @@ services:
|
||||
retries: 10
|
||||
|
||||
judge:
|
||||
# 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
|
||||
# 上游停更在 2024-04,registry 上的 1.6.1 == latest,没有新版可拉。
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
image: oj2-judge-2
|
||||
image: oj2-judge-3
|
||||
container_name: oj2-judge
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- SETPCAP
|
||||
- MKNOD
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_CHROOT
|
||||
- SETFCAP
|
||||
- FSETID
|
||||
# go-judge 要自己建 namespace 和 cgroup 来关学生程序,只能给特权,理由见 docker/judge/README.md
|
||||
privileged: true
|
||||
# go-judge 的文件存储(编译产物、程序输出)放这里,见 Dockerfile 的 -dir
|
||||
tmpfs:
|
||||
- /tmp
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
volumes:
|
||||
- ../data/test_case:/test_case:ro
|
||||
- ../data/judge_server/log:/log
|
||||
- ../data/judge_server/run:/judger
|
||||
environment:
|
||||
SERVICE_URL: http://oj2-judge:8080
|
||||
BACKEND_URL: http://host.docker.internal:3000/api/judge-server/heartbeat
|
||||
# 没有默认值是故意的:写死在仓库里的弱 token 等于没有 token。
|
||||
# 未设置时 docker compose 直接报错,不会静默起一个人人可连的判题机。
|
||||
TOKEN: ${OJ2_JUDGE_TOKEN:?请先设置 OJ2_JUDGE_TOKEN,见本文件顶部注释}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ES_AUTH_TOKEN: ${OJ2_JUDGE_TOKEN:?请先设置 OJ2_JUDGE_TOKEN,见本文件顶部注释}
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -29,32 +29,21 @@ services:
|
||||
retries: 10
|
||||
|
||||
oj-judge:
|
||||
# 自己构建的判题沙箱(上游 JudgeServer 1.6.1 + 新工具链,见 docker/judge/)。
|
||||
# 上游停更在 2024-04,registry 上的 1.6.1 == latest,没有新版可拉。
|
||||
# 自己构建的判题沙箱(go-judge + gcc / python,见 docker/judge/)。
|
||||
# ⚠️ 这个 tag 不在任何 registry 上:本机 docker/judge/build.sh 构建,
|
||||
# 服务器/机房用 docker load 装。镜像不在本地时 compose 会去 pull 然后报找不到。
|
||||
image: oj2-judge-2
|
||||
image: oj2-judge-3
|
||||
container_name: oj-judge
|
||||
restart: always
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- SETPCAP
|
||||
- MKNOD
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_CHROOT
|
||||
- SETFCAP
|
||||
- FSETID
|
||||
# go-judge 要自己建 namespace 和 cgroup 来关学生程序,只能给特权,理由见 docker/judge/README.md
|
||||
privileged: true
|
||||
# go-judge 的文件存储(编译产物、程序输出)放这里,见 docker/judge/Dockerfile 的 -dir
|
||||
tmpfs:
|
||||
- /tmp
|
||||
volumes:
|
||||
- ${DATA_DIR:-../data}/backend/test_case:/test_case:ro
|
||||
# 判题机的运行状态目录,默认跟着 DATA_DIR 走
|
||||
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/log:/log
|
||||
- ${JUDGE_STATE_DIR:-${DATA_DIR:-../data}/judge_server}/run:/judger
|
||||
environment:
|
||||
SERVICE_URL: http://oj-judge:8080
|
||||
BACKEND_URL: http://oj-api:3000/api/judge-server/heartbeat
|
||||
TOKEN: ${OJ2_JUDGE_TOKEN:?请在 env 文件里设置 OJ2_JUDGE_TOKEN}
|
||||
ES_AUTH_TOKEN: ${OJ2_JUDGE_TOKEN:?请在 env 文件里设置 OJ2_JUDGE_TOKEN}
|
||||
# 机房机器内存宽裕,判题给足
|
||||
mem_limit: 2g
|
||||
|
||||
@@ -81,6 +70,8 @@ services:
|
||||
REDIS_URL: redis://oj-redis:6379
|
||||
JUDGE_SERVER_URL: http://oj-judge:8080
|
||||
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
|
||||
# 后台「判题机列表」里这一台的名字(worker 替判题机记心跳,见 judge/heartbeat.ts)
|
||||
JUDGE_SERVER_NAME: 机房
|
||||
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-4}
|
||||
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-4}
|
||||
AI_KEY: ${AI_KEY:-}
|
||||
|
||||
+1
-7
@@ -108,14 +108,9 @@ pgver=$(cat "$pgver_file")
|
||||
[ "$pgver" = 18 ] || die "$data_dir/postgresql 是 PostgreSQL $pgver 的数据目录,但 compose 里是 postgres:18-alpine,起不来。"
|
||||
ok "数据目录 $data_dir/postgresql(PostgreSQL $pgver)"
|
||||
|
||||
# ③ 判题机运行目录:不设就是 $DATA_DIR/judge_server
|
||||
judge_dir=$(grep -E '^JUDGE_STATE_DIR=' docker/.env | tail -1 | cut -d= -f2- || true)
|
||||
ok "判题机运行目录 ${judge_dir:-$data_dir/judge_server}"
|
||||
|
||||
# ④ 判题镜像是自建的(上游 JudgeServer 停更,官方镜像的编译器停在 gcc-13),
|
||||
# ③ 判题镜像是自建的(go-judge 的二进制 + 我们要的 gcc / python),
|
||||
# registry 上没有这个 tag。忘了 docker load 的话,要到「起栈」那步 compose 去 pull
|
||||
# 才失败 —— 不如在这里就把该跑的三条命令说清楚。
|
||||
# 回滚到官方镜像时这段自动跳过:那个 tag 是 pull 得到的。
|
||||
judge_image=$(grep -m1 -E '^[[:space:]]*image: oj2-judge' <<<"$cfg" | awk '{print $2}' || true)
|
||||
if [ -n "$judge_image" ]; then
|
||||
docker image inspect "$judge_image" >/dev/null 2>&1 \
|
||||
@@ -134,7 +129,6 @@ ok "对外端口 $port"
|
||||
[ "$CHECK_ONLY" -eq 0 ] || { printf '\n\033[1;32m✅ 自检通过(--check,没动容器)\033[0m\n'; exit 0; }
|
||||
|
||||
# ---------------------------------------------------------------- 起栈
|
||||
mkdir -p "$judge_dir/log" "$judge_dir/run"
|
||||
|
||||
if [ "$BUILD" -eq 1 ]; then
|
||||
say "构建镜像(首次约 5 分钟,之后走缓存很快)"
|
||||
|
||||
+34
-99
@@ -1,86 +1,41 @@
|
||||
# 判题沙箱镜像。**这是 QingdaoU/JudgeServer 官方 Dockerfile 的分叉**,
|
||||
# 只改工具链版本,server/ 和 Judger/ 的代码一行都没动(构建时从上游仓库
|
||||
# 的固定 commit 拉,见 build.sh)。
|
||||
# 判题沙箱镜像:go-judge(https://github.com/criyle/go-judge)+ 我们要的工具链。
|
||||
#
|
||||
# 为什么要分叉:上游停更在 2024-04-05(b28aa56,也就是 1.6.1 这个 tag),
|
||||
# registry 上的 `latest` 和 `1.6.1` 是同一份镜像,没有新版可升。想要新编译器
|
||||
# 只能自己构建。
|
||||
# go-judge 只管「在沙箱里跑一条命令」:编译、逐个测试点运行、比对输出、算状态码
|
||||
# 都在 api 那边(apps/api/src/judge/sandbox.ts)。所以这个镜像里只有两样东西:
|
||||
# go-judge 的单个二进制,和学生程序要用的编译器 / 解释器。
|
||||
#
|
||||
# 相对上游的全部改动:
|
||||
# gcc/g++ 13 → 14 (trixie 默认)
|
||||
# Python 3.12 → 3.13 (trixie 默认)
|
||||
# Go / JDK / Node → **整套删掉**(见下)
|
||||
# 2026-10 从 QingdaoU/JudgeServer 换过来的(那边停更在 2024-04,而且 gunicorn 按
|
||||
# 核数起 worker,16 核的机器空闲就占 300MB)。换的原因和逐份对比的结论见 README.md。
|
||||
#
|
||||
# 只留 C / C++ / Python3 的工具链:前端的语言复选框从来只给这三种加 SQL,
|
||||
# 生产库 12 万条提交里 Java/Golang/JavaScript 一共 62 条、全是很早以前的。
|
||||
# 删掉 golang-1.24-go、temurin-25-jdk、nodejs 之后镜像从 1.16GB 掉到 ~500MB,
|
||||
# 构建也少了 NodeSource 那个 39MB 的 deb(它没有可用的国内镜像,最慢的一块)。
|
||||
# 想恢复某种语言:这里加回包 + alternatives,同时改 apps/api/src/judge/languages.ts。
|
||||
# gcc / g++ 14 (trixie 默认)
|
||||
# Python 3.13 (trixie 默认)
|
||||
#
|
||||
# ⚠️ 语言的编译/运行命令**不在这个文件里**,在 apps/api/src/judge/languages.ts。
|
||||
# 升 gcc 大版本要同步看那边的开关(gcc-14 把 implicit-function-declaration
|
||||
# 等提成了 error,languages.ts 里有几个 -Wno-error 把它压回去)。
|
||||
# ⚠️ 换 Python 大版本时,api 镜像(docker/Dockerfile 的 python3-minimal)要一起换,
|
||||
# 理由见 README.md。
|
||||
|
||||
# 镜像源。默认走国内镜像 —— 官方源在这边实测 197 KB/s,清华 3.9 MB/s,
|
||||
# 整个构建从 12 分钟掉到 2 分钟出头。出国内网络环境用 build.sh --no-mirror 关掉。
|
||||
# go-judge 的版本。升级前看一眼它的 release notes 里有没有改 /run 的出入参。
|
||||
ARG GO_JUDGE_VERSION=v1.13.3
|
||||
|
||||
# 镜像源。默认走国内镜像 —— 官方源在这边实测 197 KB/s,清华 3.9 MB/s。
|
||||
# 出国内网络环境用 build.sh --no-mirror 关掉。
|
||||
#
|
||||
# ⚠️ debian 这两个只能用 **http**:改 sources 这一步发生在装 ca-certificates 之前,
|
||||
# ⚠️ 只能用 **http**:改 sources 这一步发生在装 ca-certificates 之前,
|
||||
# base 镜像里没有 CA 根证书,https 一律 `certificate verify failed`。
|
||||
# PyPI 那条是 pip 自己请求的,pip 内置 certifi,不依赖系统 CA。
|
||||
ARG APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian
|
||||
ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
FROM debian:trixie-slim AS builder
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG APT_MIRROR
|
||||
ARG APT_SECURITY_MIRROR
|
||||
ARG PIP_INDEX_URL
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-builder,sharing=locked \
|
||||
<<EOS
|
||||
set -ex
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
|
||||
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
|
||||
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
|
||||
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
|
||||
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
|
||||
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y libtool make cmake libseccomp-dev gcc python3 python3-venv
|
||||
EOS
|
||||
|
||||
COPY Judger/ /app/
|
||||
RUN <<EOS
|
||||
set -ex
|
||||
mkdir /app/build
|
||||
cmake -S . -B build
|
||||
cmake --build build --parallel $(nproc)
|
||||
EOS
|
||||
|
||||
RUN <<EOS
|
||||
set -ex
|
||||
cd bindings/Python
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip3 install -i "$PIP_INDEX_URL" build
|
||||
.venv/bin/python3 -m build -w
|
||||
EOS
|
||||
FROM criyle/go-judge:${GO_JUDGE_VERSION} AS go-judge
|
||||
|
||||
FROM debian:trixie-slim
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG APT_MIRROR
|
||||
ARG APT_SECURITY_MIRROR
|
||||
ARG PIP_INDEX_URL
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,id=apt-cahce-1-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,id=apt-cahce-2-$TARGETARCH$TARGETVARIANT-final,sharing=locked \
|
||||
@@ -90,59 +45,39 @@ rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/keep-cache
|
||||
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/no-recommends
|
||||
echo 'APT::AutoRemove::RecommendsImportant "0";' >> /etc/apt/apt.conf.d/no-recommends
|
||||
needed="python3.13-minimal \
|
||||
python3.13-venv \
|
||||
libpython3.13-stdlib \
|
||||
libpython3.13-dev \
|
||||
gcc-14 \
|
||||
g++-14 \
|
||||
strace"
|
||||
savedAptMark="$(apt-mark showmanual) $needed"
|
||||
# 换镜像源。debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
|
||||
# debian-security 必须先换:它的 URI 以 debian 的 URI 为前缀,
|
||||
# 反过来的话第二条 sed 会把它改成 <镜像>/debian-security,路径不存在。
|
||||
sed -i "s|http://deb.debian.org/debian-security|$APT_SECURITY_MIRROR|; s|http://deb.debian.org/debian|$APT_MIRROR|" /etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y $needed
|
||||
# 和换掉的 JudgeServer 镜像同一组包(去掉了它自己要的 venv / dev / strace),
|
||||
# 学生程序能 import 的标准库模块因此一个不少
|
||||
apt-get install -y python3.13-minimal libpython3.13-stdlib gcc-14 g++-14
|
||||
# languages.ts 里的编译/运行命令写的是绝对路径(/usr/bin/gcc、/usr/bin/python3 …),
|
||||
# 全靠下面这几条 alternatives 挂出来。换包名必须同步改,漏一条的表现是 CE 而不是报错。
|
||||
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 14
|
||||
update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-14 14
|
||||
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.13 13
|
||||
apt-mark auto '.*' > /dev/null
|
||||
apt-mark manual $savedAptMark
|
||||
apt-get purge -y --auto-remove
|
||||
EOS
|
||||
|
||||
COPY --from=builder --chmod=755 --link /app/output/libjudger.so /usr/lib/judger/libjudger.so
|
||||
COPY --from=builder /app/bindings/Python/dist/ /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cahce-$TARGETARCH$TARGETVARIANT-final \
|
||||
<<EOS
|
||||
set -ex
|
||||
python3 -m venv .venv
|
||||
CC=gcc .venv/bin/pip3 install -i "$PIP_INDEX_URL" --compile --no-cache-dir flask gunicorn idna psutil requests
|
||||
.venv/bin/pip3 install *.whl
|
||||
EOS
|
||||
COPY --from=go-judge /opt/go-judge /usr/local/bin/go-judge
|
||||
COPY mount.yaml /etc/go-judge/mount.yaml
|
||||
|
||||
COPY server/ /app/
|
||||
RUN <<EOS
|
||||
set -ex
|
||||
chmod -R u=rwX,go=rX /app/
|
||||
chmod +x /app/entrypoint.sh
|
||||
gcc -shared -fPIC -o unbuffer.so unbuffer.c
|
||||
useradd -u 901 -r -s /sbin/nologin -M compiler
|
||||
useradd -u 902 -r -s /sbin/nologin -M code
|
||||
useradd -u 903 -r -s /sbin/nologin -M -G code spj
|
||||
mkdir -p /usr/lib/judger
|
||||
EOS
|
||||
|
||||
# 构建期自检:这五行任何一个不是预期版本,说明上面的包名或 alternatives 配错了。
|
||||
# 构建期自检:任何一个不是预期版本,说明上面的包名或 alternatives 配错了。
|
||||
RUN <<EOS
|
||||
set -ex
|
||||
gcc --version
|
||||
g++ --version
|
||||
python3 --version
|
||||
go-judge -version
|
||||
EOS
|
||||
|
||||
HEALTHCHECK --interval=5s CMD [ "/app/.venv/bin/python3", "/app/service.py" ]
|
||||
# 参数也能用环境变量覆盖(ES_ 前缀,比如 ES_PARALLELISM=4),compose 里就是这么传 token 的。
|
||||
# -http-addr 端口沿用 JudgeServer 的 8080,compose 和 JUDGE_SERVER_URL 不用改
|
||||
# -mount-conf 沙箱里能看到哪些目录,见 mount.yaml
|
||||
# -src-prefix 学生程序的 stdin 直接从 /test_case 里开,不经 api 中转;别的路径一律拒
|
||||
# -output-limit 每条命令写文件的上限(RLIMIT_FSIZE)。16MB 是 JudgeServer 的口径:
|
||||
# 死循环打印写到 16MB 吃 SIGXFSZ,判运行时错误,前端有对应的中文说明
|
||||
# -file-timeout 编译产物和输出由 api 用完就删,这是 api 半路崩掉时的兜底
|
||||
# -dir 文件存储默认在 /dev/shm,docker 默认只给 64MB,放到 /tmp(compose 里的 tmpfs)
|
||||
ENTRYPOINT [ "go-judge", "-http-addr=:8080", "-mount-conf=/etc/go-judge/mount.yaml", "-src-prefix=/test_case", "-output-limit=16m", "-file-timeout=10m", "-dir=/tmp/go-judge" ]
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
+110
-71
@@ -1,109 +1,148 @@
|
||||
# 判题沙箱镜像
|
||||
|
||||
判题机跑的是 [QingdaoU/JudgeServer](https://github.com/QingdaoU/JudgeServer)。
|
||||
**上游已经停更**:master 最后一次提交是 2024-04-05(`b28aa56`,即 tag `v1.6.1`),
|
||||
registry 上的 `oj-image/judge:latest` 和 `:1.6.1` 是同一份镜像(config digest 都是
|
||||
`221bf4c0e730`)。所以想换新编译器,只能自己构建。
|
||||
判题机跑的是 [go-judge](https://github.com/criyle/go-judge):一个 Go 写的沙箱服务,
|
||||
只管「在沙箱里跑一条命令、量出用了多少时间和内存」。**编译、逐个测试点运行、比对输出、
|
||||
定状态码都在后端** `apps/api/src/judge/sandbox.ts`。
|
||||
|
||||
这里放的是**只改工具链的分叉**:`server/`(Flask + `_judger` 那套判题逻辑)和
|
||||
`Judger/`(libjudger.so 沙箱内核)一行都没动,构建时从上游那个固定 commit 拉。
|
||||
这个镜像里只有两样东西:go-judge 的单个二进制(从官方镜像 `criyle/go-judge` 里拷出来),
|
||||
和学生程序要用的工具链。
|
||||
|
||||
## 工具链
|
||||
|
||||
2026-09 从上游镜像升级,同时把用不上的三种语言整套砍掉:
|
||||
|
||||
| | 上游 1.6.1 | 现在 |
|
||||
|---|---|---|
|
||||
| gcc / g++ | 13 | **14.2**(trixie 默认) |
|
||||
| Python | 3.12 | **3.13.5**(trixie 默认) |
|
||||
| Go | 1.22 | **删掉** |
|
||||
| Node | 20.x | **删掉** |
|
||||
| JDK | temurin-21 | **删掉** |
|
||||
| base | debian:trixie-slim(2024 年的) | debian:trixie-slim(当前) |
|
||||
| 镜像体积 | 1.1 GB | **433 MB** |
|
||||
| | 版本 |
|
||||
|---|---|
|
||||
| go-judge | **v1.13.3**(`Dockerfile` 的 `GO_JUDGE_VERSION`) |
|
||||
| gcc / g++ | **14.2**(trixie 默认) |
|
||||
| Python | **3.13.5**(trixie 默认) |
|
||||
| base | debian:trixie-slim |
|
||||
| 镜像体积 | 384 MB |
|
||||
|
||||
⚠️ **换 Python 大版本时,api 镜像(`docker/Dockerfile` 的 `python3-minimal`)要一起换。**
|
||||
提交前的语法检查在 api 那边用 CPython 编译一遍,前端再按报错原文翻成中文(翻译表在
|
||||
`apps/web/src/oj/problem/utils/pythonError.ts`)。两边版本不一致的话,同一份代码
|
||||
`apps/web/src/utils/pythonError.ts`)。两边版本不一致的话,同一份代码
|
||||
在提交前和判题时报的句式可能不同,翻译表只能对上其中一边。
|
||||
|
||||
砍语言的依据:前端的题目语言复选框从来只给 Python / C / C++ / SQL,
|
||||
生产库 12 万条提交里 Java 44 条、Golang 15 条、JavaScript 3 条,全是很早以前的。
|
||||
契约 `judgeLanguageSchema` 里那几个键留着(渲染历史提交要用),只是判题机不再认。
|
||||
要恢复某种语言:Dockerfile 里加回包和 `update-alternatives`,同时改
|
||||
`apps/api/src/judge/languages.ts`,两边缺一个都是静默失败。
|
||||
## 为什么从 JudgeServer 换过来(2026-10)
|
||||
|
||||
之前跑的是 QingdaoU/JudgeServer(上游停更在 2024-04,我们自己重编过工具链,那是 `oj2-judge-2`)。
|
||||
它的 gunicorn 按核数起 worker、每份提交再按核数开进程池,**空闲内存跟着机器核数走**:
|
||||
|
||||
| | 空闲 | 判题中(同一批提交) |
|
||||
|---|---|---|
|
||||
| JudgeServer,2 核(= 服务器) | 52 MB | — |
|
||||
| JudgeServer,16 核 | 305 MB | 约 370 MB |
|
||||
| go-judge | **16 MB** | **80–100 MB** |
|
||||
|
||||
判题中那一列是在 16 核的开发机上、两边同时跑同一批真实提交时量的。go-judge 同时跑几个
|
||||
程序由 `-parallelism` 管(默认等于核数),不再随提交数翻倍。
|
||||
|
||||
## 判定和原来一样吗
|
||||
|
||||
后端逐条照搬了 JudgeServer 的口径(输出 rstrip 后比 md5、stdout 和 stderr 合在一起比、
|
||||
地址空间限两倍内存、16MB 输出上限、墙钟是 CPU 时限的 3 倍、libjudger 的状态码顺序),
|
||||
细节见 `sandbox.ts` 顶部。换之前拿**生产库里的真实提交**,同一份代码同时送进新旧两台判题机,
|
||||
逐个测试点比状态码:
|
||||
|
||||
| 语料 | 份数 | 一模一样 | 不一样 |
|
||||
|---|---|---|---|
|
||||
| C 提交(共 19757,随机抽样) | 2000 | 1989 | 11 |
|
||||
| C++ 提交(全量) | 891 | 890 | 1 |
|
||||
| Python 提交(共 115492,随机抽样) | 2000 | 2000 | 0 |
|
||||
|
||||
不一样的全部是下面三种,都是有意接受的:
|
||||
|
||||
- **`system("pause")`**(Dev-C++ 留下的习惯):原来 seccomp 直接杀,运行时错误、信号 31;
|
||||
现在学生程序开不了子进程,`system()` 返回 -1,程序照常往下跑、按输出判。全库这样判成
|
||||
运行时错误的一共 63 条,2026-09 以后一条没有。
|
||||
- **未定义行为**:读越界的数组元素(`max = num[10]`)、没读进值的变量。读到的垃圾值取决于
|
||||
栈上原来有什么,换了沙箱就换了一批垃圾。库里存的结果本来就和旧判题机现在重跑的对不上。
|
||||
- **死循环里用 `endl` 打印**(C++ 那 1 条):原来 CPU 时间只算用户态,先写满 16MB 判运行时错误;
|
||||
现在内核态也算(每次 `endl` 刷新都是一次系统调用),先到 1 秒判运行超时。都是错,说法不同。
|
||||
|
||||
另外几处数字会变,不影响判定:
|
||||
|
||||
- **内存读数变小**:现在是 cgroup 的峰值,原来是 ru_maxrss。C 的空程序 0.25 MB(原来
|
||||
1.5 MB),Python 2–5 MB(原来 8 MB)。程序打印的输出也算在内存里(写在 tmpfs 上)。
|
||||
- **CPU 时间含内核态**,原来只算用户态。读大输入的程序会多出几毫秒。
|
||||
- **Python 的回溯路径**是 `/w/solution.py`,原来是 `/judger/run/<随机串>/solution.py`。
|
||||
前端显示前两种都会去掉(`apps/web/src/utils/judge.ts` 的 `stripJudgePath`)。
|
||||
编译错误是相对路径(`main.c:3:5`、`File "solution.py"`),不受影响。
|
||||
|
||||
## 判题机容器为什么是 privileged
|
||||
|
||||
go-judge 要给每个学生程序建 namespace(看不到网络、别的进程、宿主的文件)和 cgroup(限内存、
|
||||
数进程),这些在普通容器里做不了。试过不给特权:要同时加 `SYS_ADMIN`、关掉 Docker 的
|
||||
seccomp 和 AppArmor、去掉 `/proc` `/sys` 的遮罩、再在容器里把 cgroup 重新挂成可写 ——
|
||||
那几乎就是特权了,还更脆,换台宿主机就可能起不来。所以照 go-judge 官方的做法给 `privileged: true`。
|
||||
|
||||
学生程序本身是关在 go-judge 自己的沙箱里的(独立的 mount / pid / net / ipc / uts / user
|
||||
namespace、cgroup、seccomp),比原来 libjudger 只有 seccomp + setuid 关得更严。
|
||||
|
||||
**宿主机要求**:cgroup v2(Debian 12 起默认就是),内核 ≥ 5.19 时内存峰值用 `memory.peak`,
|
||||
更老的内核退回 maxrss(读数偏大一点)。起不来时 `docker logs oj-judge` 里会有
|
||||
`create environment builder failed`。
|
||||
|
||||
## 镜像源
|
||||
|
||||
默认走清华源(`Dockerfile` 顶部三个 ARG)。官方源在这边实测 **197 KB/s**,清华
|
||||
**3.9 MB/s**,整个构建从 12 分钟掉到 1 分钟以内。
|
||||
默认走清华源(`Dockerfile` 顶部两个 ARG)。官方源在这边实测 **197 KB/s**,清华
|
||||
**3.9 MB/s**。
|
||||
|
||||
`build.sh --no-mirror` 换回官方源。debian 那两个只能用 http —— 改 sources 发生在
|
||||
装 ca-certificates 之前,base 镜像里没有 CA 根证书,https 一律
|
||||
`certificate verify failed`(这个坑踩过)。
|
||||
|
||||
go-judge 那层是从 Docker Hub 拉 `criyle/go-judge:<版本>`,构建机要能拉到它。
|
||||
|
||||
## 构建与分发
|
||||
|
||||
```bash
|
||||
docker/judge/build.sh --save # 本机构建 + 导出 dist/oj2-judge-2.tar
|
||||
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
|
||||
ssh root@服务器 'docker load -i /root/OJDeploy/oj2-judge-2.tar'
|
||||
docker/judge/build.sh --save # 本机构建 + 导出 dist/oj2-judge-3.tar
|
||||
scp dist/oj2-judge-3.tar root@服务器:/root/OJDeploy/
|
||||
ssh root@服务器 'docker load -i /root/OJDeploy/oj2-judge-3.tar'
|
||||
# 机房那台同样来一遍 —— 两个站点各有各的判题沙箱
|
||||
```
|
||||
|
||||
之后正常 `docker/deploy.sh` 即可。
|
||||
之后正常 `docker/deploy.sh` 即可。**判题机和 api / worker 要一起换**:新后端只会说
|
||||
go-judge 的话,旧判题机只认旧后端。
|
||||
|
||||
⚠️ **这个名字不在任何 registry 上。** 服务器上忘了 `docker load`,compose 会去
|
||||
pull 然后报找不到镜像(好在是响亮地失败,不是静默降级)。
|
||||
⚠️ **这个名字不在任何 registry 上。** 服务器上忘了 `docker load`,`deploy.sh` 的自检会拦下。
|
||||
|
||||
⚠️ **改工具链就把末尾的序号 +1**(下一版叫 `oj2-judge-3`:`build.sh` 里的 `IMAGE`、
|
||||
compose 里三处,一起改)。`docker compose up -d` 不带 `--pull`,名字没变会静默用机器上
|
||||
的旧镜像。官方镜像算第 1 版,所以我们自己重编的从 `-2` 起。
|
||||
⚠️ **改工具链或升 go-judge 就把末尾的序号 +1**(下一版叫 `oj2-judge-4`:`build.sh` 里的
|
||||
`IMAGE`、三个 compose 里的 image,一起改)。`docker compose up -d` 不带 `--pull`,名字没变会静默
|
||||
用机器上的旧镜像。
|
||||
|
||||
回滚:把三个 compose 的 image 改回
|
||||
`registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1`,重新 `up -d`。别在服务器上
|
||||
`docker image prune` 把那份旧镜像清掉。
|
||||
回滚:JudgeServer 那版(`oj2-judge-2`)只认 2026-10 之前的后端,要连代码一起回退到换判题机之前
|
||||
的提交再部署。别在服务器上 `docker image prune` 把 `oj2-judge-2` 清掉。
|
||||
|
||||
## 和 `languages.ts` 的关系
|
||||
## 和后端的分工
|
||||
|
||||
**编译和运行命令不在镜像里**,在 `apps/api/src/judge/languages.ts`。镜像只负责把
|
||||
`/usr/bin/gcc`、`/usr/bin/python3`、`/usr/bin/go`、`/usr/bin/node`、`/usr/bin/java`
|
||||
这些绝对路径挂到正确的版本上(Dockerfile 末尾的 `update-alternatives`)。
|
||||
- **编译和运行命令在 `apps/api/src/judge/languages.ts`**,不在镜像里。镜像只负责把
|
||||
`/usr/bin/gcc`、`/usr/bin/g++`、`/usr/bin/python3` 这些绝对路径挂到正确的版本上
|
||||
(Dockerfile 里的 `update-alternatives`)。恢复一种语言要两边一起改,缺一个都是静默失败。
|
||||
- **沙箱里能看到哪些目录在 `mount.yaml`**:`/usr`、`/lib` 这些只读,工作目录 `/w` 和 `/tmp`
|
||||
各是一块 128MB 的 tmpfs。
|
||||
- **测试点不经过 api**:学生程序的 stdin 直接从判题机容器里的 `/test_case/<id>/N.in` 开
|
||||
(镜像的 `-src-prefix=/test_case`,别的路径一律拒)。比对用的 `stripped_output_md5` 由 api
|
||||
从同一个目录的 `info` 里读。所以 compose 里判题机和 api 挂的必须是同一个测试点目录。
|
||||
- **程序输出不内联回传**:每个测试点的输出(最多 16MB)留在判题机的文件存储里,api 用
|
||||
`GET /file/:id` 按原始字节取回来比 md5,然后删掉。直接塞在 JSON 里的话,死循环打印的
|
||||
16MB 会在 worker 里膨胀成几倍。api 半路崩了没删的,判题机 10 分钟后自己清(`-file-timeout`)。
|
||||
- **心跳**:go-judge 不主动报,worker 每 5 秒问一次 `/version`,替它在 `judge_server` 表里记一笔
|
||||
(`apps/api/src/judge/heartbeat.ts`)。后台列表里两台分别叫「服务器」「机房」(compose 的
|
||||
`JUDGE_SERVER_NAME`)。
|
||||
- **鉴权**:`Authorization: Bearer <OJ2_JUDGE_TOKEN>`,go-judge 从 `ES_AUTH_TOKEN` 读。原来
|
||||
JudgeServer 收的是 token 的 sha256,现在是原文。
|
||||
|
||||
gcc-14 把隐式函数声明、int↔指针互赋、不兼容指针类型从 warning 提成了 error,`-w`
|
||||
压不住。`languages.ts` 里的 `cLooseErrors` 里的 `-Wno-error=` 就是为此加的 ——
|
||||
实测 1951 份历史 C 提交和 20 篇 C 教程的 93 个代码块,加了之后与 gcc-13 逐个文件
|
||||
结果完全一致;不加的话有一批会从能过变成 CE。
|
||||
|
||||
## 这次升级是怎么验的
|
||||
|
||||
不写测试,全是实跑。除了 `smoke.ts` 的 13 条,还拿**生产库备份里的真实代码**逐个
|
||||
文件对比了新旧镜像的编译结果(脚本是一次性的,结论记在这里):
|
||||
|
||||
| 语料 | 份数 | 老镜像 (gcc-13 / py3.12) | 新镜像 (gcc-14 / py3.13) | 差异 |
|
||||
|---|---|---|---|---|
|
||||
| 历史 C 提交(共 19262,随机抽样) | 1951 | 1725 过 / 226 CE | 一模一样 | **0** |
|
||||
| 历史 C++ 提交(全量) | 882 | 603 过 / 279 CE | 一模一样 | **0** |
|
||||
| 历史 Python 提交(共 104527,随机抽样) | 2000 | 1833 过 / 167 CE | 一模一样 | **0** |
|
||||
| 20 篇 C 教程里含 `int main` 的代码块 | 93 | 93 全过 | 一模一样 | **0** |
|
||||
|
||||
**不加 `cLooseErrors` 那三个开关的话,1951 份 C 提交里有 26 份会从「能过」变成 CE**
|
||||
(按比例算全库约 260 条),全是忘了 `#include <string.h>` 之类的隐式函数声明。
|
||||
教程那 93 块本身写得规范,加不加都全过。
|
||||
2026-09 升 gcc-14 时实测 1951 份历史 C 提交和 20 篇 C 教程的 93 个代码块,加了之后与
|
||||
gcc-13 逐个文件结果完全一致;不加的话有 26 份会从「能过」变成 CE(按比例算全库约 260 条),
|
||||
全是忘了 `#include <string.h>` 之类的隐式函数声明。
|
||||
|
||||
## 换镜像后怎么验
|
||||
|
||||
```bash
|
||||
bun docker/judge/smoke.ts # 六种语言 + 六种状态码 + gcc 宽松度
|
||||
bun docker/judge/smoke.ts # 三种语言 + 六种状态码 + gcc 宽松度 + 几条照搬的口径
|
||||
```
|
||||
|
||||
它直接打判题机的 `/judge`,不需要起后端、不需要库里有题。用的 `languageConfigs`
|
||||
就是线上那份,所以配置和镜像对不上会当场暴露。
|
||||
它走的就是线上那份 `sandbox.ts`,不需要起后端、不需要库里有题,测试点内联传。
|
||||
|
||||
**Go 那条坑记一下**(升级时发现的,升级之前就有):`GOCACHE` 指向容器的 tmpfs
|
||||
`/tmp`,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒的编译
|
||||
预算 —— 重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。Go 现在整个
|
||||
删掉了,但**以后加回任何需要编译缓存的语言,记得把编译预算放宽**。
|
||||
|
||||
判题机装好之后,后台「判题机列表」应该能看到它上线(心跳走
|
||||
`POST /api/judge-server/heartbeat`,5 秒一次)。
|
||||
判题机装好之后,后台「判题机列表」应该能看到它上线(worker 起来 5 秒内)。
|
||||
+10
-25
@@ -7,9 +7,8 @@
|
||||
# docker/judge/build.sh --no-cache # 不吃构建缓存
|
||||
# docker/judge/build.sh --no-mirror # 不走国内镜像源(默认走)
|
||||
#
|
||||
# 上游 JudgeServer 停更在 2024-04-05,registry 上的 1.6.1 == latest,没有新版
|
||||
# 可拉。这个脚本从上游那个固定 commit 拉源码(server/ 和 Judger/ 一行不改),
|
||||
# 只把 Dockerfile 换成 docker/judge/Dockerfile —— 新工具链的全部改动都在那里。
|
||||
# 镜像 = go-judge 官方镜像里的那个二进制 + debian 的 gcc / python。构建上下文就是
|
||||
# docker/judge/ 这个目录(Dockerfile + mount.yaml)。
|
||||
#
|
||||
# 上传和切换见 docker/judge/README.md。
|
||||
|
||||
@@ -18,15 +17,11 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
|
||||
|
||||
# 上游 master HEAD(= tag v1.6.1+judgeserver.1.6.1)。Judger 子模块的版本由这个
|
||||
# commit 自己钉住(d19a6dc),不用在这里再写一遍。
|
||||
UPSTREAM_REPO=https://github.com/QingdaoU/JudgeServer.git
|
||||
UPSTREAM_COMMIT=b28aa56d60fed7358a29d9bdeb9d86fcc06e41a7
|
||||
|
||||
# 我们自己重编的第几版判题镜像(官方那个算第 1 版,这是第 2 版)。
|
||||
# **改工具链就把末尾的序号 +1**(下一版叫 oj2-judge-3),别在同一个名字上重建 ——
|
||||
# 我们自己构建的第几版判题镜像(官方 JudgeServer 算第 1 版,自己重编的 JudgeServer
|
||||
# 是第 2 版,换成 go-judge 是第 3 版)。
|
||||
# **改工具链就把末尾的序号 +1**(下一版叫 oj2-judge-4),别在同一个名字上重建 ——
|
||||
# compose 的 `up -d` 不带 --pull,名字没变就会静默用机器上的旧镜像。
|
||||
IMAGE=oj2-judge-2
|
||||
IMAGE=oj2-judge-3
|
||||
|
||||
SAVE=0
|
||||
BUILD_ARGS=()
|
||||
@@ -34,13 +29,12 @@ for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--save) SAVE=1 ;;
|
||||
--no-cache) BUILD_ARGS+=(--no-cache) ;;
|
||||
# 默认用国内镜像源(Dockerfile 顶部的四个 ARG)。在能直连的网络里用这个关掉,
|
||||
# 换回 deb.debian.org / pypi.org。
|
||||
# 默认用国内镜像源(Dockerfile 顶部的 ARG)。在能直连的网络里用这个关掉,
|
||||
# 换回 deb.debian.org。
|
||||
--no-mirror)
|
||||
BUILD_ARGS+=(
|
||||
--build-arg APT_MIRROR=http://deb.debian.org/debian
|
||||
--build-arg APT_SECURITY_MIRROR=http://deb.debian.org/debian-security
|
||||
--build-arg PIP_INDEX_URL=https://pypi.org/simple
|
||||
) ;;
|
||||
*) echo "未知参数:$arg(可用:--save、--no-cache、--no-mirror)" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -53,18 +47,8 @@ die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
|
||||
command -v docker >/dev/null || die "没装 docker"
|
||||
[ -f docker/judge/Dockerfile ] || die "docker/judge/Dockerfile 不见了,当前目录:$PWD"
|
||||
|
||||
src=$(mktemp -d)
|
||||
trap 'rm -rf "$src"' EXIT
|
||||
|
||||
say "拉上游源码 $UPSTREAM_COMMIT"
|
||||
git -c advice.detachedHead=false clone --quiet "$UPSTREAM_REPO" "$src"
|
||||
git -C "$src" -c advice.detachedHead=false checkout --quiet "$UPSTREAM_COMMIT"
|
||||
git -C "$src" submodule update --quiet --init --recursive
|
||||
ok "server/ 和 Judger/ 就位"
|
||||
|
||||
say "构建 $IMAGE"
|
||||
# 上下文是上游源码,Dockerfile 用我们自己的那份。
|
||||
docker build "${BUILD_ARGS[@]}" -f docker/judge/Dockerfile -t "$IMAGE" "$src"
|
||||
docker build "${BUILD_ARGS[@]}" -t "$IMAGE" docker/judge
|
||||
ok "$IMAGE"
|
||||
|
||||
say "镜像里的工具链"
|
||||
@@ -72,6 +56,7 @@ docker run --rm --entrypoint sh "$IMAGE" -c '
|
||||
printf "gcc %s\n" "$(gcc -dumpfullversion)"
|
||||
printf "g++ %s\n" "$(g++ -dumpfullversion)"
|
||||
printf "python3 %s\n" "$(python3 -V | cut -d" " -f2)"
|
||||
printf "go-judge %s\n" "$(go-judge -version | head -1)"
|
||||
'
|
||||
|
||||
if [ "$SAVE" = 1 ]; then
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# go-judge 沙箱里的文件系统:学生程序和编译器只看得到这些。
|
||||
# 照 go-judge 自带的 mount.yaml 删减的:只留 gcc / g++ / python3 要的,
|
||||
# fpc、mono、ghc、java 那几条(还有 MPI 的 /sys、/dev/shm)都去掉了。
|
||||
mount:
|
||||
# 编译器、解释器和它们的库
|
||||
- type: bind
|
||||
source: /bin
|
||||
target: /bin
|
||||
readonly: true
|
||||
- type: bind
|
||||
source: /lib
|
||||
target: /lib
|
||||
readonly: true
|
||||
- type: bind
|
||||
source: /lib64
|
||||
target: /lib64
|
||||
readonly: true
|
||||
- type: bind
|
||||
source: /usr
|
||||
target: /usr
|
||||
readonly: true
|
||||
- type: bind
|
||||
source: /etc/ld.so.cache
|
||||
target: /etc/ld.so.cache
|
||||
readonly: true
|
||||
# /usr/bin/gcc → /etc/alternatives/gcc → /usr/bin/gcc-14,少了这条编译器就找不到
|
||||
- type: bind
|
||||
source: /etc/alternatives
|
||||
target: /etc/alternatives
|
||||
readonly: true
|
||||
- type: bind
|
||||
source: /dev/null
|
||||
target: /dev/null
|
||||
- type: bind
|
||||
source: /dev/urandom
|
||||
target: /dev/urandom
|
||||
- type: bind
|
||||
source: /dev/random
|
||||
target: /dev/random
|
||||
- type: bind
|
||||
source: /dev/zero
|
||||
target: /dev/zero
|
||||
- type: bind
|
||||
source: /dev/full
|
||||
target: /dev/full
|
||||
# 工作目录:源码、编译产物、程序输出都在这里。输出上限 16MB(-output-limit),
|
||||
# 加上可执行文件,128MB 绰绰有余
|
||||
- type: tmpfs
|
||||
target: /w
|
||||
data: size=128m,nr_inodes=4k
|
||||
# g++ 的中间文件写在 /tmp
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
data: size=128m,nr_inodes=4k
|
||||
proc: true
|
||||
symLink:
|
||||
- linkPath: /dev/fd
|
||||
target: /proc/self/fd
|
||||
- linkPath: /dev/stdin
|
||||
target: /proc/self/fd/0
|
||||
- linkPath: /dev/stdout
|
||||
target: /proc/self/fd/1
|
||||
- linkPath: /dev/stderr
|
||||
target: /proc/self/fd/2
|
||||
maskPath:
|
||||
- /sys/firmware
|
||||
- /sys/devices/virtual/powercap
|
||||
- /proc/acpi
|
||||
- /proc/asound
|
||||
- /proc/kcore
|
||||
- /proc/keys
|
||||
- /proc/latency_stats
|
||||
- /proc/timer_list
|
||||
- /proc/timer_stats
|
||||
- /proc/sched_debug
|
||||
- /proc/scsi
|
||||
workDir: /w
|
||||
hostName: go-judge
|
||||
domainName: go-judge
|
||||
uid: 1536
|
||||
gid: 1536
|
||||
+55
-49
@@ -4,27 +4,24 @@
|
||||
* bun docker/judge/smoke.ts # 打 .env 里的 JUDGE_SERVER_URL
|
||||
* JUDGE_SERVER_URL=http://localhost:8082 bun docker/judge/smoke.ts
|
||||
*
|
||||
* 直接打判题机的 /judge,不经过 api / worker / 队列,所以不需要起后端,也不需要
|
||||
* 库里有题 —— 测试点用 `test_case` 内联传进去,判题机会当场写进临时目录。
|
||||
* 走的是线上那份判题代码(apps/api/src/judge/sandbox.ts 的 judge()),不经过 api / worker /
|
||||
* 队列,所以不需要起后端,也不需要库里有题 —— 测试点内联传进去。
|
||||
*
|
||||
* 它核的是三件事:
|
||||
* 它核的是四件事:
|
||||
* 1. 三种语言都能编译、运行、判对(languageConfigs 就是线上那份,不是另抄的)
|
||||
* 2. 六种结果状态的**整数值**没变(落库的值,见 packages/contract/src/judge-status.ts)
|
||||
* 3. gcc 的宽松度没变 —— 忘了 #include 的老代码照样能过(gcc-14 默认会把它判 CE)
|
||||
* 4. 从 JudgeServer 照搬过来的几条口径:stderr 算进输出、死循环打印判运行时错误(信号 25)
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { JudgeStatus } from '@oj2/contract'
|
||||
|
||||
import { languageConfigs } from '../../apps/api/src/judge/languages'
|
||||
import { judge, sandboxVersion, type JudgeCase } from '../../apps/api/src/judge/sandbox'
|
||||
|
||||
const url = process.env.JUDGE_SERVER_URL ?? 'http://localhost:8081'
|
||||
const rawToken = process.env.JUDGE_SERVER_TOKEN
|
||||
if (!rawToken) {
|
||||
if (!process.env.JUDGE_SERVER_TOKEN) {
|
||||
console.error('JUDGE_SERVER_TOKEN 没设 —— 在 OJ2 根目录跑,bun 会自己读 .env')
|
||||
process.exit(2)
|
||||
}
|
||||
const token = createHash('sha256').update(rawToken).digest('hex')
|
||||
|
||||
interface Case {
|
||||
language: string
|
||||
@@ -34,6 +31,8 @@ interface Case {
|
||||
/** 默认 3000ms / 128MB,跑得慢或要撑爆内存的用例自己改 */
|
||||
cpu?: number
|
||||
memory?: number
|
||||
/** 再核一下第一个测试点的信号 */
|
||||
signal?: number
|
||||
}
|
||||
|
||||
const sumTestCase = [{ input: '1 2\n', output: '3\n' }]
|
||||
@@ -114,12 +113,12 @@ int main(void) {
|
||||
}`,
|
||||
},
|
||||
{
|
||||
// RLIMIT_AS 是 max_memory 的两倍,所以 malloc 会先成功一阵子再失败,
|
||||
// 退出时 ru_maxrss 已经超过 max_memory → judger 判 MLE 而不是 RE。
|
||||
// 地址空间限在内存限制的两倍,所以 malloc 会先成功一阵子再失败,
|
||||
// 退出时峰值已经超过内存限制 → 判 MLE 而不是 RE(sandbox.ts 的 verdict)。
|
||||
language: 'C',
|
||||
name: 'C 内存超限',
|
||||
expect: JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||
memory: 64 * 1024 * 1024,
|
||||
memory: 64,
|
||||
code: `#include <stdlib.h>
|
||||
#include <string.h>
|
||||
int main(void) {
|
||||
@@ -134,10 +133,36 @@ int main(void) {
|
||||
language: 'C',
|
||||
name: 'C 运行时错误',
|
||||
expect: JudgeStatus.RUNTIME_ERROR,
|
||||
signal: 11,
|
||||
code: `int main(void) {
|
||||
int *p = 0;
|
||||
*p = 1;
|
||||
return 0;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
// 写满 16MB 吃 SIGXFSZ。前端按信号 25 给「循环停不下来」的说明
|
||||
language: 'C',
|
||||
name: 'C 死循环打印判运行时错误',
|
||||
expect: JudgeStatus.RUNTIME_ERROR,
|
||||
signal: 25,
|
||||
code: `#include <stdio.h>
|
||||
int main(void) {
|
||||
for (;;) printf("hello world\\n");
|
||||
}`,
|
||||
},
|
||||
{
|
||||
// JudgeServer 把 stdout 和 stderr 写进同一个文件再比,往 stderr 打东西就是答案错误
|
||||
language: 'C',
|
||||
name: 'C 往 stderr 打的也算输出',
|
||||
expect: JudgeStatus.WRONG_ANSWER,
|
||||
code: `#include <stdio.h>
|
||||
int main(void) {
|
||||
int a, b;
|
||||
scanf("%d %d", &a, &b);
|
||||
fprintf(stderr, "debug\\n");
|
||||
printf("%d\\n", a + b);
|
||||
return 0;
|
||||
}`,
|
||||
},
|
||||
// ---------------------------------------------------------------- Python
|
||||
@@ -168,6 +193,14 @@ print(a + b)`,
|
||||
expect: JudgeStatus.RUNTIME_ERROR,
|
||||
code: `print(1 / 0)`,
|
||||
},
|
||||
{
|
||||
// 学生程序开不了进程(procLimit 1),subprocess 抛 OSError
|
||||
language: 'Python',
|
||||
name: 'Python 开不了子进程',
|
||||
expect: JudgeStatus.RUNTIME_ERROR,
|
||||
code: `import subprocess
|
||||
subprocess.run(['true'])`,
|
||||
},
|
||||
// ---------------------------------------------------------------- C++
|
||||
{
|
||||
language: 'C++',
|
||||
@@ -202,51 +235,22 @@ const names: Record<number, string> = {
|
||||
const label = (code: number) => `${names[code] ?? '?'}(${code})`
|
||||
|
||||
async function runCase(item: Case) {
|
||||
const response = await fetch(new URL('/judge', url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'X-Judge-Server-Token': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language_config: languageConfigs[item.language],
|
||||
src: item.code,
|
||||
max_cpu_time: item.cpu ?? 3000,
|
||||
max_memory: item.memory ?? 128 * 1024 * 1024,
|
||||
test_case: sumTestCase,
|
||||
output: false,
|
||||
io_mode: {
|
||||
io_mode: 'Standard IO',
|
||||
input: 'input.txt',
|
||||
output: 'output.txt',
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
|
||||
const body = (await response.json()) as {
|
||||
err: string | null
|
||||
data: unknown
|
||||
}
|
||||
const body = await judge(item.language, item.code, item.cpu ?? 3000, item.memory ?? 128, sumTestCase)
|
||||
// 编译失败走 err 通道,不会有逐测试点的结果
|
||||
if (body.err === 'CompileError') return { result: JudgeStatus.COMPILE_ERROR }
|
||||
if (body.err) throw new Error(`${body.err}: ${JSON.stringify(body.data)}`)
|
||||
|
||||
const results = body.data as { result: number; cpu_time: number }[]
|
||||
const results = body.data as JudgeCase[]
|
||||
// 多个测试点取最坏的那个,和 run.ts 的口径一致
|
||||
const failed = results.find((r) => r.result !== JudgeStatus.ACCEPTED)
|
||||
return failed ?? results[0]!
|
||||
}
|
||||
|
||||
/** 判题机刚重建时 gunicorn 还没起来,先等它 —— 否则整屏都是连接被关。 */
|
||||
/** 判题机刚重建时还没起来,先等它 —— 否则整屏都是连接被拒。 */
|
||||
async function waitReady() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const response = await fetch(new URL('/ping', url), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Judge-Server-Token': token },
|
||||
})
|
||||
if (response.ok) return
|
||||
return await sandboxVersion()
|
||||
} catch {
|
||||
// 还没起来,接着等
|
||||
}
|
||||
@@ -256,18 +260,20 @@ async function waitReady() {
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
await waitReady()
|
||||
const version = await waitReady()
|
||||
|
||||
let failures = 0
|
||||
console.log(`判题机 ${url}\n`)
|
||||
console.log(`判题机 ${url}(go-judge ${version})\n`)
|
||||
for (const item of cases) {
|
||||
try {
|
||||
const got = await runCase(item)
|
||||
const pass = got.result === item.expect
|
||||
const pass = got.result === item.expect && (item.signal === undefined || ('signal' in got && got.signal === item.signal))
|
||||
if (!pass) failures++
|
||||
const time = 'cpu_time' in got ? ` ${got.cpu_time}ms` : ''
|
||||
const signal = item.signal !== undefined && 'signal' in got ? ` 信号 ${got.signal}` : ''
|
||||
console.log(
|
||||
`${pass ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} ${item.name.padEnd(32)}` + ` 期望 ${label(item.expect).padEnd(10)} 实得 ${label(got.result)}${time}`,
|
||||
`${pass ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} ${item.name.padEnd(32)}` +
|
||||
` 期望 ${label(item.expect).padEnd(10)} 实得 ${label(got.result)}${signal}${time}`,
|
||||
)
|
||||
} catch (error) {
|
||||
failures++
|
||||
|
||||
+7
-6
@@ -84,23 +84,24 @@ docker/deploy.sh --no-build # 只改了 env / compose 时跳过构建
|
||||
代码怎么上到服务器不归它管(rsync 命令在脚本头部注释里)。
|
||||
|
||||
脚本起栈前有一串自检,**每一条都是真撞到过的**:`DATA_DIR` 有没有生效、数据目录是不是
|
||||
一个 18 的库、判题机运行目录、判题镜像在不在。起完再跑四条冒烟,**题目数是 0 也中止**
|
||||
一个 18 的库、判题镜像在不在。起完再跑四条冒烟,**题目数是 0 也中止**
|
||||
—— 那意味着连错库了。
|
||||
|
||||
### 判题镜像是自建的,不在 registry 上
|
||||
|
||||
`compose.*.yml` 里的 `oj2-judge-2` 是本机构建的(上游 JudgeServer 停更在
|
||||
2024-04,官方镜像的编译器停在 gcc-13)。**新机器或换镜像之后,先把镜像 load 进去
|
||||
再部署**:
|
||||
`compose.*.yml` 里的 `oj2-judge-3` 是本机构建的(go-judge 的二进制 + 我们要的 gcc /
|
||||
python)。**新机器或换镜像之后,先把镜像 load 进去再部署**:
|
||||
|
||||
```bash
|
||||
# 本机
|
||||
docker/judge/build.sh --save
|
||||
scp dist/oj2-judge-2.tar root@服务器:/root/OJDeploy/
|
||||
scp dist/oj2-judge-3.tar root@服务器:/root/OJDeploy/
|
||||
# 服务器 / 机房各来一次(两个站点各有各的判题沙箱)
|
||||
docker load -i /root/OJDeploy/oj2-judge-2.tar
|
||||
docker load -i /root/OJDeploy/oj2-judge-3.tar
|
||||
```
|
||||
|
||||
判题机容器是 `privileged` 的(go-judge 要自己建 cgroup),理由见 `docker/judge/README.md`。
|
||||
|
||||
忘了这一步,`deploy.sh` 起栈时会去 pull 一个不存在的镜像并失败(响亮地失败,
|
||||
不会静默降级)。构建、回滚和工具链版本表见 `docker/judge/README.md`。
|
||||
|
||||
|
||||
Reference in new issue
Block a user