Summary
When one side of an interact session exits, forge closes its pipe ends. If the other side then writes to it, the write fails with EPIPE. Both hosts do this: the server (crates/runtime-core/src/interactive.rs, virtual_fs::Pipe) and the browser nested Workers with shared-memory pipes (#99).
Judges that run the two programs natively do something different. The judge sits between the programs and keeps draining each one's stdout until it exits. Bytes meant for a side that has gone are thrown away. The writer never gets EPIPE; its next read gets EOF, and it exits with its own verdict.
The common case where this matters: the contestant prints its final answer and exits without reading the reply. The interactor writes correct, gets EPIPE, and fails, so the case comes out as a system error instead of being judged on the contestant's actual result.
Reproduction (0.2.4)
The contestant prints 7 and exits. A CPython interactor (DOMjudge exit codes):
import sys
guess = int(input())
print("correct", flush=True)
try:
input()
except EOFError:
pass
sys.exit(42)
| Host |
Interactor |
interactorToContestant |
| Server, 3 of 3 runs |
exit 120, BrokenPipeError: [Errno 64] Broken pipe |
correct\n × 5 |
| Chromium |
exit 120 |
correct\n × 5 |
| Firefox |
exit 120 |
correct\n × 5 |
The interactor's stdout wrapper writes into the transcript capture before it writes to the pipe. CPython retries the failed flush, so every retry adds the line to the transcript again. NOJV also sees the line twice when the EPIPE happens in the middle of a dialogue.
A C interactor that does puts("correct"); fflush(stdout); loses the newline: the transcript has correct without \n, because wasi-libc writes the two iovecs separately and the second one fails. The exit code depends on whether the program checks fflush.
The same thing happens the other way round. A contestant that writes after the interactor exited gets EPIPE (interactive-interactor-exits in scripts/verify-browser-csp.mjs expects exit 32 for this).
Proposal
Make both hosts behave like a judge that keeps draining:
- Once a side has exited, or closed its stdin, writes from its peer still succeed. The bytes are dropped and the peer keeps running; no
EPIPE.
- Reads from a side that has exited, or closed its stdout, return the bytes still buffered, then EOF. Both hosts already do this.
- The transcript records each byte a side writes exactly once, up to that side's output limit. That includes bytes written after the peer exited. A judge wants to see the interactor's final
correct/wrong line, and it keeps the transcript deterministic: it depends only on what the writer wrote, not on when the reader exited. Those bytes still count toward the writer's output limit, so a writer that keeps writing to a dead peer still hits output-limit.
Implementation: on the server, InteractiveOutput::poll_write treats BrokenPipe from PipeTx as a successful write; the bytes are already in the capture. In the browser, StreamOutput::poll_write does the same when the shared-memory pipe reports that the reader closed. Native and web runtime-core both change, so the runtime is rebuilt and its identity pins refreshed.
Summary
When one side of an
interactsession exits, forge closes its pipe ends. If the other side then writes to it, the write fails withEPIPE. Both hosts do this: the server (crates/runtime-core/src/interactive.rs,virtual_fs::Pipe) and the browser nested Workers with shared-memory pipes (#99).Judges that run the two programs natively do something different. The judge sits between the programs and keeps draining each one's stdout until it exits. Bytes meant for a side that has gone are thrown away. The writer never gets
EPIPE; its next read gets EOF, and it exits with its own verdict.The common case where this matters: the contestant prints its final answer and exits without reading the reply. The interactor writes
correct, getsEPIPE, and fails, so the case comes out as a system error instead of being judged on the contestant's actual result.Reproduction (0.2.4)
The contestant prints
7and exits. A CPython interactor (DOMjudge exit codes):interactorToContestantBrokenPipeError: [Errno 64] Broken pipecorrect\n× 5correct\n× 5correct\n× 5The interactor's stdout wrapper writes into the transcript capture before it writes to the pipe. CPython retries the failed flush, so every retry adds the line to the transcript again. NOJV also sees the line twice when the
EPIPEhappens in the middle of a dialogue.A C interactor that does
puts("correct"); fflush(stdout);loses the newline: the transcript hascorrectwithout\n, because wasi-libc writes the two iovecs separately and the second one fails. The exit code depends on whether the program checksfflush.The same thing happens the other way round. A contestant that writes after the interactor exited gets
EPIPE(interactive-interactor-exitsinscripts/verify-browser-csp.mjsexpects exit 32 for this).Proposal
Make both hosts behave like a judge that keeps draining:
EPIPE.correct/wrongline, and it keeps the transcript deterministic: it depends only on what the writer wrote, not on when the reader exited. Those bytes still count toward the writer's output limit, so a writer that keeps writing to a dead peer still hitsoutput-limit.Implementation: on the server,
InteractiveOutput::poll_writetreatsBrokenPipefromPipeTxas a successful write; the bytes are already in the capture. In the browser,StreamOutput::poll_writedoes the same when the shared-memory pipe reports that the reader closed. Native and web runtime-core both change, so the runtime is rebuilt and its identity pins refreshed.