v0.31.0: Captured Synchronous Subprocess Output
Status: complete, published, and externally audited (2026-09-03). v0.31.0 extends the closed synchronous process lifecycle with complete standard-output and standard-error capture. The release boundary remains intentionally smaller than an asynchronous child API: one consumed command creates one child, drains both output pipes without deadlock, observes termination, and returns an owning result.
Completion Boundary
Command.outputcaptures arbitrary stdout and stderr bytes while inheriting stdin, environment, and working directory;Outputowns oneExitStatus, one stdoutVec<u8>, and one stderrVec<u8>;- both finite pipes are drained concurrently rather than sequentially;
- child setup failure is unambiguous and cannot be mistaken for an exit status;
- descriptor collisions with previously closed standard streams cannot corrupt the launch protocol;
- every representable post-fork error path closes owned descriptors and attempts exact-child reap;
- native stress executes simultaneous output substantially larger than pipe capacity;
- editor, formatter, examples, installed-home, complete-workspace, and reproducible-package gates consume the same public declarations;
- final review finds no duplicate pipe authority, target ABI leakage, caller-discipline lifecycle, or obsolete compatibility surface.
The normative contract is spec/31-subprocess-output.md. Release identity, reproducible archive generation, publication, and public re-download evidence are owned by the release-preparation and immutable release records.
Accepted Design
The result streams are Vec<u8>, not String, because operating-system output is not necessarily UTF-8. Text conversion remains an explicit caller decision through String.from_utf8. The two vectors preserve their own byte order; the API does not invent an ordering between independent descriptors.
Command.output remains synchronous and consuming. Adding an independently owned Child would require wait, termination, detach, destruction, and nonblocking contracts that are unnecessary for capture and would reopen the closed v0.30.0 lifecycle.
The Darwin implementation will use poll over two read descriptors. Sequential read_to_end calls are rejected because the unobserved pipe can fill and deadlock the child. select is also rejected for this boundary because its fixed descriptor-set representation introduces a descriptor-number limit that the public contract does not need. poll expresses exactly the two readiness records and has no FD_SETSIZE dependency.
Every pre-fork pipe endpoint is normalized to descriptor 3 or greater and marked close-on-exec. This protects redirection when a host launches Nocter with descriptor 1 or 2 already closed. One private pipe owner will serve launch reports and captured streams so close-on-exec setup, collision avoidance, and close transitions do not acquire competing implementations.
The launch report expands from raw exec errno to a fixed stage-plus-errno payload. Clean close-on-exec EOF still proves successful exec. A payload distinguishes standard-descriptor setup from exec rejection without formatting, allocation, or ordinary Nocter return in the child.
Phase 0: Contract and Feasibility
Status: complete. The future-direction chapter fixes the public owning result, byte semantics, concurrent-drain rule, EOF behavior, launch stages, stable failures, allocation policy, lifecycle, responsibility split, and non-goals.
The installed Darwin SDK defines dup2 as syscall 90 and poll as syscall 230. It defines struct pollfd as an eight-byte record containing a 32-bit descriptor, 16-bit requested events, and 16-bit returned events. POLLIN, POLLERR, POLLHUP, and POLLNVAL provide the required readable and terminal facts. Existing Nocter target capabilities already expose generic two- and three-argument syscall roles, so no parser, checker, semantic representation, MIR, machine, or image-format change is required.
The Phase 0 review closes the design hazards before implementation: two-pipe deadlock, descriptor-number collision, hangup with unread bytes, child setup ambiguity, capture-error cleanup, unbounded-byte interpretation, and accidental compiler ownership of process policy.
Phase 1: Raw Descriptor and Readiness Boundary
Status: complete. OwnedPipe is now the sole close-on-exec creation and endpoint-lifetime authority. It normalizes both raw endpoints to descriptor 3 or greater with F_DUPFD_CLOEXEC, closes intermediate descriptors on every construction result, makes explicit endpoint close idempotent, and closes every remaining endpoint through its drop body. The published Command.status path consumes that owner instead of transporting two unrelated descriptor integers.
The launch report now carries one fixed eight-byte stage-plus-errno payload. Clean EOF still proves successful exec; setup rejection, exec rejection, channel failure, partial payload, unknown stage, and zero errno are closed typed results. Fork/wait transitions and launch reporting are separate source responsibilities rather than a growing subprocess utility file.
The target-specific capture boundary owns the exact two-record pollfd representation, infinite timeout word, supported event vocabulary, inactive descriptor, readiness-count validation, and bounded-read result classification. Actual Darwin tests cross F_DUPFD_CLOEXEC, F_GETFD, dup2, poll, pipe hangup, EOF, and failed read. Deterministic classification tests cover interruption and malformed kernel facts without depending on signal timing.
The Phase 1 review finds no raw descriptor leak, duplicate pipe or launch authority, target record leakage, obsolete active compatibility path, or compiler change. The public Output and Command.output declarations remain unavailable until Phase 2.
Phase 2: Owning Output Operation
Status: complete. The public module now exposes the transparent owning Output record and the consuming Command.output method exactly as specified. Target-specific sources keep capture state, scratch storage, descriptor installation, and public failure selection private.
One CommandFork transition now prepares command storage, creates the launch-report owner, forks exactly once, and transfers the same closed child-or-parent fact to both status and output. Status does not allocate capture buffers. Output prepares its two capture pipes and bounded scratch storage before entering that common transition. Both parent paths reuse one exact-child terminal observer, which makes wait failure authoritative before another public result is selected.
CaptureSession owns both pipes, both result vectors, one 8 KiB scratch buffer, and all stream liveness. It closes parent write ends before polling, reads at most one bounded chunk from each ready stream per iteration, treats read as the sole EOF authority after hangup, and closes both read ends before returning a capture failure. The child setup bridge installs descriptors 1 and 2, closes every superseded endpoint, reports setup failure through the staged launch channel, and uses the same exec transition as Command.status.
Native qualification captures 262,146 bytes from each stream while a child alternates writes, including NUL and non-UTF-8 tail bytes, and retains a nonzero exit status. This exceeds finite pipe capacity and would deadlock under sequential whole-stream reads. A missing executable retains the existing std.process.not_found failure, and the unchanged status lifecycle tests continue to pass. The Phase 2 review closes the implementation boundary.
Phase 3: Native Failure and Lifecycle Qualification
Status: complete. Public native qualification now checks empty output, exact ordinary text, embedded NUL, non-UTF-8 bytes, nonzero exit, signal termination with retained output, and rejected missing executables. The adversarial child still alternates writes totaling 262,146 bytes on each stream, substantially exceeding finite pipe capacity.
The complete public case runs normally and with either inherited stdout or inherited stderr closed. This crosses the real process boundary and proves that pipe normalization does not depend on descriptors 1 and 2 being occupied before launch. Forty-eight consecutive empty captures per run exercise endpoint cleanup and exact-child observation without exposing descriptors publicly.
A private native case forces standard-descriptor installation to fail after fork. The child emits the typed setup stage and errno, exits with code 127, and the parent drains both capture streams and reaps that exact child. Phase 1 classification tests remain the deterministic authority for interrupted reads and malformed readiness facts; Phase 2 remains the deadlock and arbitrary-byte authority. The Phase 3 review records the complete evidence and finds no open lifecycle or ownership issue.
Phase 4: Tooling and Practical Integration
Status: complete. The public subprocess-output package invokes its repository-owned helper.sh, passes one whitespace-bearing argument, captures both streams, verifies typed nonzero termination, converts each byte vector to UTF-8 explicitly, validates the complete text, and emits one deterministic report. The helper remains an execution fixture rather than another process implementation.
The shared public-example catalog owns the package's executable fixture and exact run contract. Native compilation, command execution, and installed-home execution consume that entry. Formatter qualification discovers the same authored .nct files, while the language-server test opens capture.nct and verifies diagnostics, hover, completion, declaration navigation, and implementation navigation against the public process declaration and private Darwin body. The Phase 4 review finds no tooling-only source or duplicated runtime contract.
Phase 5: Qualification and Review
Status: complete. Complete locked workspace tests, warnings-denied Clippy, no-default-features checking, formatter, deterministic documentation generation, native, installation, packaging-input, editor, and repository gates pass. The local-release qualifier now executes the captured-output package and compares its complete stdout and stderr against the shared example contract rather than merely compiling it.
The final review traces descriptor ownership, child setup, two-stream drain, exact-child wait, failure precedence, compiler/standard-library separation, public examples, and installed-home qualification. The Phase 5 review records no open finding. The separate release-preparation record owns candidate identity, artifact qualification, and the publication boundary.
Non-goals
- asynchronous child handles or streaming access;
- input capture or caller-provided redirection;
- merged stdout/stderr ordering;
- bounded capture or recoverable allocation exhaustion;
- timeouts, signals, process groups, or terminal control;
- shell execution,
PATHsearch, environment edits, or working-directory overrides; - another target.