diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index 10ed367..480f817 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -5,41 +5,25 @@ WORKDIR /app RUN apt-get update RUN apt-get install curl -y - RUN apt-get install -y ca-certificates curl gnupg RUN mkdir -p /etc/apt/keyrings RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg ENV NODE_MAJOR=18 RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list RUN apt-get update -RUN apt-get install nodejs -y -RUN apt-get install git -y +RUN apt-get install nodejs git openjdk-17-jdk-headless dotnet-sdk-7.0 -y + +ENV DOTNET_NOLOGO=true +ENV DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true RUN git config --global user.name "Admin" - RUN git config --global user.email noemail@example.com -WORKDIR /opt - -RUN apt-get install wget -y - -RUN wget -O java.tar.gz https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.7%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.7_7.tar.gz - -RUN tar -xzvf java.tar.gz - -RUN rm java.tar.gz - -ENV JAVA_PATH=/opt/jdk-17.0.7+7/bin - WORKDIR /app COPY package*.json ./ - RUN npm install - COPY . . - RUN npm run build - CMD ["node", "build"] \ No newline at end of file diff --git a/sandbox/package-lock.json b/sandbox/package-lock.json index 1fd8000..e74f396 100644 --- a/sandbox/package-lock.json +++ b/sandbox/package-lock.json @@ -12,6 +12,7 @@ "dotenv": "^16.3.1", "fs-extra": "^11.2.0", "simple-git": "^3.22.0", + "tree-kill": "^1.2.2", "url-join": "^5.0.0", "zod": "^3.22.4" }, @@ -153,6 +154,14 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", diff --git a/sandbox/package.json b/sandbox/package.json index 9d2d32c..66fc0f2 100644 --- a/sandbox/package.json +++ b/sandbox/package.json @@ -21,6 +21,7 @@ "dotenv": "^16.3.1", "fs-extra": "^11.2.0", "simple-git": "^3.22.0", + "tree-kill": "^1.2.2", "url-join": "^5.0.0", "zod": "^3.22.4" } diff --git a/sandbox/src/index.ts b/sandbox/src/index.ts index 0243fc2..e6654e7 100644 --- a/sandbox/src/index.ts +++ b/sandbox/src/index.ts @@ -6,6 +6,7 @@ import os, { EOL } from 'os'; import { join } from 'path'; import { simpleGit, SimpleGit } from 'simple-git'; import { runJava } from './run/java.js'; +import { runCSharp } from './run/csharp.js'; export const timeoutSeconds = 30; @@ -39,6 +40,7 @@ const submissionGetData = z contestName: z.string(), teamId: z.number(), teamName: z.string(), + teamLanguage: z.enum(['Java', 'CSharp']), problem: z.object({ id: z.number(), pascalName: z.string(), @@ -81,7 +83,7 @@ async function cloneAndRun(submissionData: SubmissionGetData) { return; } const tmpDir = os.tmpdir(); - const buildDir = join(tmpDir, 'bwcontest_java'); + const buildDir = join(tmpDir, 'bwcontest-build'); if (fs.existsSync(buildDir)) { fs.removeSync(buildDir); } @@ -100,16 +102,32 @@ async function cloneAndRun(submissionData: SubmissionGetData) { await git.clone(teamRepoUrl, '.'); await git.checkout(submissionData.submission.commitHash); const problemName = submissionData.submission.problem.pascalName; - let runResult: RunResult; + let runResult: RunResult | undefined; try { - runResult = await runJava( - javaBinPath, - buildDir, - join(repoDir, problemName, problemName + '.java'), - problemName, - submissionData.submission.problem.realInput - ); + if (submissionData.submission.teamLanguage === 'Java') { + let res = await runJava({ + srcDir: buildDir, + mainFile: join(repoDir, problemName, problemName + '.java'), + mainClass: problemName, + input: submissionData.submission.problem.realInput + }); + if (res.success === true) { + runResult = await res.runResult; + } else { + runResult = res.runResult; + } + } else if (submissionData.submission.teamLanguage === 'CSharp') { + let res = await runCSharp({ + srcDir: join(repoDir, problemName), + input: submissionData.submission.problem.realInput + }); + if (res.success === true) { + runResult = await res.runResult; + } else { + runResult = res.runResult; + } + } } catch (error) { runResult = { kind: 'SandboxError', @@ -117,29 +135,33 @@ async function cloneAndRun(submissionData: SubmissionGetData) { }; } - printRunResult(runResult); + if (runResult !== undefined) { + printRunResult(runResult); - const postBodyObject: SubmissionPostData = { - submissionId: submissionData.submission.id, - result: runResult - }; - const res = await fetch(urlJoin(adminUrl, 'api/submission'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(postBodyObject) - }); - if (res.status !== 200) { - console.error('- POST: Failed with error code: ' + res.status + ' ' + res.statusText); - return; + const postBodyObject: SubmissionPostData = { + submissionId: submissionData.submission.id, + result: runResult + }; + const res = await fetch(urlJoin(adminUrl, 'api/submission'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postBodyObject) + }); + if (res.status !== 200) { + console.error('- POST: Failed with error code: ' + res.status + ' ' + res.statusText); + return; + } + + const data = (await res.json()) as { success: boolean }; + if (!data.success) { + console.error('- POST: Failed with response: ' + JSON.stringify(data)); + return; + } + + console.log(`- POST: Succeeded`); + } else { + console.warn(`runResult is undefined`); } - - const data = (await res.json()) as { success: boolean }; - if (!data.success) { - console.error('- POST: Failed with response: ' + JSON.stringify(data)); - return; - } - - console.log(`- POST: Succeeded`); } function printRunResult(runResult: RunResult) { @@ -163,11 +185,7 @@ function printRunResult(runResult: RunResult) { } function validateEnv(): boolean { - return ( - process.env.ADMIN_URL !== undefined && - process.env.REPO_URL !== undefined && - process.env.JAVA_PATH !== undefined - ); + return process.env.ADMIN_URL !== undefined && process.env.REPO_URL !== undefined; } dotenv.config(); diff --git a/sandbox/src/run/csharp.ts b/sandbox/src/run/csharp.ts new file mode 100644 index 0000000..657e06c --- /dev/null +++ b/sandbox/src/run/csharp.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs-extra'; +import { join } from 'path'; +import os = require('os'); +import { spawn } from 'child_process'; +import kill from 'tree-kill'; +import { RunResult, timeoutSeconds } from '../index.js'; +import { IRunner, IRunnerReturn } from './types.js'; + +export const runCSharp: IRunner = async function (params: { + srcDir: string; + input: string; + outputCallback?: (data: string) => void; +}): IRunnerReturn { + console.log(`- RUN: ${params.srcDir}`); + const child = spawn('dotnet run', { shell: true, cwd: params.srcDir }); + + let outputBuffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (data) => { + outputBuffer += data.toString(); + params.outputCallback?.(data.toString()); + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (data) => { + outputBuffer += data.toString(); + params.outputCallback?.(data.toString()); + }); + + let runStartTime = performance.now(); + child.stdin.write(params.input); + child.stdin.end(); + + let timeLimitExceeded = false; + let completedNormally = false; + + return { + success: true, + runResult: new Promise((resolve) => { + child.on('close', () => { + completedNormally = !timeLimitExceeded; + + let runEndTime = performance.now(); + const runtimeMilliseconds = Math.floor(runEndTime - runStartTime); + + if (completedNormally) { + clearTimeout(timeoutHandle); + resolve({ + kind: 'Completed', + output: outputBuffer, + exitCode: child.exitCode!, + runtimeMilliseconds + }); + } else { + console.log(`Process terminated, total sandbox time: ${runtimeMilliseconds}ms`); + resolve({ + kind: 'TimeLimitExceeded', + output: outputBuffer, + resultKindReason: `Timeout after ${timeoutSeconds} seconds` + }); + } + }); + + let timeoutHandle = setTimeout(() => { + if (completedNormally) { + return; + } + + console.log(`Run timed out after ${timeoutSeconds} seconds, killing process...`); + timeLimitExceeded = true; + + child.stdin.end(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (child.pid !== undefined) { + kill(child.pid); + } + }, timeoutSeconds * 1000); + }), + killFunc() { + if (child.pid !== undefined) { + if (!completedNormally && !timeLimitExceeded) { + kill(child.pid); + params.outputCallback?.('\n[Manually stopped]'); + } + } + } + }; +}; diff --git a/sandbox/src/run/java.ts b/sandbox/src/run/java.ts index 7905f51..17553f9 100644 --- a/sandbox/src/run/java.ts +++ b/sandbox/src/run/java.ts @@ -1,89 +1,107 @@ import { join } from 'path'; import { exec, spawn } from 'child_process'; import util from 'util'; -import { RunResult, RunResultKind, timeoutSeconds } from '../index.js'; +import { RunResult, timeoutSeconds } from '../index.js'; +import { IRunner, IRunnerParams, IRunnerReturn } from './types.js'; +import kill from 'tree-kill'; const execPromise = util.promisify(exec); -export async function runJava( - javaBinPath: string, - buildDir: string, - mainFile: string, - mainClass: string, - input: string -): Promise { - console.log(`- BUILD: ${mainFile}`); - const compileCommand = `${join(javaBinPath, 'javac')} -cp ${join( - buildDir, - 'src' - )} ${mainFile} -d ${join(buildDir, 'build')}`; +interface IRunnerParamsJava extends IRunnerParams { + srcDir: string; + mainFile: string; + mainClass: string; + input: string; + outputCallback?: (data: string) => void; +} + +export const runJava: IRunner = async function ( + params: IRunnerParamsJava +): IRunnerReturn { + console.log(`- BUILD: ${params.mainFile}`); + const compileCommand = `javac -cp ${join(params.srcDir, 'src')} ${params.mainFile} -d ${join(params.srcDir, 'build')}`; try { await execPromise(compileCommand); } catch (e) { const buildErrorText = e?.toString() ?? 'Unknown build errors.'; console.log('Build errors: ' + buildErrorText); - return { kind: 'CompileFailed', resultKindReason: buildErrorText }; + return { + success: false, + runResult: { kind: 'CompileFailed', resultKindReason: buildErrorText } + }; } - console.log(`- RUN: ${mainClass}`); - const runCommand = `${join(javaBinPath, 'java')} -cp "${join(buildDir, 'build')}" ${mainClass}`; - return new Promise((resolve) => { - let outputBuffer = ''; - const child = spawn(runCommand, { shell: true }); - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (data) => { - outputBuffer += data.toString(); - }); - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (data) => { - outputBuffer += data.toString(); - }); + console.log(`- RUN: ${params.mainClass}`); + const runCommand = `java -cp "${join(params.srcDir, 'build')}" ${params.mainClass}`; - let runStartTime = performance.now(); - child.stdin.write(input); - child.stdin.end(); - - let timeLimitExceeded = false; - let completedNormally = false; - - child.on('close', () => { - completedNormally = !timeLimitExceeded; - - let runEndTime = performance.now(); - const runtimeMilliseconds = Math.floor(runEndTime - runStartTime); - - if (completedNormally) { - clearTimeout(timeoutHandle); - resolve({ - kind: 'Completed', - output: outputBuffer, - exitCode: child.exitCode!, - runtimeMilliseconds - }); - } else { - console.log(`Process terminated, total sandbox time: ${runtimeMilliseconds}ms`); - resolve({ - kind: 'TimeLimitExceeded', - output: outputBuffer, - resultKindReason: `Timeout after ${timeoutSeconds} seconds` - }); - } - }); - - let timeoutHandle = setTimeout(() => { - if (completedNormally) { - return; - } - - console.log(`Run timed out after ${timeoutSeconds} seconds, killing process...`); - timeLimitExceeded = true; - - child.stdin.end(); - child.stdin.destroy(); - child.stdout.destroy(); - child.stderr.destroy(); - child.kill('SIGKILL'); - }, timeoutSeconds * 1000); + let outputBuffer = ''; + const child = spawn(runCommand, { shell: true }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (data) => { + outputBuffer += data.toString(); }); -} + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (data) => { + outputBuffer += data.toString(); + }); + + let runStartTime = performance.now(); + child.stdin.write(params.input); + child.stdin.end(); + + let timeLimitExceeded = false; + let completedNormally = false; + + return { + success: true, + runResult: new Promise(async (resolve) => { + child.on('close', () => { + completedNormally = !timeLimitExceeded; + + let runEndTime = performance.now(); + const runtimeMilliseconds = Math.floor(runEndTime - runStartTime); + + if (completedNormally) { + clearTimeout(timeoutHandle); + resolve({ + kind: 'Completed', + output: outputBuffer, + exitCode: child.exitCode!, + runtimeMilliseconds + }); + } else { + console.log(`Process terminated, total sandbox time: ${runtimeMilliseconds}ms`); + resolve({ + kind: 'TimeLimitExceeded', + output: outputBuffer, + resultKindReason: `Timeout after ${timeoutSeconds} seconds` + }); + } + }); + + let timeoutHandle = setTimeout(() => { + if (completedNormally) { + return; + } + + console.log(`Run timed out after ${timeoutSeconds} seconds, killing process...`); + timeLimitExceeded = true; + + child.stdin.end(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.kill('SIGKILL'); + }, timeoutSeconds * 1000); + }), + killFunc() { + if (child.pid !== undefined) { + if (!completedNormally && !timeLimitExceeded) { + kill(child.pid); + params.outputCallback?.('\n[Manually stopped]'); + } + } + } + }; +}; diff --git a/sandbox/src/run/types.d.ts b/sandbox/src/run/types.d.ts new file mode 100644 index 0000000..e24f300 --- /dev/null +++ b/sandbox/src/run/types.d.ts @@ -0,0 +1,16 @@ +import { RunResult } from '../index.ts'; + +interface IRunnerParams { + srcDir: string; + input: string; + outputCallback?: (data: string) => void; +} + +type IRunnerReturn = Promise< + | { success: true; killFunc: () => void; runResult: Promise } + | { success: false; runResult: RunResult } +>; + +interface IRunner { + (params: T): IRunnerReturn; +} diff --git a/web/src/routes/admin/diff/[submissionId]/+page.svelte b/web/src/routes/admin/diff/[submissionId]/+page.svelte index 53c9b77..06b09b7 100644 --- a/web/src/routes/admin/diff/[submissionId]/+page.svelte +++ b/web/src/routes/admin/diff/[submissionId]/+page.svelte @@ -7,6 +7,7 @@ import { goto } from '$app/navigation'; import { stretchTextarea } from '$lib/util'; import FormAlert from '$lib/FormAlert.svelte'; + import { theme } from '../../../stores'; export let data: PageData; export let form: Actions; @@ -72,7 +73,7 @@

Diff

-
+
Message
diff --git a/web/src/routes/admin/submissions/[submissionId]/+page.svelte b/web/src/routes/admin/submissions/[submissionId]/+page.svelte index ec45e05..0e17961 100644 --- a/web/src/routes/admin/submissions/[submissionId]/+page.svelte +++ b/web/src/routes/admin/submissions/[submissionId]/+page.svelte @@ -6,6 +6,7 @@ import { enhance } from '$app/forms'; import { stretchTextarea } from '$lib/util'; import ConfirmModal from '$lib/ConfirmModal.svelte'; + import { theme } from '../../../stores'; export let data: PageData; export let form: Actions; @@ -141,56 +142,10 @@

Output

Diff

-
+
{/if} - - diff --git a/web/src/routes/api/submission/+server.ts b/web/src/routes/api/submission/+server.ts index 8ad40af..f5317f3 100644 --- a/web/src/routes/api/submission/+server.ts +++ b/web/src/routes/api/submission/+server.ts @@ -21,6 +21,7 @@ export const GET = (async () => { contestName: submissions[0].contest.name, teamId: submissions[0].team.id, teamName: submissions[0].team.name, + teamLanguage: submissions[0].team.language, problem: { id: submissions[0].problemId, pascalName: submissions[0].problem.pascalName,