diff --git a/doc/manual/config-advanced.rst b/doc/manual/config-advanced.rst index 6c9f5feee8..cec4ddd4d0 100644 --- a/doc/manual/config-advanced.rst +++ b/doc/manual/config-advanced.rst @@ -223,7 +223,7 @@ To allow for problems that do not fit within the standard scheme of fixed input and/or output, DOMjudge has the possibility to change the way submissions are run and checked for correctness. -The back end script ``testcase_run.sh`` that handles +The judgedaemon that handles the running and checking of submissions, calls separate programs for running submissions and comparison of the results. These can be specialised and adapted to the requirements per problem. For this, one @@ -257,8 +257,7 @@ output. The validator program should not make any assumptions on its working directory. For more details on writing and modifying a compare (or validator) -script, see the ``boolfind_cmp`` example and the comments at the -top of the file ``testcase_run.sh``. +script, see the ``boolfind_cmp`` example. Run programs ------------ diff --git a/etc/judgehost-static.php.in b/etc/judgehost-static.php.in index 602b81445c..bc15463e30 100644 --- a/etc/judgehost-static.php.in +++ b/etc/judgehost-static.php.in @@ -20,7 +20,7 @@ define('CHROOTDIR', '@judgehost_chrootdir@'); define('RUNUSER', '@RUNUSER@'); define('RUNGROUP', '@RUNGROUP@'); -// Possible exitcodes from testcase_run.sh and their meaning. +// Possible exitcodes from compile scripts and their meaning. $EXITCODES = array ( 0 => 'correct', 101 => 'compiler-error', diff --git a/example_problems/hello/submissions/no_output/test-timelimit-bug.c b/example_problems/hello/submissions/no_output/test-timelimit-bug.c index 8acb76479b..3104f701b9 100644 --- a/example_problems/hello/submissions/no_output/test-timelimit-bug.c +++ b/example_problems/hello/submissions/no_output/test-timelimit-bug.c @@ -3,7 +3,7 @@ * This is issue #122 and fixed now, see old description below. * * The reason for TIMELIMIT was that program and runguard stderr are - * mixed and searched by testcase_run.sh for the string 'timelimit exceeded'. + * mixed and searched by judgedaemon for the string 'timelimit exceeded'. * This a minor bug that doesn't provide a team any advantages. It * could be fixed by having runguard write the submission stderr to a * separate file. diff --git a/example_problems/hello/submissions/time_limit_exceeded/stress-test-fork-setsid.c b/example_problems/hello/submissions/time_limit_exceeded/stress-test-fork-setsid.c index d54f3ec859..9aa31bc726 100644 --- a/example_problems/hello/submissions/time_limit_exceeded/stress-test-fork-setsid.c +++ b/example_problems/hello/submissions/time_limit_exceeded/stress-test-fork-setsid.c @@ -3,7 +3,7 @@ * * Without cgroups however, this will crash the judging daemon: it * forks processes and places these in a new session, such that - * testcase_run cannot retrace and kill these. They are left running + * the judgedaemon cannot retrace and kill these. They are left running * and should be killed before restarting the judging daemon. The * cgroups code can detect this because the processes will belong to the * same cgroup. diff --git a/example_problems/hello/submissions/time_limit_exceeded/test-fork.c b/example_problems/hello/submissions/time_limit_exceeded/test-fork.c index 574ee9b64c..e4946a2e88 100644 --- a/example_problems/hello/submissions/time_limit_exceeded/test-fork.c +++ b/example_problems/hello/submissions/time_limit_exceeded/test-fork.c @@ -4,7 +4,7 @@ * timeout. * * The result should be a TIMELIMIT and the running forked programs - * killed by testcase_run. + * killed by the judgedaemon. * * @EXPECTED_RESULTS@: TIMELIMIT */ diff --git a/judge/Makefile b/judge/Makefile index 74b756a6b8..e770042068 100644 --- a/judge/Makefile +++ b/judge/Makefile @@ -24,7 +24,7 @@ runpipe: runpipe.cc $(LIBOBJECTS) install-judgehost: $(INSTALL_PROG) -t $(DESTDIR)$(judgehost_libjudgedir) \ - compile.sh build_executable.sh testcase_run.sh chroot-startstop.sh \ + compile.sh build_executable.sh chroot-startstop.sh \ check_diff.sh evict version_check.sh $(INSTALL_DATA) -t $(DESTDIR)$(judgehost_libjudgedir) \ judgedaemon.main.php run-interactive.sh diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index 1cdf0abce3..1954d56735 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -18,6 +18,19 @@ define('DONT_CARE', new class {}); +enum Verdict +{ + case CORRECT; + case COMPILER_ERROR; + case TIMELIMIT; + case RUN_ERROR; + case NO_OUTPUT; + case WRONG_ANSWER; + case OUTPUT_LIMIT; + case COMPARE_ERROR; + case INTERNAL_ERROR; +} + class JudgeDaemon { private static ?JudgeDaemon $instance = null; @@ -273,9 +286,11 @@ private function loop(): void // indicating that we didn't find any child to be // reaped if ($errno != 10) { - logmsg(LOG_WARNING, + logmsg( + LOG_WARNING, "pcntl_waitpid returned $ret when trying to reap child processes: " - . pcntl_strerror($errno)); + . pcntl_strerror($errno) + ); } } @@ -322,7 +337,7 @@ private function loop(): void $judgehosts = $this->request('judgehosts', 'GET'); if ($judgehosts !== null) { $judgehosts = dj_json_decode($judgehosts); - $judgehost = array_values(array_filter($judgehosts, fn($j) => $j['hostname'] === $this->myhost))[0]; + $judgehost = array_values(array_filter($judgehosts, fn ($j) => $j['hostname'] === $this->myhost))[0]; if (!isset($judgehost['enabled']) || !$judgehost['enabled']) { logmsg(LOG_WARNING, "Judgehost needs to be enabled in web interface."); } @@ -467,8 +482,11 @@ private function handleDebugInfoTask(array $row, ?string &$lastWorkdir, string $ } $this->request( - sprintf('judgehosts/add-debug-info/%s/%s', urlencode($this->myhost), - urlencode((string)$judgeTask['judgetaskid'])), + sprintf( + 'judgehosts/add-debug-info/%s/%s', + urlencode($this->myhost), + urlencode((string)$judgeTask['judgetaskid']) + ), 'POST', ['full_debug' => $this->restEncodeFile($tmpfile, false)], false @@ -480,8 +498,11 @@ private function handleDebugInfoTask(array $row, ?string &$lastWorkdir, string $ // Retrieving full team output for a particular testcase. $testcasedir = $workdir . "/testcase" . sprintf('%05d', $judgeTask['testcase_id']); $this->request( - sprintf('judgehosts/add-debug-info/%s/%s', urlencode($this->myhost), - urlencode((string)$judgeTask['judgetaskid'])), + sprintf( + 'judgehosts/add-debug-info/%s/%s', + urlencode($this->myhost), + urlencode((string)$judgeTask['judgetaskid']) + ), 'POST', ['output_run' => $this->restEncodeFile($testcasedir . '/program.out', false)], false @@ -532,8 +553,10 @@ private function handleTask(string $type, array $row, ?string &$lastWorkdir, str } $this->endpoint['retrying'] = false; - logmsg(LOG_INFO, - "⇝ Received " . sizeof($row) . " '" . $type . "' judge tasks (endpoint " . $this->endpoint['id'] . ")"); + logmsg( + LOG_INFO, + "⇝ Received " . sizeof($row) . " '" . $type . "' judge tasks (endpoint " . $this->endpoint['id'] . ")" + ); if ($type == 'prefetch') { $this->handlePrefetchTask($row, $lastWorkdir, $workdirpath); @@ -574,10 +597,12 @@ private function checkDiskSpace(string $workdirpath): void $candidateDirs[] = $workdirpath . "/" . $subdir; } } - uasort($candidateDirs, fn($a, $b) => filemtime($a) <=> filemtime($b)); + uasort($candidateDirs, fn ($a, $b) => filemtime($a) <=> filemtime($b)); $after = $before = disk_free_space(JUDGEDIR); - logmsg(LOG_INFO, - "🗑 Low on diskspace, cleaning up (" . count($candidateDirs) . " potential candidates)."); + logmsg( + LOG_INFO, + "🗑 Low on diskspace, cleaning up (" . count($candidateDirs) . " potential candidates)." + ); $cnt = 0; foreach ($candidateDirs as $d) { $cnt++; @@ -590,7 +615,9 @@ private function checkDiskSpace(string $workdirpath): void break; } } - logmsg(LOG_INFO, "🗑 Cleaned up $cnt old judging directories; reduced disk space by " . + logmsg( + LOG_INFO, + "🗑 Cleaned up $cnt old judging directories; reduced disk space by " . sprintf("%01.2fMB.", ($after - $before) / (1024 * 1024)) ); } @@ -839,7 +866,7 @@ private function readJudgehostLog(int $numLines = 20): string return trim(ob_get_clean()); } - private function runCommandSafe(array $command_parts, &$retval = DONT_CARE, $log_nonzero_exitcode = true): bool + private function runCommandSafe(array $command_parts, &$retval = DONT_CARE, $log_nonzero_exitcode = true, $stdin_source = null, $stdout_target = null, $stderr_target = null): bool { if (empty($command_parts)) { logmsg(LOG_WARNING, "Need at least the command that should be called."); @@ -848,10 +875,23 @@ private function runCommandSafe(array $command_parts, &$retval = DONT_CARE, $log } $command = implode(' ', array_map('dj_escapeshellarg', $command_parts)); + if ($stdin_source !== null) { + $command .= ' < ' . dj_escapeshellarg($stdin_source); + } + if ($stdout_target !== null) { + $command .= ' > ' . dj_escapeshellarg($stdout_target); + } + if ($stderr_target !== null) { + $command .= ' 2> ' . dj_escapeshellarg($stderr_target); + } else { + $command .= ' 2>&1'; + } logmsg(LOG_DEBUG, "Executing command: $command"); system($command, $retval_local); - if ($retval !== DONT_CARE) $retval = $retval_local; // phpcs:ignore Generic.ControlStructures.InlineControlStructure.NotAllowed + if ($retval !== DONT_CARE) { + $retval = $retval_local; + } // phpcs:ignore Generic.ControlStructures.InlineControlStructure.NotAllowed if ($retval_local !== 0) { if ($log_nonzero_exitcode) { @@ -877,8 +917,10 @@ private function fetchExecutable( if ($buildlogpath !== null) { $extra_log = dj_file_get_contents($buildlogpath, 4096); } - logmsg(LOG_ERR, - "Fetching executable failed for $type script '$execid': " . $error); + logmsg( + LOG_ERR, + "Fetching executable failed for $type script '$execid': " . $error + ); $description = "$execid: fetch, compile, or deploy of $type script failed."; $this->disable( $type . '_script', @@ -939,11 +981,11 @@ private function fetchExecutableInternal( ]; } unset($files); - uasort($filesArray, fn(array $a, array $b) => strcmp($a['filename'], $b['filename'])); + uasort($filesArray, fn (array $a, array $b) => strcmp($a['filename'], $b['filename'])); $computedHash = md5( join( array_map( - fn($file) => $file['hash'] . $file['filename'] . $file['is_executable'], + fn ($file) => $file['hash'] . $file['filename'] . $file['is_executable'], $filesArray ) ) @@ -1388,11 +1430,6 @@ private function compileAndRunSubmission(array $judgeTask, string $workdirpath): } $output_storage_limit = (int)$this->djconfigGetValue('output_storage_limit'); - $cpuset_opt = ""; - if (isset($this->options['daemonid'])) { - $cpuset_opt = '-n ' . dj_escapeshellarg($this->options['daemonid']); - } - $workdir = $this->judgingDirectory($workdirpath, $judgeTask); $compile_success = $this->compile($judgeTask, $workdir, $workdirpath, $compile_config, $this->options['daemonid'] ?? null, $output_storage_limit); if (!$compile_success) { @@ -1422,6 +1459,423 @@ private function compileAndRunSubmission(array $judgeTask, string $workdirpath): return $this->runTestcase($judgeTask, $workdir, $workdirpath, $run_config, $compare_config, $output_storage_limit, $overshoot, $startTime); } + private function testcaseRunInternal( + $input, + $output, + $timelimit, + $passdir, + $run_runpath, + $combined_run_compare, + $compare_runpath, + $compare_args + ) : Verdict { + // Record some state so that we can properly reset it later in the finally block + $oldCwd = getcwd(); + $oldTmpDir = getenv('TMPDIR'); + $oldVerbose = getenv('VERBOSE'); + $resourceInfo = null; + $realWorkdir = null; + + try { + if (!is_readable($input)) { + logmsg(LOG_WARNING, "Test input not found at '$input'."); + return Verdict::INTERNAL_ERROR; + } + if (!is_readable($output)) { + logmsg(LOG_WARNING, "Test output not found at '$output'."); + return Verdict::INTERNAL_ERROR; + } + if (!is_dir($passdir) || !is_writable($passdir) || !is_executable($passdir)) { + logmsg(LOG_WARNING, "Pass directory '$passdir' not found or not writable/executable."); + return Verdict::INTERNAL_ERROR; + } + + $realWorkdir = realpath($passdir); + $prefix = '/' . basename(dirname($realWorkdir)) . '/' . basename($realWorkdir); + if (!chdir($realWorkdir)) { + logmsg(LOG_WARNING, "Could not chdir to '$realWorkdir'."); + return Verdict::INTERNAL_ERROR; + } + if (!chmod($realWorkdir, 0755)) { + logmsg(LOG_WARNING, "Could not chmod '$realWorkdir' to 0755."); + return Verdict::INTERNAL_ERROR; + } + + if (is_dir("$realWorkdir/execdir") && !chmod("$realWorkdir/execdir", 0755)) { + logmsg(LOG_WARNING, "Could not chmod '$realWorkdir/execdir' to 0755."); + return Verdict::INTERNAL_ERROR; + } + + $program = "execdir/program"; + if (!is_executable($program)) { + logmsg(LOG_WARNING, "Program '$program' not found or not executable, our current path is: " . getcwd()); + return Verdict::INTERNAL_ERROR; + } + if (!is_executable($run_runpath)) { + logmsg(LOG_WARNING, "Run script '$run_runpath' not found or not executable."); + return Verdict::INTERNAL_ERROR; + } + if (!$combined_run_compare && !is_executable($compare_runpath)) { + logmsg(LOG_WARNING, "Compare script '$compare_runpath' not found or not executable."); + return Verdict::INTERNAL_ERROR; + } + + foreach ([ + 'system.out', # Judging system output (info/debug/error) + 'program.out','program.err', # Program output and stderr (for extra information) + 'program.meta', 'runguard.err', # Metadata and runguard stderr + 'compare.meta', 'compare.err' # Compare runguard metadata and stderr + ] as $file) { + if (!touch($file)) { + logmsg(LOG_WARNING, "Could not create '$file'."); + return Verdict::INTERNAL_ERROR; + } + } + + logmsg(LOG_DEBUG, "setting up testing (chroot) environment"); + + if (!copy($input, "$realWorkdir/testdata.in")) { + logmsg(LOG_WARNING, "Could not copy '$input' to '$realWorkdir/testdata.in'."); + return Verdict::INTERNAL_ERROR; + } + + foreach (['bin', 'dj-bin', 'dev'] as $dir) { + $actualDir = '../../' . $dir; + if (!is_dir($actualDir)) { + if (!mkdir($actualDir, 0711, true)) { + logmsg(LOG_WARNING, "Could not create '$actualDir'."); + return Verdict::INTERNAL_ERROR; + } + } + } + + // Support for interactive problems + if ($combined_run_compare) { + if (!copy(BINDIR . '/runpipe', "../../dj-bin/runpipe")) { + logmsg(LOG_WARNING, "Could not copy 'runpipe' to '../../dj-bin/runpipe'."); + return Verdict::INTERNAL_ERROR; + } + if (!chmod("../../dj-bin/runpipe", 0755)) { + logmsg(LOG_WARNING, "Could not chmod '../../dj-bin/runpipe' to 0755."); + return Verdict::INTERNAL_ERROR; + } + } + + // If we need to create a writable temp directory, do so + if (CREATE_WRITABLE_TEMP_DIR) { + putenv("TMPDIR=$prefix/write_tmp"); + if (!is_dir("$realWorkdir/write_tmp")) { + if (!mkdir("$realWorkdir/write_tmp", 0777, true)) { + logmsg(LOG_WARNING, "Could not create '$realWorkdir/write_tmp'."); + return Verdict::INTERNAL_ERROR; + } + } + } + + logmsg(LOG_DEBUG, "Running program"); + $run_args = [ + $run_runpath, + 'testdata.in', 'program.out' + ]; + if ($combined_run_compare) { + // A combined run and compare script may now already need the + // feedback directory, and perhaps access to the test answers (but + // only the original that lives outside the chroot). + mkdir('feedback', 0755, true); + array_push($run_args, "$output", 'compare.meta', 'feedback'); + } + + $cpu_limit = implode(':', $timelimit['cpu']); + $wall_limit = implode(':', $timelimit['wall']); + + // TODO: Clean this up in a follow-up change, and pass it more directly. + $proclimit = getenv('PROCLIMIT'); + $memlimit = getenv('MEMLIMIT'); + $filelimit = getenv('FILELIMIT'); + $debug = getenv('DEBUG'); + $runuser = getenv('RUNUSER'); + $rungroup = getenv('RUNGROUP'); + + if ($debug) { + putenv('VERBOSE=7'); + } + + $gainroot = ['sudo', '-n']; + $cpuset = is_null($this->daemonid) ? [] : ['-P', $this->daemonid]; + + $runguard_args = [BINDIR . "/runguard"]; + if (CREATE_WRITABLE_TEMP_DIR) { + $runguard_args[] = '-V'; + $runguard_args[] = "TMPDIR=$prefix/write_tmp"; + } + if ($debug) { + $runguard_args[] = '-v'; + $runguard_args[] = '-V'; + $runguard_args[] = "DEBUG=$debug"; + } + + $run_args = array_merge( + $run_args, + $gainroot, + $runguard_args, + $cpuset, + [ + '-r', "$realWorkdir/../../", + "--nproc=$proclimit", + "--no-core", + "--streamsize=$filelimit", + "--user=$runuser", + "--group=$rungroup", + "--walltime=$wall_limit", + "--cputime=$cpu_limit", + "--memsize=$memlimit", + "--filesize=$filelimit", + "--stderr=program.err", + "--outmeta=program.meta", + "--", + "$prefix/execdir/program", + ] + ); + + $this->runCommandSafe($run_args, $exitcode, log_nonzero_exitcode: false, stderr_target: "runguard.err"); + + if (CREATE_WRITABLE_TEMP_DIR) { + // Revoke access to the TMPDIR as security measure + if (!chown("$realWorkdir/write_tmp", posix_getpwuid(posix_geteuid())['uid'])) { + logmsg(LOG_WARNING, "Could not chown '$realWorkdir/write_tmp' to us"); + return Verdict::INTERNAL_ERROR; + } + if (!chmod("$realWorkdir/write_tmp", 0700)) { + logmsg(LOG_WARNING, "Could not chmod '$realWorkdir/write_tmp' to 0700"); + return Verdict::INTERNAL_ERROR; + } + } + + if (!$combined_run_compare) { + logmsg(LOG_DEBUG, "Comparing output"); + + if (!copy($output, "$realWorkdir/testdata.out")) { + logmsg(LOG_WARNING, "Could not copy '$output' to '$realWorkdir/testdata.out'."); + return Verdict::INTERNAL_ERROR; + } + + logmsg(LOG_DEBUG, "Starting compare script '" . $compare_runpath ."'"); + + if (!is_dir('feedback')) { + if (!mkdir('feedback', 0777, true)) { + logmsg(LOG_WARNING, "Could not create 'feedback'."); + return Verdict::INTERNAL_ERROR; + } + } + // We need to set permissions explicitly for two reasons: + // `umask` might block them from being 0777, and for multi-pass problems there is the implicit contract + // of keeping files around between passes. This is ugly, but what the spec currently dictates. + // Cannot use `chmod` here directly because of recursion. + if (!$this->runCommandSafe( + [ + 'chmod', + '-R', + 'go+w', + 'feedback', + ] + )) { + logmsg(LOG_WARNING, "Could not chmod 'feedback' to go+w."); + return Verdict::INTERNAL_ERROR; + } + + // TODO: Clean this up in a follow-up change, and pass it more directly. + $scriptmemlimit = (string)getenv('SCRIPTMEMLIMIT'); + $scripttimelimit = (string)getenv('SCRIPTTIMELIMIT'); + $scriptfilelimit = (string)getenv('SCRIPTFILELIMIT'); + // TODO: Perhaps we should change this in the database to be an array of args? + $orig_compare_args = []; + if ($compare_args !== null && strlen($compare_args) > 0) { + $orig_compare_args = explode(' ', $compare_args); + } + + $compare_args = array_merge( + $gainroot, + [BINDIR . "/runguard"], + $cpuset, + [ + "--user=$runuser", + "--group=$rungroup", + "-m", $scriptmemlimit, + "-t", $scripttimelimit, + "-f", $scriptfilelimit, + "-s", $scriptfilelimit, + "--no-core", + "-M", "compare.meta", + "--", + $compare_runpath, + "testdata.in", + "testdata.out", + "feedback/", + ], + $orig_compare_args, + ); + $this->runCommandSafe($compare_args, $exitcode, log_nonzero_exitcode: false, stdin_source: "program.out", stdout_target: "compare.tmp"); + } + + $this->runCommandSafe( + array_merge( + $gainroot, + [ + 'chown', + '-R', + posix_getpwuid(posix_geteuid())['name'] . ":", + "$realWorkdir/feedback" + ] + ) + ); + + // Cannot use chmod directly here for recursion. + if (!$this->runCommandSafe( + [ + 'chmod', + '-R', + 'go-w', + 'feedback', + ] + )) { + logmsg(LOG_WARNING, "Could not chmod 'feedback' to go-w."); + return Verdict::INTERNAL_ERROR; + } + + // Make sure that feedback file exists, since we assume this later. + logmsg(LOG_DEBUG, "$realWorkdir/feedback/judgemessage.txt"); + if (!touch("$realWorkdir/feedback/judgemessage.txt")) { + logmsg(LOG_WARNING, "Could not create '$realWorkdir/feedback/judgemessage.txt'."); + return Verdict::INTERNAL_ERROR; + } + + if (is_readable("$realWorkdir/feedback/judgeerror.txt") && filesize("$realWorkdir/feedback/judgeerror.txt") > 0) { + appendToFile("$realWorkdir/feedback/judgemessage.txt", "\n---------- output validator (error) messages ----------\n"); + appendToFile("$realWorkdir/feedback/judgemessage.txt", file_get_contents("$realWorkdir/feedback/judgeerror.txt")); + } + + logmsg(LOG_DEBUG, "checking compare script exit status: $exitcode"); + $compare_meta = file_get_contents("compare.meta"); + $compare_tmp = is_readable("compare.tmp") ? file_get_contents("compare.tmp") : ""; + if (preg_match('/time-result: .*timelimit/', $compare_meta)) { + logmsg(LOG_ERR, "Comparing aborted after the script timelimit of %s seconds, compare script output:\n%s", $scripttimelimit, $compare_tmp); + return Verdict::COMPARE_ERROR; + } + + // Append output validator stdin/stderr - display separately? + if ($compare_tmp && strlen($compare_tmp) > 0) { + appendToFile("$realWorkdir/feedback/judgemessage.txt", "\n---------- output validator (error) messages ----------\n"); + appendToFile("$realWorkdir/feedback/judgemessage.txt", $compare_tmp); + } + + if (!is_readable("program.meta")) { + logmsg(LOG_ERR, "'program.meta' is not readable"); + return Verdict::INTERNAL_ERROR; + } + logmsg(LOG_DEBUG, "checking program exit status"); + $program_meta_ini = $this->readMetadata('program.meta'); + logmsg(LOG_DEBUG, "parsed program meta: " . var_export($program_meta_ini, true)); + $resourceInfo = "\nruntime: " + . $program_meta_ini['cpu-time'] . 's cpu, ' + . $program_meta_ini['wall-time'] . "s wall\n" + . 'memory: ' . $program_meta_ini['memory-bytes'] . ' bytes'; + + $compare_meta_ini = $this->readMetadata('compare.meta'); + logmsg(LOG_DEBUG, "parsed compare meta: " . var_export($compare_meta_ini, true)); + + if ($combined_run_compare && $compare_meta_ini['validator-exited-first'] == 'true' && $compare_meta_ini['exitcode'] == '43') { + // For interactive problems with combined run/compare scripts, a + // WA may override TLE and RTE. + // FIXME: Maybe we are interested in when what program exited. If so, we + // can write this to compare.meta + if (preg_match('/.*timelimit.*/', $program_meta_ini['time-result'])) { + appendToFile("system.out", "Timelimit exceeded, but validator exited first with WA."); + } elseif ($program_meta_ini['exitcode'] != 0) { + appendToFile("system.out", "Non-zero exitcode " . $program_meta_ini['exitcode'] . ", but validator exited first with WA."); + } + appendToFile("system.out", "Wrong answer!"); + return Verdict::WRONG_ANSWER; + } + + if (preg_match('/.*timelimit.*/', $program_meta_ini['time-result'])) { + appendToFile("system.out", "Timelimit exceeded."); + return Verdict::TIMELIMIT; + } + + if ($program_meta_ini['exitcode'] !== '0') { + appendToFile("system.out", "Non-zero exitcode " . $program_meta_ini['exitcode']); + return Verdict::RUN_ERROR; + } + + // Check whether stdout is in the list of truncated output streams + // These could be either 'stdout', 'stderr,stdout', or 'stdout,stderr' + $outputTruncated = $program_meta_ini['output-truncated'] ?? ''; + $truncatedStreams = explode(',', $outputTruncated); + if (in_array('stdout', $truncatedStreams, true)) { + appendToFile("system.out", "Output limit exceeded: " . $program_meta_ini['stdout-bytes'] . " bytes more than the limit of " . $filelimit*1024 . " bytes"); + return Verdict::OUTPUT_LIMIT; + } + + if ($exitcode === 42) { + appendToFile("system.out", "Correct!"); + return Verdict::CORRECT; + } + if ($exitcode === 43) { + if (!$combined_run_compare && filesize("program.out") === 0) { + appendToFile("system.out", "Program produced no output."); + return Verdict::NO_OUTPUT; + } + appendToFile("system.out", "Wrong answer!"); + return Verdict::WRONG_ANSWER; + } + + return Verdict::COMPARE_ERROR; + } finally { + if ($realWorkdir) { + if ($resourceInfo !== null) { + appendToFile("$realWorkdir/system.out", $resourceInfo); + } + + if (is_readable("$realWorkdir/runguard.err") && filesize("$realWorkdir/runguard.err") > 0) { + appendToFile("$realWorkdir/system.out", "\n********** runguard stderr follows **********\n"); + appendToFile("$realWorkdir/system.out", file_get_contents("$realWorkdir/runguard.err")); + } + + $runpipePath = dirname($realWorkdir, 2) . '/dj-bin/runpipe'; + if (file_exists($runpipePath)) { + unlink($runpipePath); + } + + if (file_exists("$realWorkdir/testdata.in")) { + unlink("$realWorkdir/testdata.in"); + symlink($input, "$realWorkdir/testdata.in"); + } + + if (file_exists("$realWorkdir/testdata.out")) { + unlink("$realWorkdir/testdata.out"); + symlink($output, "$realWorkdir/testdata.out"); + } + + // Remove access to workdir for next runs + chmod($realWorkdir, 0700); + } + + if ($oldTmpDir === false) { + putenv('TMPDIR'); + } else { + putenv("TMPDIR=$oldTmpDir"); + } + + if ($oldVerbose === false) { + putenv('VERBOSE'); + } else { + putenv("VERBOSE=$oldVerbose"); + } + + chdir($oldCwd); + } + } + private function runTestcase( array $judgeTask, string $workdir, @@ -1448,13 +1902,14 @@ private function runTestcase( $judgeTask['run_script_id'], $run_config['hash'], $judgeTask['judgetaskid'], - $combined_run_compare); + $combined_run_compare + ); if (isset($error)) { return false; } if ($combined_run_compare) { - // set to empty string to signal the testcase_run script that the + // set to empty string to signal that the // run script also acts as compare script $compare_runpath = ''; } else { @@ -1526,29 +1981,18 @@ private function runTestcase( } } - $timelimit_str = implode(':', $timelimit['cpu']) . ',' . implode(':', $timelimit['wall']); - $run_command_parts = [LIBJUDGEDIR . '/testcase_run.sh']; - if (isset($this->options['daemonid'])) { - $run_command_parts[] = '-n'; - $run_command_parts[] = $this->options['daemonid']; - } - array_push($run_command_parts, + $verdict = $this->testcaseRunInternal( $input, $output, - $timelimit_str, + $timelimit, $passdir, $run_runpath, + $combined_run_compare, $compare_runpath, $compare_config['compare_args'] ); - $this->runCommandSafe($run_command_parts, $retval, log_nonzero_exitcode: false); - // What does the exitcode mean? - if (!isset($this->EXITCODES[$retval])) { - alert('error'); - error("Unknown exitcode ($retval) from testcase_run.sh for s$judgeTask[submitid]"); - } - $result = $this->EXITCODES[$retval]; + $result = str_replace('_', '-', strtolower($verdict->name)); // Try to read metadata from file $runtime = null; @@ -1677,8 +2121,11 @@ private function reportJudgingRun(array $judgeTask, array $new_judging_run, bool dj_sleep(0.001 * $sleep_ms); } $response = $this->request( - sprintf('judgehosts/add-judging-run/%s/%s', $new_judging_run['hostname'], - urlencode((string)$judgeTaskId)), + sprintf( + 'judgehosts/add-judging-run/%s/%s', + $new_judging_run['hostname'], + urlencode((string)$judgeTaskId) + ), 'POST', $new_judging_run, false diff --git a/judge/run-interactive.sh b/judge/run-interactive.sh index 3e0c6dc1ad..7788f1ad87 100755 --- a/judge/run-interactive.sh +++ b/judge/run-interactive.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Run wrapper-script to be called from 'testcase_run.sh'. +# Run wrapper-script to be called from the judgedaemon # # This script is meant to simplify writing interactive problems where the # contestants' solution bi-directionally communicates with a jury program, e.g. diff --git a/judge/runguard.cc b/judge/runguard.cc index 845a38b07f..7baa68f751 100644 --- a/judge/runguard.cc +++ b/judge/runguard.cc @@ -266,7 +266,7 @@ void die(int errnum, std::format_string fmt, Args&&... args) /* * continue, there is not much we can do here. * In the worst case, this will trigger an error - * in testcase_run.sh, as the runuser may still be + * in the judgedaemon, as the runuser may still be * running processes */ } diff --git a/judge/testcase_run.sh b/judge/testcase_run.sh deleted file mode 100755 index 83c049a025..0000000000 --- a/judge/testcase_run.sh +++ /dev/null @@ -1,345 +0,0 @@ -#!/bin/sh -# To suppress false positive of FILELIMIT misspelling of TIMELIMIT: -# shellcheck disable=SC2153 - -# Script to test (run and compare) submissions with a single testcase -# -# Usage: $0 -# -# -# File containing test-input with absolute pathname. -# File containing test-output with absolute pathname. -# Timelimit in seconds in the format -# ":,:". -# Directory where to execute submission in a chroot-ed -# environment. For best security leave it as empty as possible. -# Certainly do not place output-files there! -# Absolute path to run script to use. -# Absolute path to compare script to use, optional. -# Arguments to pass to compare script, optional. -# -# Default run and compare scripts can be configured in the database. -# -# Exit automatically, whenever a simple command fails and trap it: -set -e -trap 'cleanup ; error' EXIT - -cleanup () -{ - # Remove some copied files to save disk space - if [ "$WORKDIR" ]; then - rm -f "$WORKDIR/../../dj-bin/runpipe" 2> /dev/null || true - - # Replace testdata by symlinks to reduce disk usage - if [ -f "$WORKDIR/testdata.in" ]; then - rm -f "$WORKDIR/testdata.in" - ln -s "$TESTIN" "$WORKDIR/testdata.in" - fi - if [ -f "$WORKDIR/testdata.out" ]; then - rm -f "$WORKDIR/testdata.out" - ln -s "$TESTOUT" "$WORKDIR/testdata.out" - fi - - # Remove access to workdir for next runs - chmod go= "$WORKDIR" - fi - - # Copy runguard and program stderr to system output. The display is - # truncated to normal size in the jury web interface. - if [ -s runguard.err ]; then - echo "********** runguard stderr follows **********" >> system.out - cat runguard.err >> system.out - fi -} - -cleanexit () -{ - set +e - trap - EXIT - - cleanup - - logmsg $LOG_DEBUG "exiting with status '$1'" - exit $1 -} - -# Runs command without error trapping and check exitcode -runcheck () -{ - logmsg $LOG_DEBUG "runcheck: $*" - set +e - "$@" - exitcode=$? - set -e -} - -# Error and logging functions -# shellcheck disable=SC1090 -. "$DJ_LIBDIR/lib.error.sh" - - -CPUSET="" -CPUSET_OPT="" -# Do argument parsing -OPTIND=1 # reset if necessary -while getopts "n:" opt; do - case $opt in - n) - CPUSET="$OPTARG" - ;; - :) - echo "Option -$OPTARG requires an argument." >&2 - ;; - *) - echo "Invalid option specified." >&2 - exit 1 - ;; - esac -done -# Shift any of the arguments out of the way -shift $((OPTIND-1)) -[ "$1" = "--" ] && shift - -if [ -n "$CPUSET" ]; then - CPUSET_OPT="-P $CPUSET" - LOGFILE="$DJ_LOGDIR/judge.$(hostname | cut -d . -f 1)-$CPUSET.log" -else - LOGFILE="$DJ_LOGDIR/judge.$(hostname | cut -d . -f 1).log" -fi - -# Logging: -LOGLEVEL=$LOG_DEBUG -PROGNAME="$(basename "$0")" - -# Check for judge backend debugging: -if [ "$DEBUG" ]; then - export DEBUG - export VERBOSE=$LOG_DEBUG - logmsg $LOG_NOTICE "debugging enabled, DEBUG='$DEBUG'" -else - export VERBOSE=$LOG_ERR -fi - -# Location of scripts/programs: -SCRIPTDIR="$DJ_LIBJUDGEDIR" -GAINROOT="sudo -n" -RUNGUARD="$DJ_BINDIR/runguard" -RUNPIPE="$DJ_BINDIR/runpipe" -PROGRAM="execdir/program" - -logmsg $LOG_INFO "starting '$0', PID = $$" - -[ $# -ge 4 ] || error "not enough arguments. See script-code for usage." -TESTIN="$1"; shift -TESTOUT="$1"; shift -TIMELIMIT="$1"; shift -WORKDIR="$1"; shift -RUN_SCRIPT="$1"; -COMPARE_SCRIPT="$2"; -COMPARE_ARGS="$3"; -logmsg $LOG_DEBUG "arguments: '$TESTIN' '$TESTOUT' '$TIMELIMIT' '$WORKDIR'" -logmsg $LOG_DEBUG "optionals: '$RUN_SCRIPT' '$COMPARE_SCRIPT' '$COMPARE_ARGS'" - -[ -r "$TESTIN" ] || error "test-input not found: $TESTIN" -[ -r "$TESTOUT" ] || error "test-output not found: $TESTOUT" -if [ ! -d "$WORKDIR" ] || [ ! -w "$WORKDIR" ] || [ ! -x "$WORKDIR" ]; then - error "Workdir not found or not writable: $WORKDIR" -fi -if [ -z "$COMPARE_SCRIPT" ]; then - export COMBINED_RUN_COMPARE=1 -else - export COMBINED_RUN_COMPARE=0 -fi -[ -x "$WORKDIR/$PROGRAM" ] || error "submission program not found or not executable: '$WORKDIR/$PROGRAM'" -[ -x "$RUN_SCRIPT" ] || error "run script not found or not executable: $RUN_SCRIPT" -[ -x "$RUNGUARD" ] || error "runguard not found or not executable: $RUNGUARD" -if [ ! -x "$COMPARE_SCRIPT" ] && [ $COMBINED_RUN_COMPARE -eq 0 ]; then - error "compare script not found or not executable: $COMPARE_SCRIPT" -fi - -cd "$WORKDIR" - -# Get the last two directory entries of $PWD -PREFIX="/$(basename $(realpath "$PWD/.."))/$(basename "$PWD")" - -# Make testing/execute dir accessible for RUNUSER: -chmod a+x "$WORKDIR" "$WORKDIR/execdir" - -# Create files which are expected to exist: -touch system.out # Judging system output (info/debug/error) -touch program.out program.err # Program output and stderr (for extra information) -touch program.meta runguard.err # Metadata and runguard stderr -touch compare.meta compare.err # Compare runguard metadata and stderr - -logmsg $LOG_INFO "setting up testing (chroot) environment" - -# Copy the testdata input -cp "$TESTIN" "$WORKDIR/testdata.in" - -# shellcheck disable=SC2174 -mkdir -p -m 0711 ../../bin ../../dj-bin ../../dev -# copy a support program for interactive problems: -cp -pL "$RUNPIPE" ../../dj-bin/runpipe -chmod a+rx ../../dj-bin/runpipe - -# If we need to create a writable temp directory, do so -if [ "$CREATE_WRITABLE_TEMP_DIR" ]; then - export TMPDIR="$PREFIX/write_tmp" - # shellcheck disable=SC2174 - mkdir -m 777 -p "$WORKDIR/write_tmp" -fi - -# Run the solution program (within a restricted environment): -logmsg $LOG_INFO "running program" - -RUNARGS="testdata.in program.out" -if [ $COMBINED_RUN_COMPARE -eq 1 ]; then - # A combined run and compare script may now already need the - # feedback directory, and perhaps access to the test answers (but - # only the original that lives outside the chroot). - mkdir -p feedback - RUNARGS="$RUNARGS $TESTOUT compare.meta feedback" -fi - -exitcode=0 -TIMELIMIT_CPU="${TIMELIMIT%%,*}" -TIMELIMIT_WALL="${TIMELIMIT#*,}" -runcheck "$RUN_SCRIPT" $RUNARGS \ - $GAINROOT "$RUNGUARD" ${DEBUG:+-v -V "DEBUG=$DEBUG"} ${TMPDIR:+ -V "TMPDIR=$TMPDIR"} $CPUSET_OPT \ - -r "$PWD/../.." \ - --nproc=$PROCLIMIT \ - --no-core --streamsize=$FILELIMIT \ - --user="$RUNUSER" --group="$RUNGROUP" \ - --walltime="$TIMELIMIT_WALL" --cputime="$TIMELIMIT_CPU" \ - --memsize=$MEMLIMIT --filesize=$FILELIMIT \ - --stderr=program.err --outmeta=program.meta -- \ - "$PREFIX/$PROGRAM" 2>runguard.err - -if [ "$CREATE_WRITABLE_TEMP_DIR" ]; then - # Revoke access to the TMPDIR as security measure - chown -R "$(id -un):" "$TMPDIR" - chmod -R go= "$TMPDIR" -fi - -if [ $COMBINED_RUN_COMPARE -eq 0 ]; then - # We first compare the output, so that even if the submission gets a - # timelimit exceeded or runtime error verdict later, the jury can - # still view the diff with what the submission produced. - logmsg $LOG_INFO "comparing output" - - # Copy testdata output, only after program has run - cp "$TESTOUT" "$WORKDIR/testdata.out" - - logmsg $LOG_DEBUG "starting compare script '$COMPARE_SCRIPT'" - - exitcode=0 - # Create dir for feedback files and make it writable for $RUNUSER - mkdir -p feedback - chmod -R a+w feedback - - runcheck $GAINROOT "$RUNGUARD" ${DEBUG:+-v} $CPUSET_OPT -u "$RUNUSER" -g "$RUNGROUP" \ - -m $SCRIPTMEMLIMIT -t $SCRIPTTIMELIMIT --no-core \ - -f $SCRIPTFILELIMIT -s $SCRIPTFILELIMIT -M compare.meta -- \ - "$COMPARE_SCRIPT" testdata.in testdata.out feedback/ $COMPARE_ARGS < program.out \ - >compare.tmp 2>&1 -fi - -# Make sure that all feedback files are owned by the current -# user/group, so that we can append content. -$GAINROOT chown -R "$(id -un):" "$WORKDIR/feedback" -chmod -R go-w feedback - -# Make sure that feedback file exists, since we assume this later. -if [ ! -f feedback/judgemessage.txt ]; then - touch feedback/judgemessage.txt -fi - -# Append output validator error messages -# TODO: display extra -if [ -s feedback/judgeerror.txt ]; then - printf "\\n---------- output validator (error) messages ----------\\n" >> feedback/judgemessage.txt - cat feedback/judgeerror.txt >> feedback/judgemessage.txt -fi - -logmsg $LOG_DEBUG "checking compare script exit-status: $exitcode" -if grep '^time-result: .*timelimit' compare.meta >/dev/null 2>&1 ; then - logmsg $LOG_ERR "Comparing aborted after $SCRIPTTIMELIMIT seconds, compare script output:\\n$(cat compare.tmp)" - cleanexit ${E_COMPARE_ERROR:-1} -fi -# Append output validator stdin/stderr - display extra? -if [ -s compare.tmp ]; then - printf "\\n---------- output validator stdout/stderr messages ----------\\n" >> feedback/judgemessage.txt - cat compare.tmp >> feedback/judgemessage.txt -fi -if [ $exitcode -ne 42 ] && [ $exitcode -ne 43 ]; then - logmsg $LOG_ERR "Comparing failed with exitcode $exitcode, compare script output:\\n$(cat feedback/judgemessage.txt)" - cleanexit ${E_COMPARE_ERROR:-1} -fi - -# Check for errors from running the program: -if [ ! -r program.meta ]; then - error "'program.meta' not readable" -fi -logmsg $LOG_DEBUG "checking program run exit-status" -# There's no bash YAML parser, and the format is rigid enough that we -# can parse it with grep here. -timeused=$( grep '^time-used: ' program.meta | sed 's/time-used: //') -program_cputime=$( grep '^cpu-time: ' program.meta | sed 's/cpu-time: //') -program_walltime=$(grep '^wall-time: ' program.meta | sed 's/wall-time: //') -program_exit=$( grep '^exitcode: ' program.meta | sed 's/exitcode: //') -program_stdout=$( grep '^stdout-bytes: ' program.meta | sed 's/stdout-bytes: //') -program_stderr=$( grep '^stderr-bytes: ' program.meta | sed 's/stderr-bytes: //') -memory_bytes=$( grep '^memory-bytes: ' program.meta | sed 's/memory-bytes: //') -resourceinfo="\ -runtime: ${program_cputime}s cpu, ${program_walltime}s wall -memory used: ${memory_bytes} bytes" - -if [ $COMBINED_RUN_COMPARE -eq 1 ] && grep '^validator-exited-first: true' compare.meta > /dev/null 2>&1 && grep '^exitcode: 43' compare.meta > /dev/null 2>&1 ; then - # For interactive problems with combined run/compare scripts, a - # WA may override TLE and RTE. - # FIXME: Maybe we are interested in when what program exited. If so, we - # can write this to compare.meta - if grep '^time-result: .*timelimit' program.meta >/dev/null 2>&1 ; then - echo "Timelimit exceeded, but validator exited first with WA." >>system.out - elif [ "$program_exit" != "0" ]; then - echo "Non-zero exitcode $program_exit, but validator exited first with WA." >>system.out - fi - echo "$resourceinfo" >>system.out - cleanexit ${E_WRONG_ANSWER:-1} -fi - -if grep '^time-result: .*timelimit' program.meta >/dev/null 2>&1 ; then - echo "Timelimit exceeded." >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_TIMELIMIT:-1} -fi -if [ "$program_exit" != "0" ]; then - echo "Non-zero exitcode $program_exit" >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_RUN_ERROR:-1} -fi - -if grep -E '^output-truncated: ([a-z]+,)*stdout(,[a-z]+)*' program.meta >/dev/null 2>&1 ; then - echo "Output limit exceeded: $program_stdout > $((FILELIMIT*1024))" >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_OUTPUT_LIMIT:-1} -fi - -if [ $exitcode -eq 42 ]; then - echo "Correct!" >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_CORRECT:-1} -elif [ $exitcode -eq 43 ]; then - # Special case detect no-output: - if [ ! -s program.out ] && [ $COMBINED_RUN_COMPARE -eq 0 ]; then - echo "Program produced no output." >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_NO_OUTPUT:-1} - fi - echo "Wrong answer." >>system.out - echo "$resourceinfo" >>system.out - cleanexit ${E_WRONG_ANSWER:-1} -fi - -# This should never be reached -exit ${E_INTERNAL_ERROR:-1} diff --git a/lib/lib.misc.php b/lib/lib.misc.php index 33ffcb7cb5..9bd281ca55 100644 --- a/lib/lib.misc.php +++ b/lib/lib.misc.php @@ -129,3 +129,15 @@ function version() : never "General Public Licence for details.\n"; exit(0); } + +/** + * Append content to a file. + * + * @param string $filename The file to append to + * @param string $content The content to append + * @return int|false The number of bytes that were written to the file, or false on failure. + */ +function appendToFile(string $filename, string $content) +{ + return file_put_contents($filename, $content, FILE_APPEND); +} diff --git a/sql/files/defaultdata/run/run b/sql/files/defaultdata/run/run index 17dd9975d2..90a4366382 100755 --- a/sql/files/defaultdata/run/run +++ b/sql/files/defaultdata/run/run @@ -1,6 +1,6 @@ #!/bin/sh -# Run wrapper-script called from 'testcase_run.sh'. +# Run wrapper-script called from judgedaemon # See that script for more info. # # Usage: $0 ...