Skip to content

Interactive writes fail with EPIPE after the other side exits, unlike a judge that keeps draining #101

Description

@TakalaWang

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions