From 20a1542bc3b118c764f05aa5846a06d655d01658 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 5 Apr 2022 21:22:57 +0800 Subject: [PATCH] judge: add type: submit_answer support --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/cases.ts | 1 - packages/hydrojudge/src/judge/index.ts | 3 +- packages/hydrojudge/src/judge/objective.ts | 39 ++++ .../hydrojudge/src/judge/submit_answer.ts | 187 ++++++++++++++---- packages/hydrooj/package.json | 1 + packages/hydrooj/src/handler/problem.ts | 19 +- packages/utils/lib/cases.ts | 2 +- packages/utils/package.json | 2 +- 9 files changed, 210 insertions(+), 46 deletions(-) create mode 100644 packages/hydrojudge/src/judge/objective.ts diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6a40e07e3..7ea97cd42 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.16.0", + "version": "2.17.0", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 52a030d2a..9c71ed897 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -185,7 +185,6 @@ async function readAutoCases(folder: string, { next }, cfg, rst) { } function isValidConfig(config) { - if (config.type === 'submit_answer' && !config.outputs.length) throw new FormatError('Problem data not found.'); if (config.count > (getConfig('testcases_max') || 100)) { throw new FormatError('Too many testcases. Cancelled.'); } diff --git a/packages/hydrojudge/src/judge/index.ts b/packages/hydrojudge/src/judge/index.ts index ab95671f7..fc4cc2b4a 100644 --- a/packages/hydrojudge/src/judge/index.ts +++ b/packages/hydrojudge/src/judge/index.ts @@ -1,9 +1,10 @@ import * as def from './default'; import * as interactive from './interactive'; import { Context } from './interface'; +import * as objective from './objective'; import * as run from './run'; import * as submit_answer from './submit_answer'; export = { - default: def, interactive, run, submit_answer, objective: submit_answer, + default: def, interactive, run, submit_answer, objective, } as Record): Promise }>; diff --git a/packages/hydrojudge/src/judge/objective.ts b/packages/hydrojudge/src/judge/objective.ts new file mode 100644 index 000000000..278c1b538 --- /dev/null +++ b/packages/hydrojudge/src/judge/objective.ts @@ -0,0 +1,39 @@ +import { readFile } from 'fs-extra'; +import { STATUS } from '@hydrooj/utils/lib/status'; +import { Context } from './interface'; + +export async function judge({ + next, end, config, code, +}: Context) { + next({ status: STATUS.STATUS_JUDGING, progress: 0 }); + const answer = ('src' in code) + ? await readFile(code.src, 'utf-8') + : ('content' in code) + ? code.content.toString().replace(/\r/g, '') + : ''; + const outputs = answer.split('\n'); + let totalScore = 0; + let totalStatus = 0; + for (const i in config.outputs) { + const c = config.outputs[i]; + outputs[i] = outputs[i] || ''; + let status = STATUS.STATUS_WRONG_ANSWER; + let score = 0; + if (outputs[i].trim() === (c.output || c[0]).trim()) { + score = c.score || c[1]; + status = STATUS.STATUS_ACCEPTED; + } + totalScore += score; + totalStatus = Math.max(status, totalStatus); + next({ + status: totalStatus, + progress: (100 * (+i + 1)) / config.outputs.length, + case: { + status, score, time_ms: 0, memory_kb: 0, message: '', + }, + }, +i + 1); + } + return end({ + status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0, + }); +} diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index 4399788fc..15d21a636 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -1,41 +1,156 @@ -import { readFile } from 'fs-extra'; +import Queue from 'p-queue'; import { STATUS } from '@hydrooj/utils/lib/status'; -import { Context } from './interface'; +import { check, compileChecker } from '../check'; +import { getConfig } from '../config'; +import { FormatError } from '../error'; +import { del, run } from '../sandbox'; +import { parseFilename } from '../utils'; +import { + Case, Context, ContextSubTask, SubTask, +} from './interface'; -// TODO drop this format in next major version -// NOT RECOMMENDED TO USE -export async function judge({ - next, end, config, code, -}: Context) { - next({ status: STATUS.STATUS_JUDGING, progress: 0 }); - const answer = ('src' in code) - ? await readFile(code.src, 'utf-8') - : ('content' in code) - ? code.content.toString().replace(/\r/g, '') - : ''; - const outputs = answer.split('\n'); - let totalScore = 0; - let totalStatus = 0; - for (const i in config.outputs) { - const c = config.outputs[i]; - outputs[i] = outputs[i] || ''; - let status = STATUS.STATUS_WRONG_ANSWER; - let score = 0; - if (outputs[i].trim() === (c.output || c[0]).trim()) { - score = c.score || c[1]; - status = STATUS.STATUS_ACCEPTED; +const Score = { + sum: (a: number, b: number) => (a + b), + max: Math.max, + min: Math.min, +}; + +function judgeCase(c: Case, sid: string) { + return async (ctx: Context, ctxSubtask: ContextSubTask) => { + if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min') + || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score) + || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length) + ) { + ctx.next({ + case: { + status: STATUS.STATUS_CANCELED, + score: 0, + time_ms: 0, + memory_kb: 0, + message: '', + }, + addProgress: 100 / ctx.config.count, + }, c.id); + return; } - totalScore += score; - totalStatus = Math.max(status, totalStatus); - next({ - status: totalStatus, - progress: (100 * (+i + 1)) / config.outputs.length, - case: { - status, score, time_ms: 0, memory_kb: 0, message: '', + + const res = await run( + ctx.config.subType === 'multi' + ? '/bin/bash -c "unzip foo.zip >/dev/null && read line && cat $line"' + : '/bin/cat foo.zip', + { + stdin: c.input, + copyIn: { 'foo.zip': ctx.code }, + copyOutCached: [], + time: 1000, + memory: 128, + cacheStdoutAndStderr: true, }, - }, +i + 1); - } - return end({ - status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0, - }); + ); + const { code } = res; + let { status } = res; + let message: any = ''; + let score = 0; + if (status === STATUS.STATUS_ACCEPTED) { + [status, score, message] = await check({ + execute: ctx.checker.execute, + copyIn: ctx.checker.copyIn || {}, + input: { src: c.input }, + output: { src: c.output }, + user_stdout: { fileId: res.fileIds['stdout'] }, + user_stderr: { fileId: res.fileIds['stderr'] }, + checker_type: ctx.config.checker_type, + score: ctxSubtask.subtask.score, + detail: ctx.config.detail ?? true, + env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, + }); + } else if (status === STATUS.STATUS_RUNTIME_ERROR && code) { + message = { message: 'Unzip failed or file not found.' }; + } + await Promise.all( + Object.values(res.fileIds).map((id) => del(id)), + ).catch(() => { /* Ignore file doesn't exist */ }); + ctxSubtask.score = Score[ctxSubtask.subtask.type](ctxSubtask.score, score); + ctxSubtask.status = Math.max(ctxSubtask.status, status); + if (ctxSubtask.status > STATUS.STATUS_ACCEPTED) ctx.failed[sid] = true; + ctx.next({ + case: { + status, + score: 0, + time_ms: 0, + memory_kb: 0, + message, + }, + addProgress: 100 / ctx.config.count, + }, c.id); + }; } + +function judgeSubtask(subtask: SubTask, sid: string) { + return async (ctx: Context) => { + subtask.type = subtask.type || 'min'; + const ctxSubtask = { + subtask, + status: 0, + score: subtask.type === 'min' + ? subtask.score + : 0, + }; + const cases = []; + for (const cid in subtask.cases) { + const runner = judgeCase(subtask.cases[cid], sid); + cases.push(ctx.queue.add(() => runner(ctx, ctxSubtask))); + } + await Promise.all(cases).catch((e) => { + ctx.errored = true; + throw e; + }); + ctx.total_status = Math.max(ctx.total_status, ctxSubtask.status); + return ctxSubtask.score; + }; +} + +export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { + if (!ctx.config.subtasks.length) throw new FormatError('Problem data not found.'); + startPromise.then(() => ctx.next({ status: STATUS.STATUS_COMPILING })); + ctx.checker = await (() => { + if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) { + return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; + } + const copyIn = { user_code: ctx.code }; + for (const file of ctx.config.judge_extra_files) { + copyIn[parseFilename(file)] = { src: file }; + } + return compileChecker( + ctx.getLang, + ctx.config.checker_type, + ctx.config.checker, + copyIn, + ); + })(); + ctx.clean.push(ctx.checker.clean); + await startPromise; + ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 }); + const tasks = []; + ctx.total_status = 0; + ctx.total_score = 0; + ctx.queue = new Queue({ concurrency: getConfig('parallelism') }); + ctx.failed = {}; + for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid)(ctx)); + const scores = await Promise.all(tasks); + for (const sid in ctx.config.subtasks) { + let effective = true; + for (const required of ctx.config.subtasks[sid].if || []) { + if (ctx.failed[required.toString()]) effective = false; + } + if (effective) ctx.total_score += scores[sid]; + } + ctx.stat.done = new Date(); + if (process.env.DEV) ctx.next({ message: JSON.stringify(ctx.stat) }); + ctx.end({ + status: ctx.total_status, + score: ctx.total_score, + time_ms: 0, + memory_kb: 0, + }); +}; diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 3dbf97ed6..349c8250c 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -26,6 +26,7 @@ "fs-extra": "^10.0.1", "graphql": "^16.3.0", "graphql-scalars": "1.17.0", + "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "koa": "^2.13.4", "koa-body": "^4.2.0", diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 5300c96f9..941de7479 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -1,5 +1,6 @@ import AdmZip from 'adm-zip'; -import { statSync } from 'fs-extra'; +import { readFile, statSync } from 'fs-extra'; +import { isBinaryFile } from 'isbinaryfile'; import { intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; @@ -481,10 +482,18 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { if (!code) { const file = this.request.files?.file; if (!file) throw new ValidationError('code'); - if (file.size > 128 * 1024 * 1024) throw new ValidationError('file'); - const id = nanoid(); - await storage.put(`submission/${this.user._id}/${id}`, file.path); - code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`; + const config = this.response.body.pdoc.config; + if (typeof config !== 'object' || config === null) throw new BadRequestError('Incorrect problem config'); + const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : 65535; + if (file.size > sizeLimit) throw new ValidationError('file'); + if (file.size > 65535 || await isBinaryFile(file.path, file.size)) { + const id = nanoid(); + await storage.put(`submission/${this.user._id}/${id}`, file.path); + code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`; + } else { + // TODO auto detect & convert encoding + code = await readFile(file.path, 'utf-8'); + } } else if (code.startsWith('@@hydro_submission_file@@')) throw new ValidationError('code'); await this.limitRate('add_record', 60, system.get('limit.submission')); const rid = await record.add(domainId, this.pdoc.docId, this.user._id, lang, code, true, pretest ? input : tid, tid && !pretest); diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index b01f16385..a9e841bda 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -76,7 +76,7 @@ export default async function readYamlCases(cfg: Record = {}, check } if (cfg.time) config.time = parseTimeMS(cfg.time); if (cfg.memory) config.memory = parseMemoryMB(cfg.memory); - if ((config.type === 'objective' || cfg.type === 'submit_answer') && !cfg.outputs?.length) throw new Error('outputs config not found'); + if (config.type === 'objective' && !cfg.outputs?.length) throw new Error('outputs config not found'); return Object.assign(cfg, config); } diff --git a/packages/utils/package.json b/packages/utils/package.json index f616b9f5d..e9e782ba2 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.1.16", + "version": "1.1.17", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git",