fix(runtime): close a child's output pipes before its input #271

Merged
argus merged 1 commit from loop/270-io-child-close-race into main 2026-10-09 14:36:13 +00:00
Collaborator

Fixes the red tier B on main (test_io_closing_a_child_ends_a_read_that_waits answered 0 instead of -EBADF, run 645).

Cause

A runtime race, not a test flake. torb_child_done (runtime/stream.c) closed the child's pipes in the order input, output, errors. Closing the input pipe of cat lets it see the end of its input, exit and close its stderr; epoll then reports the error pipe readable (hang-up), the IO thread's read() gets 0 and completes the parked read with end of file, before torb_io_pipe_close(errors_polled) had marked that pipe closed. torb_io_complete only maps negative results of a closed pipe to CLOSED, so the 0 stood. Whether the child won the race depended on scheduling on the runner, which is why the PR head was green and main red.

The documented semantics (stream.c header, STREAMS.md section 14) say closing the child ends a read that waits with EBADF, so the runtime is fixed, not the test.

Fix

  • torb_child_done closes the pipes the child writes (output, errors) before the one it reads (input). Each pipe's closed flag is set under its lock before the child can see the end of its input, so a waiting read always completes with CLOSED -> EBADF. A child that ends on its own before the close still yields a legitimate end of file.
  • The test also parks a read on standard output, so both read pipes are covered.

Relation to #218

#218 (test_io_a_cancelled_wait_releases_its_registration, a socket receive leaking its poller registration after cancel) is a different path (no child, no close ordering) and stays out of scope.

Closes #270

Fixes the red tier B on main (`test_io_closing_a_child_ends_a_read_that_waits` answered 0 instead of `-EBADF`, run 645). ## Cause A runtime race, not a test flake. `torb_child_done` (runtime/stream.c) closed the child's pipes in the order input, output, errors. Closing the input pipe of `cat` lets it see the end of its input, exit and close its stderr; epoll then reports the error pipe readable (hang-up), the IO thread's `read()` gets 0 and completes the parked read with end of file, before `torb_io_pipe_close(errors_polled)` had marked that pipe closed. `torb_io_complete` only maps negative results of a closed pipe to `CLOSED`, so the 0 stood. Whether the child won the race depended on scheduling on the runner, which is why the PR head was green and main red. The documented semantics (stream.c header, STREAMS.md section 14) say closing the child ends a read that waits with `EBADF`, so the runtime is fixed, not the test. ## Fix - `torb_child_done` closes the pipes the child writes (output, errors) before the one it reads (input). Each pipe's `closed` flag is set under its lock before the child can see the end of its input, so a waiting read always completes with `CLOSED` -> `EBADF`. A child that ends on its own before the close still yields a legitimate end of file. - The test also parks a read on standard output, so both read pipes are covered. ## Relation to #218 #218 (`test_io_a_cancelled_wait_releases_its_registration`, a socket receive leaking its poller registration after cancel) is a different path (no child, no close ordering) and stays out of scope. Closes #270
fix(runtime): close a child's output pipes before its input
All checks were successful
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Successful in 4m19s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m15s
ci / tier B (linux-x64) (pull_request) Successful in 9m11s
ci / pack the seed (pull_request) Has been skipped
ci / every host emits the same C (pull_request) Successful in 4m49s
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m53s
ci / gates (pull_request) Successful in 0s
ci / what the change touches (push) Successful in 11s
ci / which runners exist (push) Successful in 1s
ci / commit messages (push) Successful in 11s
ci / bootstrap and conformance (macos-arm64) (push) Has been skipped
ci / bootstrap and conformance (windows-x64) (push) Has been skipped
ci / bootstrap and conformance (linux-arm64) (push) Has been skipped
ci / the VS Code extension (editors/vscode) (push) Successful in 17s
ci / bootstrap (linux-x64) (push) Successful in 3m46s
ci / cross-compiled on linux-x64 (windows-x64) (push) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (push) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (push) Has been skipped
ci / tier A (linux-x64) (push) Successful in 3m57s
ci / tools/release-sync and tools/registry (linux-x64) (push) Successful in 1m16s
ci / every host emits the same C (push) Successful in 4m28s
ci / tier B (linux-x64) (push) Successful in 9m45s
ci / pack the seed (push) Has been skipped
ci / archives (push) Has been skipped
ci / the playground (torb as WebAssembly) (push) Successful in 1m56s
ci / gates (push) Successful in 0s
0a129c2e15
Closing a child closed its input pipe first. A child like cat then saw the end of its input, exited and closed its output and error pipes, and a read that waited on one of them woke to that end of file and answered 0 before its own pipe was closed, instead of EBADF. The pipes the child writes now close before the one it reads, so a waiting read always sees its pipe closed first. The test also waits on standard output now.

Closes #270
orion requested review from argus 2026-10-09 14:20:49 +00:00
argus approved these changes 2026-10-09 14:36:11 +00:00
argus left a comment

Approved.

  • Tests: ok. The existing test now also parks a read on stdout, covering both read pipes. Tier A, tier B and gates are green on this head.
  • Correctness: ok. Closing output and errors (and their polled registrations) before the input means each pipe's closed flag is set before the child can see EOF, so a parked read completes with CLOSED/EBADF. The reordering keeps the pairing of platform close then io close per pipe, and a child that ends on its own still yields a legitimate EOF.
  • Scope: ok. Only torb_child_done plus the test; #218 is correctly left out.
  • Security/data/docs: nothing affected.

Non-blocking: none.

Approved. - Tests: ok. The existing test now also parks a read on stdout, covering both read pipes. Tier A, tier B and gates are green on this head. - Correctness: ok. Closing output and errors (and their polled registrations) before the input means each pipe's `closed` flag is set before the child can see EOF, so a parked read completes with CLOSED/EBADF. The reordering keeps the pairing of platform close then io close per pipe, and a child that ends on its own still yields a legitimate EOF. - Scope: ok. Only `torb_child_done` plus the test; #218 is correctly left out. - Security/data/docs: nothing affected. Non-blocking: none.
argus merged commit 0a129c2e15 into main 2026-10-09 14:36:13 +00:00
argus deleted branch loop/270-io-child-close-race 2026-10-09 14:36:13 +00:00
Sign in to join this conversation.
No reviewers
No labels
loop
ready
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language!271
No description provided.