Streaming & interactive I/O

Previous: Overview

The one-shot verbs in Running commands buffer the whole output and hand it back when the child exits. For a long-running or conversational child you want the output as it arrives — and sometimes a back-channel to write to it. Command.StartAsync() (and the equivalent IProcessRunner.Start / ProcessGroup.Start) returns a live RunningProcess you drive yourself: stream stdout line by line, stream stdout as byte chunks, interleave stdout and stderr, write stdin incrementally, wait for the child to become ready, race several children, or profile a run end to end.

The samples below run inside a task { } block and use match!; the verbs that return a value directly (WaitAsync, ProfileAsync, WaitAllAsync) use a plain let!. From C# the same surface is await-able fluent methods, and the IAsyncEnumerable<_> streams are await foreach.

Lifecycle

The detailed states, ownership claims, and teardown transitions are documented in Lifecycle state machine.

StartAsync() spawns the child and returns a RunningProcess without waiting for it to exit. The handle is an IAsyncDisposable: a use binding inside task { } reaps the whole process tree on scope exit, exactly like the disposal at the end of a one-shot run.

F#

task {
    match! (Command.create "dev-server").StartAsync() with
    | Error err -> eprintfn $"could not start: {err.Message}"
    | Ok proc ->
        use _ = proc // disposing the handle kills the whole tree

        printfn $"pid={proc.Pid} started {proc.StartTime:o}"
        // ... drive the process: stream, write stdin, probe for readiness ...
        printfn $"alive for {proc.Elapsed}; {proc.StdoutLineCount} stdout lines so far"

        let! outcome = proc.WaitAsync() // Outcome: Exited code / Signalled sig / TimedOut
        printfn $"exited: {outcome}"
}

C#

await using var proc = (await new Command("dev-server").StartAsync()).GetValueOrThrow(); // disposing the handle kills the whole tree

Console.WriteLine($"pid={proc.Pid} started {proc.StartTime:o}");
// ... drive the process: stream, write stdin, probe for readiness ...
Console.WriteLine($"alive for {proc.Elapsed}; {proc.StdoutLineCount} stdout lines so far");

var outcome = await proc.WaitAsync(); // Outcome: Exited code / Signalled sig / TimedOut
Console.WriteLine($"exited: {outcome}");

StartAsync() puts the child in a private group the handle owns: dropping the RunningProcess kills the tree, grandchildren included. The shared-group variant — group.StartAsync(cmd) — returns the same kind of handle, but the group controls the tree's fate (see Process groups).

Consume the handle exactly one way — stdout is read once:

  • StdoutLinesAsync() / OutputEventsAsync() — stream output as it arrives (below).
  • StdoutChunksAsync() / StderrChunksAsync() — stream one stream's raw bytes (below).
  • OutputStringAsync() / OutputBytesAsync() — capture everything, like the one-shot verbs.
  • WaitAsync() — just the Outcome; output is discarded.
  • FinishAsync() — after streaming, collect the Outcome and (unless you streamed it) drained stderr.
  • ProfileAsync() — capture plus periodic resource samples (profiling).

StdoutLinesAsync() / StdoutChunksAsync() / OutputEventsAsync() need a piped stdout, which is the default for StartAsync(); if you set Command.Stdout to StdioMode.Inherit or StdioMode.Null there is nothing to stream. StderrChunksAsync() likewise needs a piped, unmerged stderr, and says so with a typed error when the run has none (see Streaming stderr as byte chunks). The live gauges Pid, Elapsed, StartTime, StdoutLineCount, StderrLineCount, StdoutBytesSeen, and StderrBytesSeen are cheap to read at any time, including mid-stream (live counters below). There is also Kill() — "stop it now, I'll WaitAsync() for the Outcome myself" — which begins teardown without blocking.

To stop a long-running child cleanly — let it flush logs, release locks, and run its shutdown hooks — use StopAsync(gracePeriod) (or StopAsync() for a 2-second default, matching ProcessGroupOptions.ShutdownTimeout). It sends the tree the command's configured soft signal (Command.StopSignal, default Signal.Term), waits up to the grace window for it to exit on its own, then hard-kills whatever is still alive, reaps the tree, and returns the honest Outcome — the same configured-soft-signal → grace → hard-kill escalation as Command.TimeoutGrace and ProcessGroup.ShutdownAsync. It drains the child's output while it shuts down and reuses an in-flight streaming/capturing session's wait, so it is safe to call after StdoutLinesAsync()/OutputEventsAsync() or alongside FinishAsync/WaitAsync, and is idempotent with Kill/Dispose. A soft signal needs a mechanism that has one: on Windows (no per-tree graceful signal) and on a shared group from group.StartAsync(cmd) (no per-child graceful signal) the grace is skipped and the child is hard-killed at once — exactly as TimeoutGrace already degrades there. A handle from StartAsync() (its own private group) gets the full graceful stop on Unix.

Signal(signal) is the non-consuming control verb for one live handle. It targets that run's own containment unit and leaves WaitAsync/streaming available afterwards; after teardown it fails without touching a potentially recycled pid. Use ProcessGroup.Signal when the intent is a group-wide broadcast. For a pipeline, stage 0 owns StopSignal; setting a custom value on a later stage is rejected because the chain has one broadcast soft-stop phase.

A command's Timeout and CancelOn token bound the stream: at the deadline (or on cancellation) the tree is killed, the pipes close, and the stream ends — a streamed run can't hang past its deadline. After a cancelled run, FinishAsync() reports ProcessError.Cancelled. A cancellation kills immediately unless the command sets CancelGrace, which gives the tree its own soft signal → grace → hard-kill ladder first; the reported error is the same either way.

Streaming stdout line by line

StdoutLinesAsync() returns an IAsyncEnumerable<string> that yields decoded lines as the child produces them — no waiting for exit, no full-output buffering. In F#, drive the enumerator directly:

F#

task {
    match! (Command.create "git" |> Command.args [ "log"; "--oneline"; "-n"; "50" ]).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let e = proc.StdoutLinesAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true -> printfn $"commit: {e.Current}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()
}

C#

await using var proc = (await new Command("git").Args(["log", "--oneline", "-n", "50"]).StartAsync()).GetValueOrThrow();

await foreach (var line in proc.StdoutLinesAsync())
    Console.WriteLine($"commit: {line}");

From C# the same loop is simply await foreach (var line in proc.StdoutLinesAsync()) { ... }.

While you stream stdout, stderr is drained in the background, so a noisy child can never block on a full stderr pipe. The OnStdoutLine / OnStderrLine handlers and the output buffer policy from Running commands still apply to a streamed run — a handler sees each line on the pump, in addition to your loop.

Streaming stdout as byte chunks

StdoutChunksAsync() returns an IAsyncEnumerable<ReadOnlyMemory<byte>>. It performs no text decoding or line framing: each non-empty item contains exactly the bytes returned by one underlying read, including NUL bytes, invalid UTF-8, and boundaries inside a multibyte character. Every item owns its backing array, so it remains valid after the next chunk arrives. Use this for archives, media, compressed data, and other output where text is the wrong abstraction.

F#

open System.IO

task {
    match! (Command.create "git" |> Command.args [ "archive"; "HEAD" ]).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let destination = Stream.Null

        let e = proc.StdoutChunksAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                let! more = e.MoveNextAsync()

                if more then
                    do! destination.WriteAsync(e.Current)
                else
                    go <- false
        finally
            e.DisposeAsync().AsTask().Wait()

        match! proc.FinishAsync() with
        | Ok finished -> printfn $"archive finished: {finished.Outcome}"
        | Error err -> eprintfn $"{err.Message}"
}

C#

using System.IO;

await using var proc = (await new Command("git").Args(["archive", "HEAD"]).StartAsync()).GetValueOrThrow();
using var destination = Stream.Null;

await foreach (var chunk in proc.StdoutChunksAsync())
    await destination.WriteAsync(chunk);

var finished = (await proc.FinishAsync()).GetValueOrThrow();

The chunk stream uses the configured StdoutTee as a raw byte tee. MergeStderr is supported: the merged bytes arrive through stdout in the order supplied by the operating system. Pty is also supported, but the terminal remains a terminal — its normal echo, newline, and other line-discipline behaviour can transform bytes before ProcessKit reads them. Inherit and Null stdout have nothing to stream.

Chunk streaming claims the stdout pipe exactly once. OutputStringAsync(), OutputBytesAsync(), WaitAsync(), StdoutLinesAsync(), StderrChunksAsync(), OutputEventsAsync(), and framed/interactive sessions are refused on the same handle; a second StdoutChunksAsync() is refused too. After consuming chunks, call FinishAsync() to await the process and obtain drained stderr. StopAsync() and disposal remain valid lifecycle operations; WaitAsync() is not a companion to a claimed streaming session.

The default channel is unbounded for compatibility with the other streaming verbs. For bounded memory, set Command.StreamBuffer: its capacity counts unread chunks, and Backpressure pauses the stdout pump before it reads more when the channel is full, preserving every byte. The two drop modes intentionally trade byte preservation for bounded lossy output (DroppedStreamLineCount is the existing dropped-stream-item counter); Error ends the stream with ProcessError.OutputTooLarge. OutputBuffer's line/byte caps do not apply to chunk contents. A genuine stdout read failure ends the enumerator and FinishAsync() with ProcessError.Io; an IOException/ObjectDisposedException caused by this handle's teardown is quiet.

Streaming stderr as byte chunks

StderrChunksAsync() is the same verb for the other stream: an IAsyncEnumerable<ReadOnlyMemory<byte>> of byte-exact stderr, with no text decoding and no line framing. Reach for it when stderr carries something text is the wrong abstraction for — a binary progress protocol, a high-volume diagnostic log you relay or hash byte-for-byte — where the decoded Finished.Stderr, OutputEventsAsync() and StderrTee all frame, decode, or push instead of handing you the bytes to pull.

F#

open System.IO

task {
    match! (Command.create "ffmpeg" |> Command.args [ "-i"; "clip.mp4"; "-progress"; "pipe:2"; "-f"; "null"; "-" ])
        .StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let destination = Stream.Null

        let e = proc.StderrChunksAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                let! more = e.MoveNextAsync()

                if more then
                    do! destination.WriteAsync(e.Current)
                else
                    go <- false
        finally
            e.DisposeAsync().AsTask().Wait()

        match! proc.FinishAsync() with
        | Ok finished -> printfn $"progress stream ended: {finished.Outcome}"
        | Error err -> eprintfn $"{err.Message}"
}

C#

using System.IO;

await using var proc = (await new Command("ffmpeg")
    .Args(["-i", "clip.mp4", "-progress", "pipe:2", "-f", "null", "-"])
    .StartAsync()).GetValueOrThrow();
using var destination = Stream.Null;

await foreach (var chunk in proc.StderrChunksAsync())
    await destination.WriteAsync(chunk);

var finished = (await proc.FinishAsync()).GetValueOrThrow();

Everything the stdout chunk stream promises holds here, on stderr: each non-empty item is exactly one underlying read (NUL bytes, invalid UTF-8, and boundaries inside a multibyte character all survive), each item owns its backing array, StderrTee receives the same bytes as a raw tee, the claim is one-shot — a second StderrChunksAsync(), or any other consuming verb, is refused with the already-consumed InvalidOperationException — and Command.StreamBuffer bounds the unread backlog in exactly the same modes (Backpressure pauses the stderr pump, the drop modes bump DroppedStreamLineCount, Error ends the stream with ProcessError.OutputTooLarge). OutputBuffer's caps do not apply to chunk contents, and StderrLineCount stays 0 because nothing on this path frames a line. A genuine stderr read failure ends the enumerator and FinishAsync() with ProcessError.Io; the IOException/ObjectDisposedException of this handle's own teardown is quiet, and StopAsync()/disposal release an abandoned bounded stream.

This run's stdout is drained and discarded. A handle is consumed one way, and Finished carries the outcome and the captured stderr — which you have just taken as bytes — so there is nothing for a terminal verb to hand stdout back through, and retaining it would pin a whole run's output in memory for a reader that cannot exist. stdout is still read, framed, teed (StdoutTee), handed to OnStdoutLine and counted into StdoutLineCount, so a chatty child never blocks on a full pipe; it is simply never retained, and asking for it afterwards (StdoutLinesAsync(), StdoutChunksAsync(), OutputStringAsync(), …) is refused rather than answered with an empty stream. Keep stdout with Command.StdoutTee or Command.StdoutToFile if you need both streams, or stream stdout and take stderr as the decoded Finished.Stderr. FinishAsync() after this verb still returns the honest Outcome; its Stderr is empty by construction, because those bytes went to you.

A run with no separate stderr refuses honestly. Command.MergeStderr() folds stderr into stdout at the OS level, a Command.Pty run gives the child one terminal device, and Command.StderrToFile/StdioMode.Inherit/StdioMode.Null leave no parent-side stream at all — in each case there is no byte-exact stderr to stream, so StderrChunksAsync() throws ProcessException carrying ProcessError.Unsupported (naming which of them it was) instead of returning an empty enumerable that would read as "the child wrote nothing to stderr". Under a merge or a PTY the bytes really are in stdout: stream them with StdoutChunksAsync(). The refusal is decided before the pipes are claimed, so the handle is left untouched and every other verb is still available on it.

FakeProcess, ScriptedRunner, and cassette replay hand out the same chunks a real run would, so a consumer of this verb is testable without a subprocess. A double scripts stderr as text and encodes it on the way out with the stderr encoding of the command it stands in for — UTF-8 for a FakeProcess.Create double, which templates a bare command, and your own Command.StderrEncoding for a ScriptedRunner reply or a cassette replay, whose recording is text a live run had already decoded. Choose that encoding when the exact bytes matter: under Command.StderrEncoding(Encoding.Latin1) a scripted reply reaches the chunk stream as the 0x000xFF bytes its text maps to, verbatim. The doubles offer no byte-level stderr scripting, so stderr that no encoding round-trip reproduces belongs in a test against a real child process.

Streaming NDJSON / JSON Lines

Many CLIs stream their output as one JSON document per line — NDJSON / JSON Lines (docker events --format json, kubectl get -w -o json, rg --json). Rather than combining StdoutLinesAsync() with your own JsonSerializer call on every line, StdoutJsonLinesAsync<'T>() does it for you: a thin typed wrapper over StdoutLinesAsync() that deserializes each non-empty line into a 'T as it arrives. It shares the very same exclusive-consumption gate, LineTerminator, and StreamBuffer policy as StdoutLinesAsync() — pick one or the other for a given run, same as StdoutLinesAsync() / OutputEventsAsync() above:

F#

type Event = { Type: string; Message: string }

task {
    match! (Command.create "docker" |> Command.args [ "events"; "--format"; "json" ]).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let e = proc.StdoutJsonLinesAsync<Event>().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true -> printfn $"{e.Current.Type}: {e.Current.Message}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()
}

C#

record Event(string Type, string Message);

await using var proc = (await new Command("docker").Args(["events", "--format", "json"]).StartAsync()).GetValueOrThrow();

await foreach (var ev in proc.StdoutJsonLinesAsync<Event>())
    Console.WriteLine($"{ev.Type}: {ev.Message}");

A blank line (after the LineTerminator policy is applied) is skipped silently — never deserialized — a common NDJSON producer quirk (a trailing blank line, a keep-alive newline). A non-empty line that fails to deserialize ends the enumeration with an exception carrying ProcessError.Parse, exactly like OutputJsonAsync<'T>'s ProcessError.Parse (Running commands) — never a raw, undocumented exception. StdoutJsonLinesAsync<'T>(options) takes an optional JsonSerializerOptions (omitted uses the BCL defaults) and deserializes via reflection, so it is not trim-/NativeAOT-safe; for a trimmed/NativeAOT app, pass a source-generated JsonTypeInfo<'T> to the StdoutJsonLinesAsync<'T>(typeInfo) overload instead (MyJsonContext.Default.MyType from a [JsonSerializable]-annotated JsonSerializerContext) — no reflection, no RequiresUnreferencedCode/ RequiresDynamicCode. Call FinishAsync() afterwards for stderr + outcome, same as after StdoutLinesAsync().

Interleaving stdout and stderr

When the order of stdout relative to stderr matters — a build tool that prints progress to one and diagnostics to the other — OutputEventsAsync() returns an IAsyncEnumerable<OutputEvent> that merges both channels in arrival order. Each event's OutputLine carries its Text, TimestampUtc (captured from the command's TimeProvider), and a one-based Sequence shared by stdout and stderr for that run. The sequence records the order in which the independently-drained streams reached ProcessKit's line-framing boundary, so a collected transcript can be sorted unambiguously even if later processing is concurrent:

F#

task {
    match! (Command.create "dotnet" |> Command.args [ "build"; "-c"; "Release" ]).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let e = proc.OutputEventsAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true ->
                    let ev = e.Current
                    if ev.IsStdout then printfn $"{ev.Sequence} out| {ev.Text}"
                    else eprintfn $"{ev.Sequence} err| {ev.Text}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()
}

C#

await using var proc = (await new Command("dotnet").Args(["build", "-c", "Release"]).StartAsync()).GetValueOrThrow();

await foreach (var ev in proc.OutputEventsAsync())
{
    if (ev.IsStdout)
        Console.WriteLine($"{ev.Sequence} out| {ev.Text}");
    else
        Console.Error.WriteLine($"{ev.Sequence} err| {ev.Text}");
}

FakeProcess, ScriptedRunner, and cassette replay all run through the same framing path. Their sequence numbers are therefore deterministic for a given emitted order. Timestamps are synthesized when the fake/replay stream is consumed; attach a deterministic Command.TimeProvider when a test needs stable timestamp values. Cassettes continue to store output text rather than capture-time metadata, so existing cassette versions remain compatible.

From C#, await foreach (var ev in proc.OutputEventsAsync()) { ... }. Choose OutputEventsAsync() or StdoutLinesAsync() for a given run — both consume stdout, so they are alternatives, not companions. A PtySession, which reads the same output unframed to wait for terminal prompts, is a third alternative to those two.

OutputEventsAsync() tags each line with the stream it came from, keeping the two channels distinguishable. When you instead want them merged into one stream — with the real byte-for-byte interleaving preserved, but no origin tag — reach for Command.MergeStderr (a shell 2>&1): the child's stderr is folded into its stdout at the OS level, so StdoutLinesAsync() alone yields every line in order and OutputEventsAsync() emits only Stdout events (there is no longer a separate stderr stream to tag).

Redirecting a stream straight to a file

Everything above pumps output through the parent: a background pump drains the child's stdout/stderr pipe, and the log lives only as long as that pump does. For a long-running child whose output you just want on disk — a service's log file under a Supervisor, a build's full transcript — that is wasted work and an extra point of failure. Command.StdoutToFile(path, append) / Command.StderrToFile(path, append) instead redirect the stream straight to a file at the OS level: the child is handed the open file as its stdout/stderr handle/fd on the spawn (Windows: an inheritable file handle in STARTUPINFO; POSIX: a file fd via a posix_spawn file action), so the child writes the file directly, with zero copying through the parent and no pump. The file keeps growing even after the parent process — or a pump that would have drained a pipe — is gone. append = false creates the file (truncating an existing one); append = true appends.

F#

// Both streams to their own log files; the parent captures neither. The child writes them
// directly and they survive the parent.
task {
    let cmd =
        Command.create "my-service"
        |> Command.stdoutToFile "/var/log/my-service.out" true // append
        |> Command.stderrToFile "/var/log/my-service.err" true

    match! cmd.RunAsync() with
    | Ok _ -> ()
    | Error err -> eprintfn $"{err.Message}"
}

C#

var cmd = new Command("my-service")
    .StdoutToFile("/var/log/my-service.out", append: true)
    .StderrToFile("/var/log/my-service.err", append: true);

await cmd.RunAsync();

A redirected stream has no parent-side stream at allProcessResult.Stdout/Stderr is empty, the streaming stdout/stderr verbs yield nothing, and the matching OutputEvent is never produced, exactly like StdioMode.Null. Because of that, the knobs and verbs that need a parent-side view of the same stream are rejected at the builder boundary with an ArgumentException (in either chaining order), rather than silently never firing:

Combined with StdoutToFile (the stdout stream)Combined with StderrToFile (the stderr stream)
StdoutTee, OnStdoutLine — rejected (no parent stdout to observe)StderrTee, OnStderrLine — rejected (no parent stderr to observe)
MergeStderr — rejected (it folds stderr into the observed stdout, which is absent)MergeStderr — rejected (it removes the separate stderr this redirects)
Pty — rejected (a terminal replaces all stdio with one device)Pty — rejected (same)

What is allowed and useful:

  • Redirect one stream to a file, capture the other normally. StdoutToFile leaves stderr on its ordinary pipe, so ProcessResult.Stderr, OnStderrLine, StderrTee, and the stderr streaming verbs all still work — and vice versa for StderrToFile.
  • Redirect both streams, each to its own file (StdoutToFile + StderrToFile).
  • The buffered stdout/stderr of the non-redirected stream is captured exactly as always.

StdoutToFile/StderrToFile and Stdout(mode)/Stderr(mode) are both destination setters for the same stream, so the last one in the chain wins — a later Stdout(StdioMode.Null) clears a prior StdoutToFile, and vice versa. A bad path (missing directory, denied permission) fails the spawn with ProcessError.Spawn, never a silent drop of the child's output.

Rotating a long-lived log

When the log must stay bounded, use a caller-owned RotatingFileSink as a tee. The active file is the requested path; .1 is the newest archive and .N the oldest. Writes are split at the byte limit, and archives beyond maxFiles are deleted:

use log = new RotatingFileSink("/var/log/my-service.log", 64L * 1024L * 1024L, 5)

let command =
    Command.create "my-service"
    |> Command.stdoutTee log
using var log = new RotatingFileSink("/var/log/my-service.log", 64L * 1024L * 1024L, 5);
var command = new Command("my-service").StdoutTee(log);

This deliberately has the opposite lifetime trade-off from StdoutToFile: rotation requires the parent-side pump, so it stops when the parent exits. The stream remains captured as usual, and the caller owns the sink. A write, flush, delete, or rename failure propagates through the existing tee error contract and fails the run; ProcessKit never silently drops log bytes. Use separate sink instances for stdout and stderr.

Bounding the streaming backlog

By default, the channel that feeds StdoutLinesAsync() / StdoutChunksAsync() / StderrChunksAsync() / OutputEventsAsync() / WaitForLineAsync() is unbounded: a producer far outrunning your consumer (a chatty child, a slow line handler) just grows the in-flight backlog — exactly the behavior ProcessKit has always had. Command.StreamBuffer opts in to a bounded channel instead, capping that backlog with one of four StreamFullModes:

  • Backpressure (the default for StreamBufferPolicy.Bounded(capacity)) — the pump stops draining the OS pipe once the channel is full, so the child itself observably blocks writing to a full stdout/stderr pipe until your consumer catches up. Bounds memory losslessly, at the cost of the child's timing — pick this for a trusted producer you genuinely want to pace against your consumer (tailing a log, a pipeline stage).
  • DropOldest — "tail" semantics: once full, the oldest queued item is discarded to make room for the newest. Lossy but bounded (for chunks, this deliberately drops bytes).
  • DropNewest — "head" semantics: once full, the incoming item is discarded and what's already queued is kept.
  • Error — fail loud: once the cap is reached, the streaming enumerator throws (carrying ProcessError.OutputTooLarge) instead of silently dropping anything.

Both DropOldest and DropNewest bump RunningProcess.DroppedStreamLineCount — a live counter (like StdoutLineCount/StderrLineCount; for byte streaming it counts dropped chunks) so a lossy policy's drops are always visible, never silent:

F#

task {
    let command =
        (Command.create "chatty-tool")
            .StreamBuffer(StreamBufferPolicy.Bounded(1000, StreamFullMode.DropOldest))

    match! command.StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let e = proc.StdoutLinesAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true -> printfn $"{e.Current}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()

        if proc.DroppedStreamLineCount > 0 then
            printfn $"dropped {proc.DroppedStreamLineCount} lines to stay within the bound"
}

C#

var command = new Command("chatty-tool")
    .StreamBuffer(StreamBufferPolicy.Bounded(1000, StreamFullMode.DropOldest));

await using var proc = (await command.StartAsync()).GetValueOrThrow();

await foreach (var line in proc.StdoutLinesAsync())
    Console.WriteLine(line);

if (proc.DroppedStreamLineCount > 0)
    Console.WriteLine($"dropped {proc.DroppedStreamLineCount} lines to stay within the bound");

The backpressure deadlock footgun. StreamFullMode.Backpressure slows the child, not your code — but if your consumption loop itself never resumes (it's stuck waiting on something that, in turn, waits for the child to finish), the child can never finish either: it's blocked writing to a pipe nobody is reading, forever. This is the same full-duplex hazard as the interactive-stdin deadlock above, just on the read side instead of the write side. Two things to know before opting in:

  • A Command.Timeout kills the child at the deadline, but that alone does not free a writer your own pump is parked on if you also never read again — the child dying doesn't hand the pump anything new to write, but a pump already blocked inside a WriteAsync call only unblocks when either the channel gets read from again or the RunningProcess itself is disposed. In other words: pairing Backpressure with Command.Timeout bounds the child's lifetime, not necessarily your consumer's.
  • Give your own consumption loop a deadline (a CancellationToken passed to GetAsyncEnumerator(token), or a read-side timeout around each MoveNextAsync()), and make sure you Dispose/DisposeAsync the RunningProcess promptly if you give up on it. If you deliberately hand the run to a terminal operation after abandoning the stream, FinishAsync(), StopAsync(), WaitAnyAsync/WaitAllAsync, and DisposeAsync cancel a writer parked on backpressure before waiting for the shared outcome; the pump winds down and queued items remain readable until the channel ends. Real pump/I/O faults still surface unchanged.

If you can't reason about your consumer always resuming, prefer DropOldest/DropNewest (never blocks the child) or Error (fails loud instead of stalling) over Backpressure.

Live counters

A RunningProcess publishes the live counters below. All are cheap to read at any time — mid-stream, and afterwards: each keeps its final value once the pumps end, including after FinishAsync() and after the handle is disposed.

CounterCounts
StdoutLineCount / StderrLineCountframed lines pumped so far (including lines that were then dropped)
DroppedStreamLineCountitems a dropping StreamBuffer policy discarded (lines/events, or chunks for the byte streams)
StdoutBytesSeen / StderrBytesSeenraw bytes read from the child, as Int64

StdoutBytesSeen/StderrBytesSeen are byte-level progress, counted at the parent's own read of the pipe — before decoding, before line framing, and before any policy could drop or refuse anything. So they measure what came off the child, not what was kept: the bytes of a line a dropping StreamBuffer policy discarded, of output an OutputBuffer ceiling refused as too large, and of a run whose output is discarded entirely (WaitAsync()) all count. They are unaffected by the stream's encoding and line terminator — a UTF-16 stream counts wire bytes, not characters — and by every consumption mode alike: buffered captures, line/chunk/event streaming, and a readiness probe's background drain.

A counter is 0 — deterministically, with no waiting and no error — when the parent never reads that stream: StdioMode.Null or StdioMode.Inherit, a stream redirected straight to a file (Command.StdoutToFile), or a stream this command did not configure. A merged run (Command.MergeStderr(), or Command.Pty(), where the child has a single terminal device) has one parent-side stream: every byte counts into StdoutBytesSeen, and StderrBytesSeen stays 0 — the same boundary StderrChunksAsync() reports as unsupported.

These counters are also what a fail-loud streaming overflow quotes as its total: when a bounded StreamFullMode.Error backlog trips, the ProcessError.OutputTooLarge it raises reports the raw bytes read so far (both pipes' together, for the merged event stream), so the diagnostic and the handle agree on one number. A capture's own totals are a different quantity and stay one: ProcessResult reports what the capture retained, post-decode, in the currency of the OutputBuffer cap that bounds it.

F#

printfn $"{proc.StdoutLineCount} lines, {proc.StdoutBytesSeen} bytes off stdout so far"

C#

Console.WriteLine($"{proc.StdoutLineCount} lines, {proc.StdoutBytesSeen} bytes off stdout so far");

Finishing a streamed run

When a line or chunk stream ends (stdout closed), collect the rest with FinishAsync(), which returns Result<Finished, ProcessError>. Finished carries the Outcome, the Stderr that was drained while you streamed, and Truncated, which is true when the stdout stream dropped items under a dropping StreamBuffer policy, when the captured stderr was truncated by OutputBuffer, or when the post-exit output drain was bounded because something that inherited the child's stdout/stderr outlived it (see Output a descendant keeps open below):

F#

task {
    match! (Command.create "build-everything").StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc
        let e = proc.StdoutLinesAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true -> printfn $"> {e.Current}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()

        match! proc.FinishAsync() with
        | Ok finished ->
            if finished.Outcome <> Outcome.Exited 0 then
                eprintfn $"failed ({finished.Outcome}):\n{finished.Stderr}"
        | Error err -> eprintfn $"{err.Message}"
}

C#

await using var proc = (await new Command("build-everything").StartAsync()).GetValueOrThrow();

await foreach (var line in proc.StdoutLinesAsync())
    Console.WriteLine($"> {line}");

var finished = (await proc.FinishAsync()).GetValueOrThrow();
if (finished.Outcome is not { IsExited: true, Code.Value: 0 }) // anything but a clean exit 0
    Console.Error.WriteLine($"failed ({finished.Outcome}):\n{finished.Stderr}");

Use FinishAsync() after you have streamed stdout. If you only need the exit status and don't care about output, WaitAsync() returns the Outcome directly and discards the captured output; if you skipped streaming altogether, OutputStringAsync() / OutputBytesAsync() buffer and return everything just like the one-shot verbs.

Calling FinishAsync() without having taken the stdout stream is allowed and costs no memory: stdout is then drained to keep the child moving and discarded as it arrives, exactly like WaitAsync(), since Finished carries the Outcome and stderr but never stdout. Your OnStdoutLine handler and StdoutTee still see every line — only the backlog is gone, so there is nothing left to drop and Truncated reports the stderr capture (plus a bounded post-exit drain, if one fired — see below). That finish is also the point of no return for stdout, and it says so out loud: asking for the discarded stream afterwards is refused as already-consumed — StdoutLinesAsync()/StdoutJsonLinesAsync() throw InvalidOperationException, and WaitForLineAsync() (as well as WaitForStderrLineAsync() / WaitForStderrTailAsync(), which share that same streaming session) returns ProcessError.Unsupported, just as they do after WaitAsync()/ProfileAsync() — instead of handing back an empty stream you could mistake for a silent child. So take the stream first (StdoutLinesAsync()/StdoutChunksAsync()) if you want to read stdout after finishing: its backlog is retained for its enumerator as before.

Output a descendant keeps open

A pipe reaches end-of-file only when its last writer closes it, and the child's own exit closes only the child's copy. If the child spawned something that inherited its stdout/stderr and kept running — a daemonized worker, a setsid helper, a shell's & background job — the parent's read end stays open after the child is gone.

ProcessKit does not wait on that indefinitely. Once the child's exit status is known, the output pumps get a short window (5 seconds) to finish an ordinary tail; if the pipe is still open after it, the run closes its own read ends and the verb returns the outcome it already had. What was read is kept, and the result says it is incomplete:

  • OutputStringAsync/OutputBytesAsync return the partial capture with ProcessResult.Truncated set — symmetric for text and bytes.
  • A line or chunk stream ends where it was cut, and FinishAsync reports Finished.Truncated.
  • An event stream (OutputEventsAsync) ends where it was cut too, but with no separate signal: FinishAsync is not available on an event session (it answers already-consumed), so the enumerator simply ends. If you need to know whether the tail was complete, stream lines or chunks and finish, or capture with OutputStringAsync/OutputBytesAsync.
  • WaitAsync/ProfileAsync, which retain nothing anyway, simply conclude.
  • WaitAnyAsync/WaitAllAsync resolve on the child's own exit.
  • The checking verbs — RunAsync, ParseAsync, OutputJsonAsync — present their capture as the whole of stdout, so they refuse a cut-short one with ProcessError.OutputIncomplete. That is its own case, not the OutputTooLarge a buffer policy's truncation gets: nothing crossed a ceiling here, and no OutputBuffer setting would have changed the outcome. Use OutputStringAsync/OutputBytesAsync when you want the partial payload plus Truncated instead.

This is not a timeout: the run's Outcome is untouched (it is not turned into TimedOut), and Command.Timeout remains a separate, independent deadline on the run as a whole. It is also not a kill of anything the run does not own. A run started by the default runner owns a private group, so its teardown reaps whatever the child left behind, exactly as it always did; a run started through a shared ProcessGroup detaches only its own I/O, and the descendant keeps running under the group until you shut the group down.

If you want that descendant's output, it is not this run's stdout to capture — give the descendant its own pipe, or have the child wait for it before exiting.

Scope. This bound belongs to a run driven through one RunningProcess handle — the whole of this chapter, and every Command/Exec capture verb, all of which run through one. A Pipeline does not have it: its buffered verbs wait for the last stage's stdout and for every stage's stderr to reach end-of-file, so a stage that leaves a background job holding one of those still waits for that job, and the whole-chain Pipeline.Timeout does not cover it (the deadline is disarmed once every stage is terminal, which is when that wait starts). Cancelling such a run through its CancellationToken tears the chain's group down, descendants included; keeping the descendant off the stage's own stdout/stderr avoids it entirely.

Streaming a pipeline's final stage

Everything in this chapter has a pipeline counterpart. A Pipeline normally runs to completion behind its buffering verbs, but Pipeline.StartAsync() starts it as a live session — a PipelineSession, the multi-stage analogue of RunningProcess — and streams the final stage's stdout exactly as StdoutLinesAsync / StdoutJsonLinesAsync / OutputEventsAsync / WaitForLineAsync do above:

F#

task {
    let pipeline =
        (Command.create "journalctl" |> Command.args [ "-f" ])
            .Pipe(Command.create "grep" |> Command.args [ "--line-buffered"; "ERROR" ])

    match! pipeline.StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok session ->
        use session = session
        let e = session.StdoutLinesAsync().GetAsyncEnumerator()

        try
            let mutable go = true

            while go do
                match! e.MoveNextAsync() with
                | true -> printfn $"error: {e.Current}"
                | false -> go <- false
        finally
            e.DisposeAsync().AsTask().Wait()

        // FinishAsync reaps the WHOLE chain and reports the pipefail outcome + that
        // stage's stderr — identical to Pipeline.RunAsync, never a final-stage-only view.
        match! session.FinishAsync() with
        | Ok finished -> printfn $"chain finished: {finished.Outcome}"
        | Error err -> eprintfn $"{err.Message}"
}

FinishAsync / StopAsync reap and classify the entire chain (not just the final stage), so a non-zero exit deep in the pipe still surfaces as the pipefail representative's Outcome, and stopping or disposing tears down every stage. The single-consumption, timeout, and cancellation rules are the ones you already know from RunningProcess. See Streaming a pipeline for the full session surface.

Interactive stdin

Conversational tools — write a request, read the response, repeat. Keep stdin open with KeepStdinOpen, then take the writer with TakeStdin(), which returns a ProcessStdin optionSome once, and None if stdin wasn't kept open, was already taken, or was ended by a completion verb that found it untaken (see who owns the kept-open writer below):

F#

task {
    // `bc` evaluates each stdin line and prints the result.
    match! (Command.create "bc" |> Command.keepStdinOpen).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc

        match proc.TakeStdin() with
        | Some stdin ->
            do! stdin.WriteLineAsync "2 + 2" // writes "2 + 2\n" to this pipe
            do! stdin.WriteLineAsync "6 * 7"
            do! stdin.FinishAsync() // send EOF so bc exits
        | None -> ()

        // ... then read proc.StdoutLinesAsync() for the answers.
        ()
}

C#

// `bc` evaluates each stdin line and prints the result.
await using var proc = (await new Command("bc").KeepStdinOpen().StartAsync()).GetValueOrThrow();

if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
    await stdin.WriteLineAsync("2 + 2"); // writes "2 + 2\n" to this pipe
    await stdin.WriteLineAsync("6 * 7");
    await stdin.FinishAsync(); // send EOF so bc exits
}

// ... then read proc.StdoutLinesAsync() for the answers.

ProcessStdin offers WriteLineAsync(line) (appends LF for a plain stdin pipe or POSIX PTY, and CR for Windows ConPTY so a cooked console reader receives Enter), WriteAsync(bytes) (raw bytes, for binary input), FlushAsync(), and FinishAsync() (close stdin / send EOF). Disposing the writer — or the whole RunningProcess — closes stdin too; FinishAsync() just makes the EOF explicit and awaitable. The write verbs (WriteAsync / WriteLineAsync / FlushAsync) each take an optional CancellationToken, so a write to a child that has stopped reading (a full stdin pipe) can be bounded rather than blocking forever — a cancelled write throws OperationCanceledException (and, as with any cancellable stream write, may already have delivered part of its bytes, so abandon the session rather than retrying a timed-out write). FinishAsync is idempotent and uncancellable (it mirrors DisposeAsync); bound the writes/flush before closing, not the close.

Who owns the kept-open writer

A kept-open stdin pipe has exactly one owner, and your TakeStdin() is not its only possible claimant. A verb that runs the handle to completion while the writer is still untaken ends the child's input itself — nothing could write that pipe afterwards, and a child reading stdin to EOF would otherwise wait forever on an end of input nobody can deliver. That applies to:

  • OutputStringAsync(), OutputBytesAsync(), WaitAsync(), ProfileAsync() on the handle;
  • a WaitAnyAsync/WaitAllAsync/StopAsync that is this handle's first consumer — when a verb or a streaming session already owns the pipes, these reuse that consumer's own wait and decide nothing about stdin;
  • every verb that never hands you a RunningProcess at all — RunAsync, ExitCodeAsync, ProbeAsync, ParseAsync/TryParseAsync, OutputJsonAsync, FirstLineAsync (which ends the input before it starts streaming stdout, so a child that answers only after EOF still produces its first line).

So take the writer before you drive the handle to completion, not after: the claim is made the moment such a verb starts, so from then on TakeStdin() returns None and the child's end of input is already on its way. In particular, "call a buffered verb first, then TakeStdin() and write" is not a way around the deadlock below — the writer is gone by then, and the child answers on the input it has, which for an interactive-only run is none at all.

The other direction is safe and unchanged: a writer you took stays yours. No verb closes a handle it gave away, and completion waits for your own FinishAsync()/dispose. Streaming (StdoutLinesAsync/StdoutChunksAsync/StderrChunksAsync/OutputEventsAsync and the FinishAsync that concludes them), the readiness probes, and a live StartAsync handle you have not yet driven to completion all leave the pipe exactly as they found it. A PtySession or ContentLengthSession takes the writer for its own send verbs when it is created — that is the "already taken" case, and TakeStdin() afterwards returns None for the same one-owner reason.

On a Stdin(source) + KeepStdinOpen run the end of input never truncates the source: whoever ends it — you or the verb — waits for the background feeder to finish delivering the whole source first, exactly as TakeStdin() itself does. The interactive session close verbs expose that pre-delivery wait through an optional token: PtySession.CloseStdinAsync(cancellationToken), ContentLengthSession.FinishInputAsync(cancellationToken), and JsonRpcSession.FinishInputAsync(cancellationToken) return Error(ProcessError.Cancelled program) if cancellation wins while waiting for the feeder or a session send gate. They do not deliver EOF in that case. After the writer and any session gate have been claimed and FinishAsync begins, EOF delivery is not cancellable; the overload without a token is the same operation with CancellationToken.None.

Avoid the full-duplex deadlock. A child's stdout pipe has a finite OS buffer; once it fills, the child blocks writing stdout until something reads it. If you push a large interactive stdin while nothing drains the child's stdout, the child stops reading stdin (blocked on stdout), your WriteAsync parks waiting for stdin buffer space, and neither side progresses. The bc example above is safe because it interleaves one small write with one read. When you both feed a sizable stdin and the child produces output, write stdin from one task and drain stdout from another — both halves over the live StartAsync handle, with the writer taken up front (reaching for a buffered verb to "start the drains" first would end the child's input before you could take it, as described above):

F#

task {
    match! (Command.create "transform" |> Command.keepStdinOpen).StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc

        match proc.TakeStdin() with
        | Some stdin ->
            // Producer: feed a large stdin on its own task.
            let writer =
                task {
                    for line in bigInput do
                        do! stdin.WriteLineAsync line

                    do! stdin.FinishAsync()
                }

            // Consumer: drain stdout concurrently on this task.
            let e = proc.StdoutLinesAsync().GetAsyncEnumerator()

            try
                let mutable go = true

                while go do
                    match! e.MoveNextAsync() with
                    | true -> handle e.Current
                    | false -> go <- false
            finally
                e.DisposeAsync().AsTask().Wait()

            do! writer
        | None -> ()
}

C#

await using var proc = (await new Command("transform").KeepStdinOpen().StartAsync()).GetValueOrThrow();

if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
    // Producer: feed a large stdin on its own task.
    var writer = Task.Run(async () =>
    {
        foreach (var line in bigInput)
            await stdin.WriteLineAsync(line);

        await stdin.FinishAsync();
    });

    // Consumer: drain stdout concurrently on this task.
    await foreach (var line in proc.StdoutLinesAsync())
        handle(line);

    await writer;
}

For one-directional streamed input (a channel, a file tail) you don't need interactivity at all — give the command Stdin.FromLines seq, Stdin.FromAsyncLines asyncSeq, or Stdin.FromStream stream and let ProcessKit's background writer feed it; those sources run concurrently with the output pumps and never deadlock. See the stdin source table in Running commands.

Those three sources are one-shot, and StartAsync is a launch like any other: it takes the source at the spawn, so the started handle owns it and a second start over the same stream or sequence is refused with ProcessError.Unsupported before any child exists — it is never handed the drained remains. A start that fails before a child (NotFound, a failed spawn) leaves the source for the next one. See One-shot stdin sources feed one incarnation for the full contract, and use a repeatable source (Stdin.FromString / FromBytes / FromFile) when you start the same command more than once.

Content-Length framed sessions (LSP / DAP)

Language servers, debug adapters, and BSP servers usually do not speak newline-delimited JSON. They frame each byte payload as Content-Length: N, CRLF, a blank CRLF line, then exactly N payload bytes. ContentLengthSession owns a live handle's stdout and exposes those payloads as a single IAsyncEnumerable<byte[]>; build the command with KeepStdinOpen to send frames back.

F#

task {
    let command = (Command.create "language-server").KeepStdinOpen()

    match! command.StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use proc = proc
        let session = ContentLengthSession(proc)
        let initialize = Encoding.UTF8.GetBytes "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\"}"

        match! session.SendAsync initialize with
        | Error err -> eprintfn $"{err.Message}"
        | Ok() ->
            let frames = session.FramesAsync().GetAsyncEnumerator()

            try
                let! received = frames.MoveNextAsync()

                if received then
                    printfn "received %d bytes" frames.Current.Length
            finally
                frames.DisposeAsync().AsTask().Wait()
}

C#

await using var process =
    (await new Command("language-server").KeepStdinOpen().StartAsync()).GetValueOrThrow();
var session = new ContentLengthSession(process);

var initialize = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"method\":\"initialize\"}");
(await session.SendAsync(initialize)).GetValueOrThrow();

await foreach (var frame in session.FramesAsync())
    Console.WriteLine($"received {frame.Length} bytes");

The default maximum payload in either direction is 16 MiB; pass a smaller positive maxFrameBytes to the constructor for an untrusted peer. Oversized, duplicate/missing Content-Length, non-ASCII headers, bare-LF headers, and truncated payloads fail the enumerator with ProcessException carrying ProcessError.Parse before a misleading partial frame is yielded. Extra headers such as Content-Type are accepted. SendAsync serializes concurrent callers so header/payload pairs never interleave; after cancelling a send, abandon the session because the child may have received a prefix — the exception being an interruption that lands while the call is still queued behind another send or waiting for a Stdin(source) feeder, which cannot have written a byte (JsonRpcSession, layered on this type, tells the two apart so a cancelled call does not end its conversation). FinishInputAsync(cancellationToken) closes framed stdin and lets the child observe EOF. If the token fires while the feeder or this session's send gate is still pending, it returns Error(ProcessError.Cancelled program) and writes no EOF. Once the gate is held and ProcessStdin.FinishAsync begins, the EOF delivery is not cancellable. The parameterless overload passes CancellationToken.None.

Payloads remain raw for byte accuracy and NativeAOT-friendly caller control. For typed JSON, pass each frame to JsonSerializer.Deserialize(frame, MyJsonContext.Default.Message) (or the matching source-generated JsonTypeInfo) and serialize outgoing values to UTF-8 bytes before SendAsync. The session is the sole stdout consumer: do not combine it with OutputStringAsync, line/NDJSON streaming, PtySession, or another framed session on the same handle. Stderr is drained separately and still reaches StderrTee.

Command.StreamBuffer bounds the unread frame backlog the same way it bounds a line stream, so a chatty server cannot grow the parent's memory without limit while your consumer lags. Only the two lossless full modes apply: Backpressure paces the parser — and, through the pipe, the child — against your consumer, and Error faults the frame stream at the cap. DropOldest/DropNewest are refused at construction with ProcessError.Unsupported: dropping a queued frame would delete a protocol message the peer is correlating with a request, and no consumer could tell. Leaving StreamBuffer unset keeps the default unbounded backlog.

With a bounded backlog, drain FramesAsync() concurrently with your sends rather than awaiting a send first — backpressure deliberately stops the parser (and the child) once the backlog is full, so a consumer that only starts reading after some other await can stall the very child it waits on. The constructor itself never waits on the child: on a Stdin(source) + KeepStdinOpen run the source feeder is awaited by the first SendAsync/FinishInputAsync instead, so you always get the session back and can start draining frames (the interactive writer still never shares the pipe with the feeder).

If a frame consumer is abandoned, StopAsync, WaitAnyAsync/WaitAllAsync, and DisposeAsync release a parser parked on a full backpressure channel before waiting for the process outcome. The frames already queued are still delivered and the frame stream ends cleanly; genuine parser or I/O faults remain errors.

JSON-RPC sessions (LSP / BSP / MCP)

Framing bytes is only half of driving a language server. The other half is the protocol those frames carry: JSON-RPC 2.0, where every request needs a unique id, every answer must be matched back to the call that is waiting for it, and the peer sends notifications and its own requests down the same stream at any time. JsonRpcSession is that layer — it owns one ContentLengthSession over the handle and turns it into RequestAsync / NotifyAsync / a stream of incoming messages. Every inbound frame must carry a string jsonrpc member whose value is exactly "2.0"; a missing, non-string, or different value is rejected before the frame can be routed as a request, notification, or response, ending the session with a typed ProcessError.Parse.

Debug adapters are not JSON-RPC peers. DAP borrows LSP's Content-Length framing but not its envelope — its messages are {"seq":1,"type":"request","command":"next","arguments":{}} and {"seq":7,"type":"response","request_seq":1,"success":true,...}, with no jsonrpc, method, or id member. JsonRpcSession ends on the first such frame with ProcessError.Parse instead of guessing at it; drive a debug adapter with ContentLengthSession (above) and decode that envelope yourself.

F#

task {
    let command = (Command.create "language-server").KeepStdinOpen()

    match! command.StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use proc = proc
        let session = JsonRpcSession(proc)

        // Raw JSON in, raw JSON out: no serializer at all, so this path is always trim-/AOT-safe.
        match! session.RequestRawAsync("initialize", """{"processId":null}""", TimeSpan.FromSeconds 30.0) with
        | Error err -> eprintfn $"{err.Message}"
        | Ok capabilities ->
            printfn $"server capabilities: {capabilities}"
            let! _ = session.NotifyRawAsync("initialized", "{}")

            // Notifications and the server's own requests arrive here, never through RequestAsync.
            let incoming = session.MessagesAsync().GetAsyncEnumerator()

            try
                let! received = incoming.MoveNextAsync()

                if received && incoming.Current.IsRequest then
                    let! _ = session.RespondErrorAsync(incoming.Current, -32601, "Method not found")
                    ()
            finally
                incoming.DisposeAsync().AsTask().Wait()
}

C#

record HoverParams(string File, int Line);
record HoverResult(string Contents);

await using var server =
    (await new Command("language-server").KeepStdinOpen().StartAsync()).GetValueOrThrow();
var rpc = new JsonRpcSession(server);

var hover = await rpc.RequestAsync<HoverParams, HoverResult>(
    "textDocument/hover",
    new HoverParams("Program.fs", 12),
    options: null,
    timeout: TimeSpan.FromSeconds(10));

Console.WriteLine(hover switch
{
    { IsOk: true, ResultValue: var value } => value.Contents,
    { ErrorValue: ProcessError.JsonRpc e } => $"server refused: {e.Code} {e.Detail}",
    { ErrorValue: var err } => err.Message,
});

The overloads above serialize by reflection. In a trimmed or NativeAOT application pass source-generated metadata instead — every verb has a JsonTypeInfo overload, and the ...RawAsync verbs need no metadata at all:

var hover = await rpc.RequestAsync(
    "textDocument/hover",
    new HoverParams("Program.fs", 12),
    LspJson.Default.HoverParams,
    LspJson.Default.HoverResult,
    TimeSpan.FromSeconds(10));

Every failure is a typed ProcessError, never a raw exception and never a silent wait:

What happenedResult
The peer answered with an error objectProcessError.JsonRpc with its Method, Code, Detail, and the raw JSON of Data
The request timed out (timeout overloads)ProcessError.Timeout; the waiter is dropped, so a late answer is discarded
The CancellationToken firedProcessError.Cancelled
A timeout or token interrupted a send mid-frameThe same ProcessError.Timeout/Cancelled — and it ends the session, because the peer may have received a truncated frame
A timeout or token ended a send before it wrote anythingThe same ProcessError.Timeout/Cancelled, failing only that call — nothing reached the peer, so the session stays usable
A StreamBuffer cap with StreamFullMode.Error filled upProcessError.OutputTooLarge, ending the session like a protocol failure (see the backlog note below)
An unread peer request would be evicted from the decoded-message backlogProcessError.OutputTooLarge, ending the session instead of silently losing a request the peer is waiting on
The peer's framed output ended before answeringProcessError.Io — and every later verb fails the same way instead of waiting forever
The result does not fit the requested typeProcessError.Parse (a JSON null result included — read it with RequestRawAsync)
The peer sent something that is not a JSON-RPC 2.0 message, including a missing, non-string, or non-"2.0" jsonrpc memberProcessError.Parse, ending the session before routing: pending requests all fail with it and MessagesAsync faults with ProcessException

Requests may be issued concurrently — each gets its own id, and answers are routed by id, never by arrival order. Without a timeout a request waits until the peer answers, its output ends, or the token fires; pass a timeout for a peer that can go silent while still running. That budget covers the whole call, not just the wait: a peer that stops reading its own stdin blocks the write once the pipe buffer fills, and the request fails with ProcessError.Timeout there too rather than hanging. Since such a write may have delivered only part of a frame — which no peer can resynchronize from — a send interrupted while it was writing ends the conversation: pending requests fail with that same error and later requests/sends report it instead of writing into a stream the peer can no longer read. Incoming messages are unaffected (a torn outgoing frame does not corrupt what the peer says) and keep arriving on MessagesAsync until the peer's output ends.

A send that was interrupted before it wrote anything is the ordinary case, and it fails alone: an already-cancelled token, a per-request timeout that elapses while the call is still queued behind another send, or one that elapses while the very first send is still waiting for a Stdin(source) feeder to hand over the pipe, all leave the peer's stdin untouched. Cancelling one request — the completion an editor abandons on the next keystroke — therefore never ends the conversation, and a per-request timeout bounds its own call rather than the session. The framing layer underneath reports which of the two happened, so "the session is over" always means a frame really was being written.

Two backlogs sit behind a session, with separate knobs. The third constructor argument (messageBacklog, 1024 by default) bounds the decoded messages waiting for MessagesAsync. Notifications remain lossy: the oldest unread notification may be dropped and is counted in DroppedMessages. Peer requests are fail-loud: if making room would evict one, the session ends with ProcessError.OutputTooLarge, pending and later local requests fail with that same terminal error, and MessagesAsync faults after yielding anything still retained. The router never waits for this backlog, and responses to local requests bypass it, so a slow or absent message consumer cannot stall response correlation. Command.StreamBuffer bounds the raw frame backlog underneath, through the ContentLengthSession this session owns, and only its lossless full modes apply there: Backpressure paces the peer against the router, and Error ends the conversation with ProcessError.OutputTooLarge at the cap. DropOldest/DropNewest are refused when the session is constructed — the constructor throws ProcessException carrying ProcessError.Unsupported, since dropping a queued frame would delete a message the peer is correlating with a request. Leaving StreamBuffer unset keeps the default unbounded frame backlog.

MessagesAsync is a single-consumer stream of everything that is not an answer to your own requests: notifications (IsRequest false) and the peer's own requests (IsRequest true, answer them with RespondAsync / RespondRawAsync / RespondErrorAsync, which echo its id verbatim). Read ParamsJson or call ParamsAs<T>; answering a notification is a typed ProcessError.Unsupported, since the peer is not waiting for one. The backlog is bounded (1024 messages by default, the third constructor argument): when a consumer falls behind, old notifications may be dropped and counted in DroppedMessages rather than growing without limit. An unread peer request is never silently discarded — overflow at that point faults the conversation as described above.

This session owns the handle exactly as ContentLengthSession does — it creates that session itself, so the frames are never exposed for a second reader — and FinishInputAsync(cancellationToken) closes the peer's stdin for the usual shutdown/exit handshake. Cancellation while waiting for the JSON-RPC send gate or the transport's feeder/gate returns Error(ProcessError.Cancelled program) before EOF delivery, so the peer sees no EOF. Once delivery starts it is not cancellable, and the parameterless overload passes CancellationToken.None. Dispose the RunningProcess (or its owning ProcessGroup) to reap the tree.

Readiness probes

"Start a server, then use it" needs the server to be ready, not merely started. Nine probes replace the arbitrary sleep, each bounded by its own deadline and each returning a Result:

F#

task {
    match! (Command.create "my-server").StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc

        // 1. A line on stdout (returns the matching line):
        match! proc.WaitForLineAsync((fun line -> line.Contains "listening on"), TimeSpan.FromSeconds 10.0) with
        | Ok banner -> printfn $"server says: {banner}"
        | Error(ProcessError.NotReady(program, timeout)) -> eprintfn $"{program} not ready after {timeout}"
        | Error err -> eprintfn $"{err.Message}"

        // 2. A TCP port accepting connections:
        let endpoint = IPEndPoint(IPAddress.Loopback, 8080)

        match! proc.WaitForPortAsync(endpoint, TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "port is open"
        | Error err -> eprintfn $"{err.Message}"

        // 3. A Unix domain socket accepting connections:
        match! proc.WaitForSocketAsync("/run/my-server.sock", TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "socket is open"
        | Error(ProcessError.Unsupported detail) -> eprintfn $"this host can't dial AF_UNIX: {detail}"
        | Error err -> eprintfn $"{err.Message}"

        // 4. An HTTP endpoint (any 2xx response is ready by default):
        let health = Uri("http://127.0.0.1:8080/health")

        match! proc.WaitForHttpAsync(health, TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "HTTP health check passed"
        | Error err -> eprintfn $"{err.Message}"

        // Supply a configured, caller-owned client for auth headers, custom TLS, proxies, or UDS HTTP.
        use healthClient = new HttpClient()
        healthClient.DefaultRequestHeaders.Add("Authorization", "Bearer local-health-token")

        match! proc.WaitForHttpAsync(health, healthClient, TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "configured HTTP health check passed"
        | Error err -> eprintfn $"{err.Message}"

        // 5. A filesystem path appearing (a pidfile, a sentinel/lock file, a socket path
        //    someone else will dial) — existence only, not "fully written":
        match! proc.WaitForPathAsync("/run/my-server.pid", TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "pidfile is there"
        | Error err -> eprintfn $"{err.Message}"

        // 6. A Windows named pipe accepting a client connection (Windows-only):
        match! proc.WaitForNamedPipeAsync("my-service", TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "named pipe is open"
        | Error(ProcessError.Unsupported detail) -> eprintfn $"this host has no named pipes: {detail}"
        | Error err -> eprintfn $"{err.Message}"

        // 7. Any async predicate (a custom dependency check, a stronger "file is fully
        //    written" check, …):
        match! proc.WaitForAsync((fun () -> healthCheck ()), TimeSpan.FromSeconds 10.0) with
        | Ok() -> printfn "healthy"
        | Error err -> eprintfn $"{err.Message}"

        // 8. A line on STDERR — plenty of tools publish their readiness banner there
        //    (returns the matching line):
        match! proc.WaitForStderrLineAsync((fun line -> line.Contains "listening on"), TimeSpan.FromSeconds 10.0) with
        | Ok banner -> printfn $"server says: {banner}"
        | Error err -> eprintfn $"{err.Message}"

        // 9. A newline-free prompt on stderr — matched as the tail grows, without waiting
        //    for a line terminator that may never come (returns the tail that matched):
        match! proc.WaitForStderrTailAsync((fun tail -> tail.EndsWith "Password: "), TimeSpan.FromSeconds 10.0) with
        | Ok prompt -> printfn $"prompt: {prompt}"
        | Error err -> eprintfn $"{err.Message}"
}

C#

await using var proc = (await new Command("my-server").StartAsync()).GetValueOrThrow();

// 1. A line on stdout (returns the matching line):
Console.WriteLine(await proc.WaitForLineAsync(line => line.Contains("listening on"), TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true, ResultValue: var banner }                => $"server says: {banner}",
    { IsOk: false, ErrorValue: ProcessError.NotReady nr } => $"{nr.Program} not ready after {nr.Timeout}",
    { IsOk: false, ErrorValue: var err }                  => err.Message,
});

// 2. A TCP port accepting connections:
var endpoint = new IPEndPoint(IPAddress.Loopback, 8080);

Console.WriteLine(await proc.WaitForPortAsync(endpoint, TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "port is open",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 3. A Unix domain socket accepting connections:
Console.WriteLine(await proc.WaitForSocketAsync("/run/my-server.sock", TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "socket is open",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 4. An HTTP endpoint (any 2xx response is ready by default):
var health = new Uri("http://127.0.0.1:8080/health");

Console.WriteLine(await proc.WaitForHttpAsync(health, TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "HTTP health check passed",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// Supply a configured, caller-owned client for auth headers, custom TLS, proxies, or UDS HTTP.
using var healthClient = new HttpClient();
healthClient.DefaultRequestHeaders.Add("Authorization", "Bearer local-health-token");

Console.WriteLine(await proc.WaitForHttpAsync(health, healthClient, TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "configured HTTP health check passed",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 5. A filesystem path appearing (a pidfile, a sentinel/lock file, a socket path
//    someone else will dial) — existence only, not "fully written":
Console.WriteLine(await proc.WaitForPathAsync("/run/my-server.pid", TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "pidfile is there",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 6. A Windows named pipe accepting a client connection (Windows-only):
Console.WriteLine(await proc.WaitForNamedPipeAsync("my-service", TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "named pipe is open",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 7. Any async predicate (a custom dependency check, a stronger "file is fully written" check, …):
Console.WriteLine(await proc.WaitForAsync(() => healthCheck(), TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true }        => "healthy",
    { IsOk: false, ErrorValue: var err } => err.Message,
});

// 8. A line on STDERR — plenty of tools publish their readiness banner there
//    (returns the matching line):
Console.WriteLine(await proc.WaitForStderrLineAsync(line => line.Contains("listening on"), TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true, ResultValue: var banner } => $"server says: {banner}",
    { IsOk: false, ErrorValue: var err }    => err.Message,
});

// 9. A newline-free prompt on stderr — matched as the tail grows, without waiting for a line
//    terminator that may never come (returns the tail that matched):
Console.WriteLine(await proc.WaitForStderrTailAsync(tail => tail.EndsWith("Password: "), TimeSpan.FromSeconds(10)) switch
{
    { IsOk: true, ResultValue: var prompt } => $"prompt: {prompt}",
    { IsOk: false, ErrorValue: var err }    => err.Message,
});

Probe semantics are deliberately uniform:

  • A probe that can't pass within its deadline fails with ProcessError.NotReady — distinct from ProcessError.Timeout, which is the run's own deadline.
  • A probe also fails fast once readiness can no longer happen: the child exits, or the stream it watches closes — its stdout for WaitForLineAsync, its stderr for WaitForStderrLineAsync / WaitForStderrTailAsync — no waiting out a 10s deadline on a dead server. Observing the exit does not by itself discard readiness, though: the six external-condition probes (WaitForPortAsync / WaitForSocketAsync / WaitForHttpAsync / WaitForPathAsync / WaitForNamedPipeAsync / WaitForAsync) check the condition exactly one more time first — bounded by what is left of the deadline and by a brief grace — so a port, socket, endpoint, sentinel path, or pipe published immediately before the child terminated still reports Ok instead of being lost to that exit. Expect that one extra invocation of a WaitForAsync predicate; an already-cancelled token or a spent deadline skips it.
  • A failed probe never kills the child. You decide what happens next: retry, log and continue, or tear down.
  • All nine probes drain the child's piped stdout/stderr while they wait, so a chatty child that writes more than one OS pipe buffer of startup output (~64 KiB on Linux) before becoming ready can't block in write() and spuriously fail the probe with NotReady. The three output-watching probes keep what they drain: WaitForLineAsync hands the drained stdout back to you (consumed up to and including the matching line — continue with FinishAsync() or further streaming afterwards), and WaitForStderrLineAsync / WaitForStderrTailAsync leave both streams where the streaming session puts them (stdout queued for a stream you may still take, stderr captured for FinishAsync). WaitForPortAsync / WaitForSocketAsync / WaitForHttpAsync / WaitForPathAsync / WaitForNamedPipeAsync / WaitForAsync discard what they drain and stop draining once the probe concludes. After one of those six, a capture verb called afterward (OutputStringAsync/OutputBytesAsync/a fresh StdoutLinesAsync/OutputEventsAsync) only sees output the child wrote after the probe concluded — run probes before a capturing verb if you need the complete output.
  • WaitForSocketAsync requires the host to support AF_UNIX sockets (Windows 10 1809+, any current Linux/macOS); a host without that support fails immediately with ProcessError.Unsupported, before ever attempting to dial — never a silent downgrade or a hang.
  • WaitForNamedPipeAsync dials a Windows named pipe (CreateFileW, tried against duplex, read-only, then write-only client access so readiness does not depend on the server's data-flow direction) and is available only on Windows; every other platform fails immediately with ProcessError.Unsupported, before ever attempting to open a pipe. A pipe name may be bare ("my-service", resolved under the local \\.\pipe\ namespace) or already fully qualified (\\.\pipe\my-service, or a remote \\server\pipe\my-service). A pipe reporting ERROR_PIPE_BUSY — every instance currently serving another client — counts as ready: it proves a server created the pipe, which genuinely differs from the pipe not existing at all (the latter keeps polling).
  • WaitForPathAsync checks existence only — a file and a directory both count, and it does not wait for the writer to finish. A pidfile a daemon touches before it is done writing elsewhere is reported ready the instant it appears; probe a stronger "fully written" condition yourself with WaitForAsync when that distinction matters. A lookup failure (permissions, a transient I/O error) is treated the same as "not there yet" and retried until the deadline, and this probe never returns ProcessError.Unsupported — an existence check has no platform precondition. A relative path resolves against the run's own CurrentDir (the child's working directory) when one was configured on the Command, otherwise against the calling process's own current directory — pass an absolute path for a child sentinel when no CurrentDir is set.

Readiness on stderr, including a prompt with no newline

WaitForStderrLineAsync is WaitForLineAsync pointed at the diagnostic stream, for the many tools that publish their readiness marker there rather than on stdout. Same contract throughout: the matching line is returned, an unmet condition within the deadline is ProcessError.NotReady (reporting the clamped deadline actually armed), a cancelled token is ProcessError.Cancelled, and stderr reaching EOF — because the child exited, or simply closed it — ends the wait promptly rather than burning the rest of the deadline. Lines are framed with Command.StderrLineTerminator and decoded with Command.StderrEncoding, so a wait sees exactly what Command.OnStderrLine, a StderrTee and Finished.Stderr see.

WaitForStderrTailAsync matches the unterminated tail instead: everything written since the last line terminator, offered to your predicate as it grows. That is the only way to see a prompt that carries no newline at all (Password: , Continue? [y/N] ), which a line-framing wait cannot deliver by construction — the pump holds such text in its assembly buffer until a terminator (or EOF) finally arrives. Content that does end up terminated is offered complete, right before it is framed, so a marker that turns out to have a newline after all still matches here.

Three things worth knowing before reaching for them:

  • They observe stderr; they do not take it. Whatever a wait matched still reaches Command.OnStderrLine, the StderrTee and the captured Finished.Stderr exactly once — a matched tail arrives there once, later, inside the line it is eventually framed into, never as an extra line of its own. What a wait does consume is its own readiness view: the line it matched and the lines it read past on the way are not offered to a later stderr wait, exactly as WaitForLineAsync consumes the stdout lines it reads.
  • They join the stdout streaming session rather than opening a second reader, so they compose with WaitForLineAsync, StdoutLinesAsync/StdoutJsonLinesAsync and a closing FinishAsync before or after — and a verb that owns the pipes outright (OutputStringAsync/OutputBytesAsync/ WaitAsync/ProfileAsync, OutputEventsAsync, either byte-chunk stream, an interactive session) makes a later stderr wait return the usual already-consumed ProcessError.Unsupported, as does a terminal FinishAsync that already discarded the session's stdout.
  • What they retain is bounded. Between one wait and the next, the framed stderr lines and the current unterminated tail are kept so a marker arriving in that gap is not lost — capped at OutputBufferPolicy.MaxBytes when the run set one (Command.OutputBuffer), else at 64 KiB. At the cap the tail is force-flushed after one last offer (the same rule that force-flushes an unterminated line into a capture) and the retained lines drop oldest-first, so a child that floods stderr with no line terminator cannot grow this. A marker larger than that cap is the one thing a tail wait cannot match — raise MaxBytes if you have one.

A run with no separate stderr stream — Command.MergeStderr, a Command.Pty run, StderrToFile, StdioMode.Inherit/Null — fails both waits immediately with ProcessError.Unsupported naming which of those it was, rather than a NotReady that would read as "the marker never came" for a stream that never existed. Under a merge those bytes are in stdout: wait for them with WaitForLineAsync. Your predicate runs on the pump's thread as each line/tail is framed, so keep it cheap and non-blocking (the rule Command.OnStderrLine already follows); a predicate that throws fails only its own wait, leaving the run and every other verb untouched.

WaitForAsync takes a function returning Task<bool> (Func<Task<bool>> from C#), so any async health check fits — re-evaluated until it returns true or the deadline elapses.

WaitForHttpAsync sends GET requests every 50ms until it receives a 2xx response. Pass a seq&lt;int&gt; of acceptable status codes or a Func<HttpResponseMessage, bool> overload when a non-2xx response or response-specific validation defines readiness. Every HTTP overload also accepts a caller-owned HttpClient, enabling authentication headers, custom certificate validation, proxies, and transports such as HTTP over a Unix domain socket; ProcessKit reuses but never mutates or disposes that client. HTTP probe URIs must be absolute, and an explicit acceptable-status sequence must contain at least one value. WaitForSocketAsync likewise rejects a socket path that the platform cannot encode before polling begins instead of spending the full timeout on a permanently invalid endpoint.

Racing several children

RunningProcess.WaitAny races several started handles and reports whichever exits first — the natural primitive for "first answer wins" or "restart whatever died". It returns WaitAnyResult directly (no Result wrapper), carrying the winner's Index in the array you passed and its Outcome. The array itself must be non-null, non-empty, and free of null elements — a violation throws (ArgumentNullException/ ArgumentException) rather than reporting through a Result, the same contract WaitAllAsync below uses:

F#

task {
    // Bound the race with a per-command Timeout — WaitAny applies none of its own.
    let withDeadline name =
        Command.create name |> Command.timeout (TimeSpan.FromSeconds 30.0)

    match! (withDeadline "replica-a").StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok a ->
        use _ = a

        match! (withDeadline "replica-b").StartAsync() with
        | Error err -> eprintfn $"{err.Message}"
        | Ok b ->
            use _ = b

            let! result = RunningProcess.WaitAnyAsync [| a; b |]
            printfn $"contender #{result.Index} exited first with {result.Outcome}"
}

C#

// Bound the race with a per-command Timeout — WaitAny applies none of its own.
Command withDeadline(string name) =>
    new Command(name).Timeout(TimeSpan.FromSeconds(30));

await using var a = (await withDeadline("replica-a").StartAsync()).GetValueOrThrow();
await using var b = (await withDeadline("replica-b").StartAsync()).GetValueOrThrow();

var first = await RunningProcess.WaitAnyAsync([a, b]);
Console.WriteLine($"contender #{first.Index} exited first with {first.Outcome}");

To join a fixed set instead of racing it, RunningProcess.WaitAll waits for all of them and returns every Outcome in input order (an Outcome[] directly — no Result wrapper), under the same non-null/non-empty/no-null-element contract:

F#

let! outcomes = RunningProcess.WaitAllAsync [| a; b |]
printfn $"{outcomes.Length} children done"

C#

var outcomes = await RunningProcess.WaitAllAsync([a, b]);
Console.WriteLine($"{outcomes.Length} children done");

Both apply no per-process timeout (bound the race with a Command.Timeout, as above) and do no output pumping — drain chatty children first, or give them a bounded output buffer policy, so a child can't stall on a full pipe while you wait.

Profiling a run

A RunningProcess reports its own resource usage live, and ProfileAsync() turns a whole run into a summary. The live gauges read the child process itself at any moment:

F#

task {
    match! (Command.create "crunch").StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok proc ->
        use _ = proc

        // Live, mid-run:
        printfn $"pid={proc.Pid} elapsed={proc.Elapsed} cpu={proc.CpuTime} peak={proc.PeakMemoryBytes}"

        // Capture + sample on an interval until exit (returns a RunProfile directly):
        let! profile = proc.ProfileAsync(TimeSpan.FromMilliseconds 100.0)

        printfn $"exit={profile.ExitCode} wall={profile.Duration} samples={profile.Samples}"
        printfn $"cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} avgCpu={profile.AvgCpuCores}"
        printfn $"read={profile.IoReadBytes} write={profile.IoWriteBytes}"
}

C#

await using var proc = (await new Command("crunch").StartAsync()).GetValueOrThrow();

// Live, mid-run:
Console.WriteLine($"pid={proc.Pid} elapsed={proc.Elapsed} cpu={proc.CpuTime} peak={proc.PeakMemoryBytes}");

// Capture + sample on an interval until exit (returns a RunProfile directly):
var profile = await proc.ProfileAsync(TimeSpan.FromMilliseconds(100));

Console.WriteLine($"exit={profile.ExitCode} wall={profile.Duration} samples={profile.Samples}");
Console.WriteLine($"cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} avgCpu={profile.AvgCpuCores}");
Console.WriteLine($"read={profile.IoReadBytes} write={profile.IoWriteBytes}");

ProfileAsync() with no argument uses a default sampling interval; ProfileAsync(interval) samples at the cadence you pick. The resulting RunProfile exposes ExitCode, Duration (wall clock), CpuTime (user + kernel), PeakMemoryBytes, the number of Samples taken, and AvgCpuCores — CPU time over wall time, so a value near 1.7 means roughly 1.7 cores were busy on average. IoReadBytes, IoWriteBytes, IoReadOperations, and IoWriteOperations report the whole private containment tree when one is attributable to this run (currently the per-run Windows Job Object).

CPU and memory describe the started child; the I/O counters describe its private tree. A run started inside a shared ProcessGroup leaves profile I/O as None, because the group aggregate also includes siblings. That includes Linux cgroup v2: sample its io.stat aggregate explicitly through ProcessGroup.Stats / SampleStatsAsync (Process groups). See the platform matrix for availability.


Next: Pseudo-terminal (PTY)