diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index 1d0eb63560..b5c962ae2e 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -1342,21 +1342,6 @@ function judge(array $judgeTask): bool return false; } - // Copy program with all possible additional files to testcase - // dir. Use hardlinks to preserve space with big executables. - $programdir = $testcasedir . '/execdir'; - system('mkdir -p ' . dj_escapeshellarg($programdir), $retval); - if ($retval!==0) { - error("Could not create directory '$programdir'"); - } - - foreach (glob("$workdir/compile/*") as $compile_file) { - system('cp -PRl ' . dj_escapeshellarg($compile_file) . ' ' . dj_escapeshellarg($programdir), $retval); - if ($retval!==0) { - error("Could not copy program to '$programdir'"); - } - } - // do the actual test-run $combined_run_compare = $compare_config['combined_run_compare']; [$run_runpath, $error] = fetch_executable( @@ -1403,60 +1388,109 @@ function judge(array $judgeTask): bool putenv('SCRIPTMEMLIMIT=' . $compare_config['script_memory_limit']); putenv('SCRIPTFILELIMIT=' . $compare_config['script_filesize_limit']); - $test_run_cmd = LIBJUDGEDIR . "/testcase_run.sh $cpuset_opt " . - implode(' ', array_map('dj_escapeshellarg', [ - $tcfile['input'], - $tcfile['output'], - "$run_config[time_limit]:$hardtimelimit", - $testcasedir, - $run_runpath, - $compare_runpath, - $compare_config['compare_args'] - ])); - system($test_run_cmd, $retval); + $input = $tcfile['input']; + $output = $tcfile['output']; + $passLimit = $run_config['pass_limit']; + for ($passCnt = 1; $passCnt <= $passLimit; $passCnt++) { + $nextPass = false; + if ($passLimit > 1) { + logmsg(LOG_INFO, " 🔄 Running pass $passCnt..."); + } - // What does the exitcode mean? - if (! isset($EXITCODES[$retval])) { - alert('error'); - error("Unknown exitcode ($retval) from testcase_run.sh for s$judgeTask[submitid]"); - } - $result = $EXITCODES[$retval]; + $passdir = $testcasedir . '/' . $passCnt; + mkdir($passdir, 0755, true); - // Try to read metadata from file - $runtime = null; - $metadata = read_metadata($testcasedir . '/program.meta'); + // Copy program with all possible additional files to testcase + // dir. Use hardlinks to preserve space with big executables. + $programdir = $passdir . '/execdir'; + system('mkdir -p ' . dj_escapeshellarg($programdir), $retval); + if ($retval!==0) { + error("Could not create directory '$programdir'"); + } - if (isset($metadata['time-used'])) { - $runtime = @$metadata[$metadata['time-used']]; - } + foreach (glob("$workdir/compile/*") as $compile_file) { + system('cp -PRl ' . dj_escapeshellarg($compile_file) . ' ' . dj_escapeshellarg($programdir), $retval); + if ($retval!==0) { + error("Could not copy program to '$programdir'"); + } + } - if ($result === 'compare-error') { - if ($combined_run_compare) { - logmsg(LOG_ERR, "comparing failed for combined run/compare script '" . $judgeTask['run_script_id'] . "'"); - $description = 'combined run/compare script ' . $judgeTask['run_script_id'] . ' crashed'; - disable('run_script', 'run_script_id', $judgeTask['run_script_id'], $description, $judgeTask['judgetaskid']); - } else { - logmsg(LOG_ERR, "comparing failed for compare script '" . $judgeTask['compare_script_id'] . "'"); - $description = 'compare script ' . $judgeTask['compare_script_id'] . ' crashed'; - disable('compare_script', 'compare_script_id', $judgeTask['compare_script_id'], $description, $judgeTask['judgetaskid']); + $test_run_cmd = LIBJUDGEDIR . "/testcase_run.sh $cpuset_opt " . + implode(' ', array_map('dj_escapeshellarg', [ + $input, + $output, + "$run_config[time_limit]:$hardtimelimit", + $passdir, + $run_runpath, + $compare_runpath, + $compare_config['compare_args'] + ])); + system($test_run_cmd, $retval); + + // What does the exitcode mean? + if (!isset($EXITCODES[$retval])) { + alert('error'); + error("Unknown exitcode ($retval) from testcase_run.sh for s$judgeTask[submitid]"); } - return false; - } + $result = $EXITCODES[$retval]; - $new_judging_run = [ - 'runresult' => urlencode($result), - 'runtime' => urlencode((string)$runtime), - 'output_run' => rest_encode_file($testcasedir . '/program.out', $output_storage_limit), - 'output_error' => rest_encode_file($testcasedir . '/program.err', $output_storage_limit), - 'output_system' => rest_encode_file($testcasedir . '/system.out', $output_storage_limit), - 'metadata' => rest_encode_file($testcasedir . '/program.meta', false), - 'output_diff' => rest_encode_file($testcasedir . '/feedback/judgemessage.txt', $output_storage_limit), - 'hostname' => $myhost, - 'testcasedir' => $testcasedir, - ]; + // Try to read metadata from file + $runtime = null; + $metadata = read_metadata($passdir . '/program.meta'); - if (file_exists($testcasedir . '/feedback/teammessage.txt')) { - $new_judging_run['team_message'] = rest_encode_file($testcasedir . '/feedback/teammessage.txt', $output_storage_limit); + if (isset($metadata['time-used'])) { + $runtime = @$metadata[$metadata['time-used']]; + } + + if ($result === 'compare-error') { + if ($combined_run_compare) { + logmsg(LOG_ERR, "comparing failed for combined run/compare script '" . $judgeTask['run_script_id'] . "'"); + $description = 'combined run/compare script ' . $judgeTask['run_script_id'] . ' crashed'; + disable('run_script', 'run_script_id', $judgeTask['run_script_id'], $description, $judgeTask['judgetaskid']); + } else { + logmsg(LOG_ERR, "comparing failed for compare script '" . $judgeTask['compare_script_id'] . "'"); + $description = 'compare script ' . $judgeTask['compare_script_id'] . ' crashed'; + disable('compare_script', 'compare_script_id', $judgeTask['compare_script_id'], $description, $judgeTask['judgetaskid']); + } + return false; + } + + $new_judging_run = [ + 'runresult' => urlencode($result), + 'runtime' => urlencode((string)$runtime), + 'output_run' => rest_encode_file($passdir . '/program.out', $output_storage_limit), + 'output_error' => rest_encode_file($passdir . '/program.err', $output_storage_limit), + 'output_system' => rest_encode_file($passdir . '/system.out', $output_storage_limit), + 'metadata' => rest_encode_file($passdir . '/program.meta', false), + 'output_diff' => rest_encode_file($passdir . '/feedback/judgemessage.txt', $output_storage_limit), + 'hostname' => $myhost, + 'testcasedir' => $testcasedir, + ]; + + if (file_exists($passdir . '/feedback/teammessage.txt')) { + $new_judging_run['team_message'] = rest_encode_file($passdir . '/feedback/teammessage.txt', $output_storage_limit); + } + + if ($passLimit > 1) { + $walltime = $metadata['wall-time'] ?? '?'; + logmsg(LOG_INFO, ' ' . ($result === 'correct' ? " \033[0;32m✔\033[0m" : " \033[1;31m✗\033[0m") + . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result); + } + + if ($result !== 'correct') { + break; + } + if (file_exists($passdir . '/feedback/nextpass.in')) { + $input = $passdir . '/feedback/nextpass.in'; + $nextPass = true; + } else { + break; + } + } + if ($nextPass) { + $description = 'validator produced more passes than allowed ($passLimit)'; + disable('compare_script', 'compare_script_id', $judgeTask['compare_script_id'], $description, $judgeTask['judgetaskid']); + return false; } $ret = true; @@ -1485,9 +1519,11 @@ function judge(array $judgeTask): bool $ret = (bool)$needsMoreWork; } - $walltime = $metadata['wall-time'] ?? '?'; - logmsg(LOG_INFO, ' ' . ($result === 'correct' ? " \033[0;32m✔\033[0m" : " \033[1;31m✗\033[0m") - . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result); + if ($passLimit == 1) { + $walltime = $metadata['wall-time'] ?? '?'; + logmsg(LOG_INFO, ' ' . ($result === 'correct' ? " \033[0;32m✔\033[0m" : " \033[1;31m✗\033[0m") + . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result); + } // done! return $ret; diff --git a/judge/run-interactive.sh b/judge/run-interactive.sh index f38810b622..3e0c6dc1ad 100644 --- a/judge/run-interactive.sh +++ b/judge/run-interactive.sh @@ -36,4 +36,4 @@ MYDIR="$(dirname $0)" # Run the program while redirecting its stdin/stdout to 'runjury' via # 'runpipe'. Note that "$@" expands to separate, quoted arguments. -exec ../dj-bin/runpipe ${DEBUG:+-v} -M "$META" -o "$PROGOUT" "$MYDIR/runjury" "$TESTIN" "$TESTOUT" "$FEEDBACK" = "$@" +exec ../../dj-bin/runpipe ${DEBUG:+-v} -M "$META" -o "$PROGOUT" "$MYDIR/runjury" "$TESTIN" "$TESTOUT" "$FEEDBACK" = "$@" diff --git a/judge/testcase_run.sh b/judge/testcase_run.sh index 64cdfa5157..7cffaa7ec1 100755 --- a/judge/testcase_run.sh +++ b/judge/testcase_run.sh @@ -26,7 +26,7 @@ cleanup () { # Remove some copied files to save disk space if [ "$WORKDIR" ]; then - rm -f "$WORKDIR/../dj-bin/runpipe" 2> /dev/null || true + rm -f "$WORKDIR/../../dj-bin/runpipe" 2> /dev/null || true # Replace testdata by symlinks to reduce disk usage if [ -f "$WORKDIR/testdata.in" ]; then @@ -157,7 +157,8 @@ fi cd "$WORKDIR" -PREFIX="/$(basename "$PWD")" +# 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" @@ -174,10 +175,10 @@ logmsg $LOG_INFO "setting up testing (chroot) environment" cp "$TESTIN" "$WORKDIR/testdata.in" # shellcheck disable=SC2174 -mkdir -p -m 0711 ../bin ../dj-bin ../dev +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 +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 @@ -203,7 +204,7 @@ exitcode=0 # shellcheck disable=SC2153 runcheck "$RUN_SCRIPT" $RUNARGS \ $GAINROOT "$RUNGUARD" ${DEBUG:+-v -V "DEBUG=$DEBUG"} ${TMPDIR:+ -V "TMPDIR=$TMPDIR"} $CPUSET_OPT \ - -r "$PWD/.." \ + -r "$PWD/../.." \ --nproc=$PROCLIMIT \ --no-core --streamsize=$FILELIMIT \ --user="$RUNUSER" --group="$RUNGROUP" \ diff --git a/webapp/migrations/Version20240921081301.php b/webapp/migrations/Version20240921081301.php new file mode 100644 index 0000000000..7164cb2009 --- /dev/null +++ b/webapp/migrations/Version20240921081301.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE problem ADD is_multipass_problem TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Whether this problem is a multi-pass problem.\', ADD multipass_limit INT UNSIGNED DEFAULT NULL COMMENT \'Optional limit on the number of rounds for multi-pass problems; defaults to 2 if not specified.\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE problem DROP is_multipass_problem, DROP multipass_limit'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/Jury/ProblemController.php b/webapp/src/Controller/Jury/ProblemController.php index 6f810122b2..e4a8b33e9f 100644 --- a/webapp/src/Controller/Jury/ProblemController.php +++ b/webapp/src/Controller/Jury/ProblemController.php @@ -89,6 +89,7 @@ public function indexAction(): Response 'memlimit' => ['title' => 'memory limit', 'sort' => true], 'outputlimit' => ['title' => 'output limit', 'sort' => true], 'num_testcases' => ['title' => '# test cases', 'sort' => true], + 'type' => ['title' => 'type', 'sort' => true], ]; $contestCountData = $this->em->createQueryBuilder() @@ -206,9 +207,17 @@ public function indexAction(): Response $problemdata['badges'] = ['value' => $badges]; // merge in the rest of the data + $type = ''; + if ($p->getCombinedRunCompare()) { + $type .= ' interactive'; + } + if ($p->isMultipassProblem()) { + $type .= ' multi-pass'; + } $problemdata = array_merge($problemdata, [ 'num_contests' => ['value' => (int)($contestCounts[$p->getProbid()] ?? 0)], 'num_testcases' => ['value' => (int)$row['testdatacount']], + 'type' => ['value' => $type], ]); // Save this to our list of rows @@ -484,6 +493,13 @@ public function viewAction(Request $request, SubmissionService $submissionServic new SubmissionRestriction(problemId: $problem->getProbid()), ); + $type = ''; + if ($problem->getCombinedRunCompare()) { + $type .= ' interactive'; + } + if ($problem->isMultipassProblem()) { + $type .= ' multi-pass'; + } $data = [ 'problem' => $problem, 'problemAttachmentForm' => $problemAttachmentForm->createView(), @@ -493,6 +509,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic 'defaultOutputLimit' => (int)$this->config->get('output_limit'), 'defaultRunExecutable' => (string)$this->config->get('default_run'), 'defaultCompareExecutable' => (string)$this->config->get('default_compare'), + 'type' => $type, 'showContest' => count($this->dj->getCurrentContests(honorCookie: true)) > 1, 'showExternalResult' => $this->dj->shadowMode(), 'lockedProblem' => $lockedProblem, diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index b8ea182f17..46016b4971 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -532,6 +532,7 @@ public function viewAction( 'combinedRunCompare' => $submission->getProblem()->getCombinedRunCompare(), 'requestedOutputCount' => $requestedOutputCount, 'version_warnings' => [], + 'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(), ]; if ($selectedJudging === null) { diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index 9b7105e72f..5b37346091 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -109,6 +109,21 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?string $problemstatement_type = null; + #[ORM\Column(options: [ + 'comment' => 'Whether this problem is a multi-pass problem.', + 'default' => 0, + ])] + #[Serializer\Exclude] + private bool $isMultipassProblem = false; + + #[ORM\Column( + nullable: true, + options: ['comment' => 'Optional limit on the number of rounds; defaults to 1 for traditional problems, 2 for multi-pass problems if not specified.', 'unsigned' => true] + )] + #[Assert\GreaterThan(0)] + #[Serializer\Exclude] + private ?int $multipassLimit = null; + /** * @var Collection */ @@ -273,6 +288,31 @@ public function getCombinedRunCompare(): bool return $this->combined_run_compare; } + public function setMultipassProblem(bool $isMultipassProblem): Problem + { + $this->isMultipassProblem = $isMultipassProblem; + return $this; + } + + public function isMultipassProblem(): bool + { + return $this->isMultipassProblem; + } + + public function setMultipassLimit(?int $multipassLimit): Problem + { + $this->multipassLimit = $multipassLimit; + return $this; + } + + public function getMultipassLimit(): int + { + if ($this->isMultipassProblem) { + return $this->multipassLimit ?? 2; + } + return 1; + } + public function setProblemstatementFile(?UploadedFile $problemstatementFile): Problem { $this->problemstatementFile = $problemstatementFile; diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 5731d70d64..2351a78bc5 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -1439,6 +1439,7 @@ public function getRunConfig(ContestProblem $problem, Submission $submission): s 'output_limit' => $outputLimit, 'process_limit' => $this->config->get('process_limit'), 'entry_point' => $submission->getEntryPoint(), + 'pass_limit' => $problem->getProblem()->getMultipassLimit(), 'hash' => $runExecutable->getHash(), ] ); diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index 64d5059137..26474e5a00 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -283,10 +283,15 @@ public function importZippedProblem( if (isset($yamlData['validation']) && ($yamlData['validation'] == 'custom' || - $yamlData['validation'] == 'custom interactive')) { + $yamlData['validation'] == 'custom interactive' || + $yamlData['validation'] == 'custom multi-pass')) { if (!$this->searchAndAddValidator($zip, $messages, $externalId, $yamlData['validation'], $problem)) { return null; } + + if ($yamlData['validation'] == 'custom multi-pass') { + $problem->setMultipassProblem(true); + } } if (isset($yamlData['limits'])) { @@ -296,6 +301,9 @@ public function importZippedProblem( if (isset($yamlData['limits']['output'])) { $yamlProblemProperties['outputlimit'] = 1024 * $yamlData['limits']['output']; } + if (isset($yamlData['limits']['validation_passes'])) { + $problem->setMultipassLimit($yamlData['limits']['validation_passes']); + } } foreach ($yamlProblemProperties as $key => $value) { diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index 380054dbf3..70de500b0f 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -109,6 +109,12 @@ {{ problem.specialCompareArgs }} {% endif %} + {% if type is not empty %} + + Type + {{ type }} + + {% endif %} diff --git a/webapp/templates/jury/submission.html.twig b/webapp/templates/jury/submission.html.twig index 4a540d8967..589c6dde95 100644 --- a/webapp/templates/jury/submission.html.twig +++ b/webapp/templates/jury/submission.html.twig @@ -711,6 +711,9 @@
{% if run.firstJudgingRun is not null %} + {% if run.firstJudgingRun.runresult != 'correct' and isMultiPassProblem %} +
Note that this is a multi-pass problem, and only data of the failing pass is presented.
+ {% endif %} {{ runsOutput[runIdx].metadata | printMetadata }}