From 37e971b6b4b2b1a985d625b002c6ec63101a13e6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 26 Oct 2026 15:03:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E5=88=A4=E9=A2=98=E6=9C=BA):=20?= =?UTF-8?q?=E6=8D=A2=E6=88=90=20go-judge=EF=BC=8C=E7=A9=BA=E9=97=B2?= =?UTF-8?q?=E5=86=85=E5=AD=98=E4=BB=8E=E5=87=A0=E7=99=BE=20MB=20=E9=99=8D?= =?UTF-8?q?=E5=88=B0=2016=20MB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的 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 --- .env.example | 2 +- CLAUDE.md | 14 +- apps/api/src/config.ts | 4 + apps/api/src/index.ts | 2 - apps/api/src/judge/heartbeat.ts | 77 ++++ apps/api/src/judge/languages.ts | 88 +++-- apps/api/src/judge/run.ts | 117 +----- apps/api/src/judge/sandbox.ts | 360 ++++++++++++++++++ apps/api/src/judge/trial.ts | 42 +- apps/api/src/routes/judge-server.ts | 75 ---- apps/api/src/services/test-case.ts | 4 +- apps/api/src/worker.ts | 4 + .../src/oj/learn/components/CodeRunBlock.vue | 5 +- .../components/CompileErrorExplain.vue | 4 +- .../problem/components/SubmissionResult.vue | 6 +- .../oj/problem/components/TrialErrorNote.vue | 4 +- .../src/oj/submission/composables/explain.ts | 5 +- apps/web/src/utils/judge.ts | 9 + docker/.env.example | 4 - docker/Caddyfile | 5 +- docker/compose.debian.yml | 26 +- docker/compose.dev.yml | 30 +- docker/compose.school.yml | 25 +- docker/deploy.sh | 8 +- docker/judge/Dockerfile | 133 ++----- docker/judge/README.md | 181 +++++---- docker/judge/build.sh | 41 +- docker/judge/mount.yaml | 81 ++++ docker/judge/smoke.ts | 104 ++--- docs/deploy.md | 13 +- 30 files changed, 881 insertions(+), 592 deletions(-) create mode 100644 apps/api/src/judge/heartbeat.ts create mode 100644 apps/api/src/judge/sandbox.ts delete mode 100644 apps/api/src/routes/judge-server.ts create mode 100644 docker/judge/mount.yaml diff --git a/.env.example b/.env.example index d795880..ad64ae4 100644 --- a/.env.example +++ b/.env.example @@ -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 # 试运行(运行例子 / 自己输入)同时在判题机上跑几批,和正式提交共用判题机 diff --git a/CLAUDE.md b/CLAUDE.md index 5e76554..820a6c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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。 diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index aebd006..3eb73d5 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 35c3c06..31b3f51 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) => { diff --git a/apps/api/src/judge/heartbeat.ts b/apps/api/src/judge/heartbeat.ts new file mode 100644 index 0000000..ad78a76 --- /dev/null +++ b/apps/api/src/judge/heartbeat.ts @@ -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) +} diff --git a/apps/api/src/judge/languages.ts b/apps/api/src/judge/languages.ts index 775a3c6..3c70c35 100644 --- a/apps/api/src/judge/languages.ts +++ b/apps/api/src/judge/languages.ts @@ -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> = { +export const languageConfigs: Record = { 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'] }, }, } diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index f6087ad..ca5ea6f 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -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(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 } diff --git a/apps/api/src/judge/sandbox.ts b/apps/api/src/judge/sandbox.ts new file mode 100644 index 0000000..41b95d5 --- /dev/null +++ b/apps/api/src/judge/sandbox.ts @@ -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 + fileIds?: Record +} + +interface SandboxCase { + key: string + stdin: { src: string } | { content: string } + expectedMd5: string +} + +function headers() { + return { authorization: `Bearer ${config.judgeServerToken}` } +} + +async function runCommand(command: Record, 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 { + 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' +} diff --git a/apps/api/src/judge/trial.ts b/apps/api/src/judge/trial.ts index 043887c..ecefaa4 100644 --- a/apps/api/src/judge/trial.ts +++ b/apps/api/src/judge/trial.ts @@ -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[0]): Promise[0]): Promise { - 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[0]): Promise [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) diff --git a/apps/api/src/routes/judge-server.ts b/apps/api/src/routes/judge-server.ts deleted file mode 100644 index 435d9f4..0000000 --- a/apps/api/src/routes/judge-server.ts +++ /dev/null @@ -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) diff --git a/apps/api/src/services/test-case.ts b/apps/api/src/services/test-case.ts index 6719bf2..07a4c05 100644 --- a/apps/api/src/services/test-case.ts +++ b/apps/api/src/services/test-case.ts @@ -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 diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 0b85e7d..0f5e53b 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -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) diff --git a/apps/web/src/oj/learn/components/CodeRunBlock.vue b/apps/web/src/oj/learn/components/CodeRunBlock.vue index a384ba5..5838105 100644 --- a/apps/web/src/oj/learn/components/CodeRunBlock.vue +++ b/apps/web/src/oj/learn/components/CodeRunBlock.vue @@ -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 { diff --git a/apps/web/src/oj/problem/components/CompileErrorExplain.vue b/apps/web/src/oj/problem/components/CompileErrorExplain.vue index 2ffdf5a..38e14ca 100644 --- a/apps/web/src/oj/problem/components/CompileErrorExplain.vue +++ b/apps/web/src/oj/problem/components/CompileErrorExplain.vue @@ -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)) /** * 报错只点名第一处。别的行也有中文标点的话,一句话带过(设计稿「语法没过」: diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 8c27b26..fa48c2f 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -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 diff --git a/apps/web/src/oj/problem/components/TrialErrorNote.vue b/apps/web/src/oj/problem/components/TrialErrorNote.vue index d34c16f..02ff202 100644 --- a/apps/web/src/oj/problem/components/TrialErrorNote.vue +++ b/apps/web/src/oj/problem/components/TrialErrorNote.vue @@ -1,5 +1,6 @@