Streaming & interactive I/O
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
- Streaming stdout line by line
- Streaming stdout as byte chunks
- Streaming NDJSON / JSON Lines
- Interleaving stdout and stderr
- Bounding the streaming backlog
- Finishing a streamed run
- Interactive stdin
- Readiness probes
- Racing several children
- Profiling a run
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 theOutcome; output is discarded.FinishAsync()— after streaming, collect theOutcomeand (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
0x00–0xFF 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 all — ProcessResult.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.
StdoutToFileleaves stderr on its ordinary pipe, soProcessResult.Stderr,OnStderrLine,StderrTee, and the stderr streaming verbs all still work — and vice versa forStderrToFile. - 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 forStreamBufferPolicy.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 (carryingProcessError.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.Timeoutkills 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 aWriteAsynccall only unblocks when either the channel gets read from again or theRunningProcessitself is disposed. In other words: pairingBackpressurewithCommand.Timeoutbounds the child's lifetime, not necessarily your consumer's. - Give your own consumption loop a deadline (a
CancellationTokenpassed toGetAsyncEnumerator(token), or a read-side timeout around eachMoveNextAsync()), and make sure youDispose/DisposeAsynctheRunningProcesspromptly if you give up on it. If you deliberately hand the run to a terminal operation after abandoning the stream,FinishAsync(),StopAsync(),WaitAnyAsync/WaitAllAsync, andDisposeAsynccancel 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.
| Counter | Counts |
|---|---|
StdoutLineCount / StderrLineCount | framed lines pumped so far (including lines that were then dropped) |
DroppedStreamLineCount | items a dropping StreamBuffer policy discarded (lines/events, or chunks for the byte streams) |
StdoutBytesSeen / StderrBytesSeen | raw 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/OutputBytesAsyncreturn the partial capture withProcessResult.Truncatedset — symmetric for text and bytes.- A line or chunk stream ends where it was cut, and
FinishAsyncreportsFinished.Truncated. - An event stream (
OutputEventsAsync) ends where it was cut too, but with no separate signal:FinishAsyncis 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 withOutputStringAsync/OutputBytesAsync. WaitAsync/ProfileAsync, which retain nothing anyway, simply conclude.WaitAnyAsync/WaitAllAsyncresolve 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 withProcessError.OutputIncomplete. That is its own case, not theOutputTooLargea buffer policy's truncation gets: nothing crossed a ceiling here, and noOutputBuffersetting would have changed the outcome. UseOutputStringAsync/OutputBytesAsyncwhen you want the partial payload plusTruncatedinstead.
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 option — Some 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/StopAsyncthat 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
RunningProcessat 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 happened | Result |
|---|---|
The peer answered with an error object | ProcessError.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 fired | ProcessError.Cancelled |
| A timeout or token interrupted a send mid-frame | The 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 anything | The same ProcessError.Timeout/Cancelled, failing only that call — nothing reached the peer, so the session stays usable |
A StreamBuffer cap with StreamFullMode.Error filled up | ProcessError.OutputTooLarge, ending the session like a protocol failure (see the backlog note below) |
| An unread peer request would be evicted from the decoded-message backlog | ProcessError.OutputTooLarge, ending the session instead of silently losing a request the peer is waiting on |
| The peer's framed output ended before answering | ProcessError.Io — and every later verb fails the same way instead of waiting forever |
The result does not fit the requested type | ProcessError.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 member | ProcessError.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 fromProcessError.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 forWaitForStderrLineAsync/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 reportsOkinstead of being lost to that exit. Expect that one extra invocation of aWaitForAsyncpredicate; 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 withNotReady. The three output-watching probes keep what they drain:WaitForLineAsynchands the drained stdout back to you (consumed up to and including the matching line — continue withFinishAsync()or further streaming afterwards), andWaitForStderrLineAsync/WaitForStderrTailAsyncleave both streams where the streaming session puts them (stdout queued for a stream you may still take, stderr captured forFinishAsync).WaitForPortAsync/WaitForSocketAsync/WaitForHttpAsync/WaitForPathAsync/WaitForNamedPipeAsync/WaitForAsyncdiscard what they drain and stop draining once the probe concludes. After one of those six, a capture verb called afterward (OutputStringAsync/OutputBytesAsync/a freshStdoutLinesAsync/OutputEventsAsync) only sees output the child wrote after the probe concluded — run probes before a capturing verb if you need the complete output. WaitForSocketAsyncrequires the host to supportAF_UNIXsockets (Windows 10 1809+, any current Linux/macOS); a host without that support fails immediately withProcessError.Unsupported, before ever attempting to dial — never a silent downgrade or a hang.WaitForNamedPipeAsyncdials 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 withProcessError.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 reportingERROR_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).WaitForPathAsyncchecks existence only — a file and a directory both count, and it does not wait for the writer to finish. A pidfile a daemontouches before it is done writing elsewhere is reported ready the instant it appears; probe a stronger "fully written" condition yourself withWaitForAsyncwhen 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 returnsProcessError.Unsupported— an existence check has no platform precondition. A relativepathresolves against the run's ownCurrentDir(the child's working directory) when one was configured on theCommand, otherwise against the calling process's own current directory — pass an absolutepathfor a child sentinel when noCurrentDiris 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, theStderrTeeand the capturedFinished.Stderrexactly 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 asWaitForLineAsyncconsumes the stdout lines it reads. - They join the stdout streaming session rather than opening a second reader, so they compose
with
WaitForLineAsync,StdoutLinesAsync/StdoutJsonLinesAsyncand a closingFinishAsyncbefore 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-consumedProcessError.Unsupported, as does a terminalFinishAsyncthat 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.MaxByteswhen 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 — raiseMaxBytesif 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<int> 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)