Skip to content

fix(hooks): let a hook that ignores its stdin succeed - #6

Merged
umars28 merged 1 commit into
mainfrom
fix/hook-stdin-broken-pipe
Sep 13, 2026
Merged

umars28 merged 1 commit into
mainfrom
fix/hook-stdin-broken-pipe

Conversation

@umars28

@umars28 umars28 commented Sep 13, 2026

Copy link
Copy Markdown
Member

The bug

one() writes the container state to the hook's stdin and propagates the error:

.write_all(payload)
.ctx("write the container state to the hook's stdin")?;

A hook that exits without reading closes the read end first, so the write returns
EPIPE and the hook is reported as failed — even though it exited 0.

Why it is a race, not a constant failure

The payload usually fits in the pipe buffer, so the write completes before the
child is reaped and nothing looks wrong. A larger payload, or a loaded machine,
loses that race.

It showed up as a_hook_that_succeeds_is_not_an_error failing in CI on
#5, a change that
touched no Rust at all, and then passing on a re-run with nothing altered. It
passed 20 out of 20 runs locally, which is consistent with the diagnosis rather
than against it.

Why the current behaviour is wrong

The runtime-spec writes the state to the hook's stdin but makes the exit code
the verdict. A hook may ignore what it is handed, and most real ones do: they run
a command and never read anything. mars refuses those hooks non-deterministically,
more often on the busy machines where hooks matter most.

The change

BrokenPipe on that write is no longer fatal; the exit status decides, as it
already did. Every other write error still fails, and now kills the child instead
of leaving it running.

The test

a_hook_that_never_reads_its_stdin_is_not_an_error hands /bin/true a megabyte,
which cannot fit in the pipe buffer, so the write always outlives the child. It
does not depend on timing, and it fails deterministically without this change:

called `Result::unwrap()` on an `Err` value: Io {
  context: "write the container state to the hook's stdin",
  source: Os { code: 32, kind: BrokenPipe, message: "Broken pipe" } }

cargo fmt --check, cargo clippy --all-targets -- -D warnings and
cargo test --lib (107 passed) are green.

A hook that exits without reading its stdin was reported as failed. The
state is written with write_all and the error propagated, so when the
child closed the read end first the write returned EPIPE and the hook
was refused even though it had exited 0.

That is a race against the pipe buffer. A small payload usually lands
before the child goes away and the hook passes; a large one, or a busy
machine, does not. It surfaced as a_hook_that_succeeds_is_not_an_error
failing once in CI on a change that touched no Rust at all, and passing
on a re-run with nothing altered.

The runtime-spec writes the state to the hook's stdin but makes the exit
code the verdict. A hook is entitled to ignore what it is handed, and
most real ones do - they run a command and never read anything. So a
broken pipe on that write is not a failure, and the exit status decides
as it always did. Any other write error still fails, and now kills the
child rather than leaving it behind.

The new test does not depend on the race: it hands /bin/true a megabyte,
which cannot fit in the pipe buffer, so the write always outlives the
child. It fails deterministically without this change.
@umars28
umars28 merged commit ebce675 into main Sep 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant