v0.31.0 Phase 2 Owning Output Lifecycle Review
Result: passed (2026-09-03). Phase 2 exposes the adopted owning output contract and composes it from the private descriptor facts completed in Phase 1. No asynchronous child or compiler-owned process behavior was introduced.
Reviewed Boundary
The review covers public declaration shape, pre-fork preparation, fork authority, child descriptor installation, parent write-end closure, simultaneous drain, bounded buffer growth, EOF authority, failure precedence, exact-child observation, status-path regression, source responsibility, and native behavior beyond finite pipe capacity.
Public Contract
std/process exposes exactly one additional owning value and one consuming operation:
pub struct Output {
pub status: ExitStatus
pub stdout: Vec<u8>
pub stderr: Vec<u8>
}
instance Command {
pub method self.output(): Output!
}
The output fields are bytes rather than text, so embedded NUL and invalid UTF-8 are ordinary data. No raw descriptor, target event, launch stage, scratch buffer, or child resource enters the public representation.
Ownership and Transition Model
CommandFork is the sole fork-level transition. It prepares stable path and argument storage, creates the launch-report owner, invokes fork once, and returns either a child or parent value that owns the prepared command and report. Both status and output consume this transition. The status path therefore does not create capture storage, while the two operations cannot acquire different launch or fork rules.
Output prepares CaptureSession before entering CommandFork. The session owns stdout and stderr pipe pairs, both result vectors, an 8 KiB scratch allocation, and endpoint liveness. In the child, capture_launch_darwin.nct closes the read ends, installs the normalized writers as descriptors 1 and 2, closes the superseded writers, and delegates executable replacement to the same launch- report function used by status. A setup syscall failure becomes a staged report; an impossible successful dup2 result becomes a deliberately malformed report rather than clean launch EOF.
In the parent, CaptureSession closes both writer copies before polling. One iteration reads at most one bounded chunk from each ready stream in stdout-then-stderr order. An interrupted read does not suppress service of the other ready stream. Hangup triggers a read and only a zero-byte read closes that stream, so unread tail bytes cannot be discarded. Readiness or read failure closes both read ends inside the owning session before control returns to process policy.
Both parent operations use observe_terminal. Output drains or closes every capture endpoint before this exact-child wait. A wait failure is selected before launch or capture failure because it is the only outcome that prevents proof of terminal observation. After a successful wait, exec rejection retains its existing specific error, setup rejection becomes std.process.capture_failed, and capture transition failure becomes the same stable error.
Native Evidence
The private process target now executes ten authored native cases. The new session case writes distinct byte sequences into both owning pipe pairs, lets the session close its own writer copies, and proves exact separated output after both EOF transitions.
The public native case launches a repository-created helper that alternates 64-byte stdout and stderr writes 4,096 times, then appends distinct NUL and non-UTF-8 bytes to both streams and exits with code 23. Each captured vector contains exactly 262,146 bytes in its own order. Because either stream exceeds ordinary pipe capacity while writes alternate, the test would not terminate if the parent drained one descriptor to EOF before reading the other. The same case proves that a missing executable remains std.process.not_found through output.
Existing Command.status argument, environment, exit-code, signal, exec-rejection, repeated- failure, and exact-child lifecycle tests pass through the common CommandFork transition.
Authority Review
- public source owns only
OutputandCommand.outputsemantics; command_darwin.nctalone maps typed private facts to public process failures;CommandForkalone prepares and forks a command;CaptureSessionalone owns stream buffers, liveness, drain fairness, and failure cleanup;capture_launch_darwin.nctalone maps capture pipes to standard child descriptors;launch_report_darwin.nctalone executes image replacement and reports its rejection;- Phase 1 remains the sole owner of raw pipe, poll, read, and wait facts.
No layer reinterprets a pollfd, repeats fork result classification, duplicates exec, or asks a caller to close a correlated set of raw descriptors. The compiler still exposes only generic syscall and process-entry roles and does not know the public Output type.
Remaining Work
Phase 3 must expand lifecycle qualification around empty streams, signal output, setup rejection, closed inherited standard descriptors, and descriptor cleanup. Phase 4 will add a practical public package and editor coverage. These are qualification and integration tasks; Phase 2 has no open implementation or responsibility-boundary finding.