Skip to content

Deliver a leader's buffered output before Exited - #351

Merged
SaladDay merged 3 commits into
feature/agent-outside-sandboxfrom
aos/exit-order
Oct 1, 2026
Merged

SaladDay merged 3 commits into
feature/agent-outside-sandboxfrom
aos/exit-order

Conversation

@SaladDay

@SaladDay SaladDay commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

This change to the Process protocol comes from the #348 review. A client that mirrors a native child, such as the process broker, must see all of the leader's output before it sees Exited, as a native parent does once waitpid returns. Before this change Exited could overtake output still buffered in the service's pipe. A Harness that read a stdout file on exit then got truncated output.

Protocol (docs/process-protocol.md)

  • Exited follows every output byte buffered in a captured stream when the service reaps the leader, and it never waits for an acknowledgement. The rule names its exceptions:
    • a stream abandoned by CloseOutput, or lost to a read failure, delivers no more output;
    • output that the replay limit keeps the service from reading follows Exited;
    • with a PTY, output still in the kernel's asynchronous queue to the master at the reap can follow Exited, and a terminal flush discards output as it does natively.
  • Output written after the reap, by processes that still hold a stream, can follow Exited, as it does natively.
  • Flow control: the service stops reading when less than one memory page of the replay limit remains. A read never splits a packet-mode pipe write.

The protocol code (internal/sandboxprocess) needs no type change.

Linux service

  • Watermark: when the leader is reaped, each open stream records its delivered bytes plus TIOCINQ. Exited is pushed once every stream has reached its mark, closed or been abandoned, or once less than a page of the replay limit remains.
  • Non-blocking reads: before the target runs, every output descriptor is set to O_NONBLOCK and checked to be registered with the runtime poller. Otherwise the launch fails with IO and EffectNone. A read under the operation lock therefore never blocks, and the watermark is exact.
  • Whole packets: a read always has at least a page of room, so an O_DIRECT pipe never loses the rest of a packet.
  • Status: it reports Running until the Exited event exists.
  • Discarded terminal output: an empty read after the reap lowers the mark, so a terminal flush cannot stall Exited.

Checks

  • Tests:
    • TestOutputPrecedesExited holds the readers through a test seam until the reap. It fails 5 of 5 runs with the watermark disabled.
    • TestLaterOutputFollowsExited uses a FIFO handshake.
    • TestPacketsAtReplayLimit sends 20 × 4096-byte O_DIRECT packets. It fails on the previous code, with a 1000-byte short read.
  • Commands: race tests, -count=20 on the new tests, go test ./apps/sandboxio/... ./internal/sandboxprocess, vet and gofmt.
  • Review: blind review by a new session. All four findings are fixed.

The process protocol now orders Exited after every output byte buffered
in a captured stream when the service reaps the leader, as a native
parent sees all of its child's output once waitpid returns. Output
written later by processes that still hold the stream can follow
Exited. Exited never waits for an acknowledgement: when the replay limit
stops reading, it follows the output read until then. With a PTY, output
still in the kernel's asynchronous terminal queue at the reap can follow
Exited.

The Linux service records each open stream's mark at the reap, which is
the offset it has delivered plus FIONREAD/TIOCINQ, and pushes Exited once
every stream reaches its mark, closes or is abandoned, or once the
retained output reaches the replay limit. Readers now read non-blocking
and push under the operation lock, so no byte is uncounted between a
read and its event. That also makes the replay budget exact and removes
the per-stream reservations. An empty read lowers the mark, so a
terminal flush cannot leave it unreachable. CloseOutput closes the file
outside the lock because Close waits for a read in progress.
@SaladDay
SaladDay merged commit f39aaa3 into feature/agent-outside-sandbox Oct 1, 2026
11 checks passed
The launch now fails with EffectNone unless each output descriptor is
non-blocking and watched by the runtime poller, so a read under op.mu
never blocks. A read always has at least a page of room under the replay
limit, so a packet-mode pipe never loses the rest of a packet; with less
room the reader waits for acknowledgements and the exit gate treats it as
the limit.

The ordering rule now names its exceptions: an abandoned or lost stream,
output held back by the replay limit, and the PTY residual. The ordering
tests hold the readers through a test seam and use a FIFO handshake
instead of scheduling and sleeps.
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