stdio 0.4.0
stdio: ^0.4.0 copied to clipboard
File-descriptor-level (dup2) capture and redirection of stdout/stderr, including native/FFI and child-process output invisible to Dart streams. POSIX only.
Changelog #
0.4.0 — 2026-07-19 #
Pre-publish consolidation: correctness fixes at the fd layer plus a final API finalization pass (breaking — 0.4.0 was never published).
Correctness
stop()now restores the saved descriptors' original file-status flags. The mirror-to-original path setsO_NONBLOCKon the saved fds, and the flag lives on the open file description — shared with the fd 1/2 handed back on stop (and with the parent shell when the target is the tty) — so post-stop writes couldEAGAIN, and the leak outlived the process.maxLineBytesis now enforced on newline-terminated lines too: a long line arriving within one read chunk previously bypassed the cap entirely. Runs are split byte-exactly into cap-sized pieces (an exactly-cap-sized line stays whole); the README's split guarantee is now unconditionally true.- A reader-isolate death mid-session now restores fd 1/2 immediately
(previously they stayed pointed at pipes nobody drained, and the app's next
~64 KiB of prints wedged the main isolate in a blocking
write()).pause()/resume()/startProcess/adoptthrowStateErroron a degraded session;readerError+ closed streams remain the signal. - Saved-fd mirror overflow drops are now counted on the new dedicated
mirrorDroppedBytescounter (they were silent; they are a separate channel from the line counters, which they deliberately do not pollute). droppedLines/droppedBytesnow count exactly the lines missing fromhistory: reader-side backpressure drops PLUS history-ring evictions (evictions were previously uncounted, making the documented totals wrong).close()is no longer retried onEINTR(on Linux/macOS the fd is deallocated regardless, so the retry was a double-close that could hit an unrelated reallocated fd).stop()'s drain-timeout path no longer closes fds a wedged-but-alive reader may still be blocked inread()/poll()on (deliberate bounded leak instead of fd-reuse byte theft), and the backstopIsolate.kill(immediate)is now issued only on that path — it can no longer race a healthy reader's cleanup.startProcess()/adopt()afterstop()now throwStateErrorinstead of silently discarding the child's output.- A throwing
classifycallback no longer burns reader send credits (which permanently stalled the live feed after ~8 throws): the line is treated as unclassified and the first error is surfaced once onreaderError. open(2)is bound throughVarArgslike the other variadic syscalls.StdioRedirect.stop()releases the process-wide slot in afinally, so a (nearly impossible) restore failure can no longer wedge the package with no way to start a fresh session.
Breaking API changes
- Renamed the main class
StdioCapture→Stdio, so the entry points read asStdio.capture/Stdio.start/Stdio.redirectToFile(matching the package name). Capturedis nowCaptured<T>with avaluefield:Stdio.capture<T>()returns the body's result alongside the transcript, andstop()returnsFuture<Captured<void>>.Capturedalso gaineddroppedLines/droppedBytes(lines missing fromlines).Stdio.capture()acceptshistoryLines/maxLineBytes/mirrorToFile/classify, forwarded tostart().startProcess()returnsCapturedProcess(theprocessplus adrainedfuture that completes on full delivery);adopt()now returnsCapturedProcesstoo (it was the bare drain future — ambiguous at the call site) and throws a clearStateErrorwhen the child's streams were already claimed (adopted twice / listened before adopt).- Renamed
mirrorToSavedFds:→mirrorToOriginal:(introduced unpublished after 0.3.0), with real documentation and, new in this release, drop accounting (mirrorDroppedBytes) and end-to-end tests. terminalis now typedStdoutTerminalSink— anIOSinkwith the terminal accessors and a concreteStdout— and the redundantterminalStdoutis gone (it was a second mutable-encoding handle to the same fd).terminalStderr(introduced unpublished after 0.3.0) stays, as the saved fd 2 counterpart. The abstractTerminalSinkinterface is gone too: it had no consumers and both implementations are final, soFdTerminalSinkis now the documented base type (nullable-safecolumns/rows;StdoutTerminalSinkadds the throwing Stdout-contractterminalColumns/terminalLinesview of the same data).CapturedLinegainedseq, a monotonic per-session sequence number stamped as each line enters the transcript — the robust history→stream stitch acrossawaits (the constructor now requires it).CapturedLine.textstrips one trailing\r(CRLF output reads clean);bytesstays byte-exact.StdoutTerminalSink.writelnhonorslineTerminator(payload\ns still pass through untranslated).StdioExceptionmoved to its own library and is now exported (the 0.4.0 pre-release changelog wrongly called the non-export deliberate: callers could neither catch it by type nor readreaderErrorusefully).- New
Stdio.anyActivestatic — advisory "is any capture/redirect holding fd 1/2" probe for coordination; the uncoordinated secondstart()still throwsStateError. - pubspec:
platforms: linux, macosdeclared explicitly (POSIX-only).
Docs
- The README and library summary are general-purpose — capturing native and child output is the headline; the terminal handle is one use among several.
- Honest transcripts:
stop()/capture()return the retained transcript (the lasthistoryLineslines; seedroppedLines) — the docs no longer claim everything is guaranteed present, and note that a throwingcapture()body loses the transcript (usestart()/stop()for post-mortems). - Documented: main-isolate-only entry points, the double-start
StateErroron every entry point,redirectToFilemoving BOTH descriptors, the FIFO-mirror caveat,readerErrorcarrying the stringified isolate error, the gap-free history→subscribe recipe (andseqstitching), and the sinkflush/closeno-op semantics on the sink type itself. dart run stdio:verifyis mentioned as the platform self-check.
0.3.0 — 2026-07-05 #
pause()/resume()/isPaused: temporarily point fd 1/2 back at the real terminal without tearing the session down — the terminal-handoff primitive for TUIs (spawn$EDITOR/a pager withinheritStdiomid-session; the child inherits the real descriptors). Buffered writers are flushed at both edges so bytes land on the side of the boundary they were written on.stop()remains safe while paused.
0.2.0 — 2026-07-04 #
API finalization (breaking, pre-publish) + production-hardening.
Renamed: stdio_capture → stdio (import package:stdio/stdio.dart).
The scope is the process's stdio descriptors — capture, scoped capture,
reroute, and the saved-terminal handle. stdin is deliberately untouched (a
TUI keeps reading keys); fd-level stdin injection is a natural future
addition.
API — the surface now mirrors Process.start/Process.run:
collect()→capture()(returnsCaptured, as before).divertToFile()→redirectToFile()(matchesStdioRedirect).- The combined feed is a real stream:
capture.listen(...)→capture.output(Stream<CapturedLine>, composable likestdout/stderr). stop()now returnsFuture<Captured>— the same transcriptcapture()yields.backlogLines:→historyLines:(names what it bounds:history).CapturedLine.rawBytes→bytes;CapturedLineis now immutable (sourceis final — the classifier tags via copy).startProcessgainedrunInShell/includeParentEnvironmentpass-throughs.FdTerminalSinkis exported (it was the hidden supertype ofStdoutTerminalSink).- If the reader isolate dies mid-session the line streams now close (listeners
get
onDone) with the cause onreaderError, instead of going silent.
Hardening (see the 0.1.x → 0.2.0 commit for the full story):
- Root-fixed a variadic-FFI ABI bug:
fcntl/ioctl(andopen) are variadic in C, and fixed-arity bindings pass the third arg in a register while arm64-macOS callees read the varargs stack —F_SETFLset random flags (O_NONBLOCK intermittently missing → blocking drains → data loss on fast stops) andTIOCGWINSZnever worked. Bound withVarArgs;setNonBlockingis verify-or-throw. stop()restores fd 2 from its own saved dup (it was restored from fd 1's, silently re-routing stderr whenever they differed).capture()restores the redirect even when the body throws.- Reader lifecycle notifications share the data port (cross-port delivery has no ordering guarantee; a separate exit port could outrun queued batches).
- Mirror file opens (and fails) at
start()instead of inside the reader. - O(1) ring buffers; bounded, verified non-blocking end to end.
- Platform coverage verified: the full suite + fast-stop stress probe pass on
macOS arm64 and in Linux arm64 + amd64 containers — all three varargs ABI
conventions the
VarArgsbindings must satisfy. maxLineBytes(default 64 KiB) bounds the in-progress line: a writer that never emits\nis delivered in cap-sized pieces instead of growing memory without bound (makes the "never OOMs" guarantee unconditional).- README no longer implies the package installs signal handlers (it never
did, deliberately): restore is
stop()'s job — wire your signal handling to call it.
0.1.0 — 2026-07-04 #
Initial implementation: fd-level dup2 capture of stdout/stderr with a
dedicated drain isolate (bounded backpressure, credit-based delivery,
self-pipe control), line assembly, history ring, durable mirror file,
subprocess tagging, classifier hook, scoped capture, file redirect, and the
saved-terminal render sinks (TerminalSink/StdoutTerminalSink).