7. Process Execution and Stream Lifetime
A typed command crosses into operating-system behavior through an executor and process runner. The key runtime guarantee is that both output channels and the actual process exit can be consumed without confusing a log milestone with completion.
Execution lifetime also belongs to the consumer: abandoning a live stream is an operation with consequences for the owned process.
Resolving and preparing an invocation
IBinaryResolver maps a BinaryIdentifier to a BinaryBinding. The binding supplies an executable path, optional detected version, environment defaults and any configured command overrides.
CommandExecutor resolves once for an execution. It combines command-path segments with ToArguments(), merges binding and command environments into an independent dictionary, and creates ProcessSpec.
BinaryIdentifier -> BinaryBinding
ICliCommand -> CommandPath + ToArguments() + ToEnvironment()
Both -> ProcessSpec -> IProcessRunnerBinaryIdentifier -> BinaryBinding
ICliCommand -> CommandPath + ToArguments() + ToEnvironment()
Both -> ProcessSpec -> IProcessRunnerA dictionary resolver uses supplied bindings. It does not itself prove that DetectedVersion describes the executable currently found at the path. Detection and truthful binding configuration are composition responsibilities.
Three consumption modes
The runtime supports buffered raw execution, typed streaming and typed collected execution.
| Mode | Result | Main consequence |
|---|---|---|
| Raw | Result<ProcessOutput, CommandError> |
Complete stdout and stderr are buffered. |
| Typed stream | IAsyncEnumerable<TEvent> |
Events arrive during execution and terminal parser events remain observable after a nonzero exit. |
| Typed collection | Result<TResult, CommandError> |
A collector sees parsed events; nonzero exit returns failure instead of its final collected value. |
Generated clients construct commands. Applications explicitly pass them to a runner or executor. Re-enumerating an execution handle is another execution, not a replay of previously observed events.
Transport events and drainage
The process transport emits ProcessOutputReceived values wrapping OutputLine, then a final ProcessExited value.
The executor rejects output after the exit event and a stream that ends without exit status. This makes a custom transport's terminal contract explicit.
The default interface implementation of IProcessRunner.StreamEventsAsync calls buffered RunAsync and replays its output with the real exit code. Existing transports can remain compatible this way, but must override it to retain live streaming. Implementing only a line-stream method is insufficient for live typed execution through this path.
Backpressure and ordering
ProcessRunnerCore uses a bounded channel of 256 transport events, with wait behavior when full. A slow consumer therefore applies pressure rather than causing the channel to grow indefinitely.
stdout and stderr are read concurrently. Their observed interleaving is the ordering delivered by this transport; it is not a universal timestamped total order of the tool's internal operations. Within an event, OutputLine.Source records the received channel.
A forwarded child's stderr can arrive on a Compose process's stdout. The provenance is accurate about the process boundary observed by BinaryWrapper. It cannot reconstruct a channel identity that the upstream tool already discarded.
Collecting and handling failure
Typed collected execution passes line events and final parser events to collector.OnEvent. If the process exits zero, it calls collector.Complete() and returns the value. If the process exits nonzero, it returns CommandError and does not call the collector's Complete().
The executor keeps a bounded stderr diagnostic excerpt for collected failure. It does not accumulate the entire potentially unbounded stdout stream merely to create that error.
The following complete offline example demonstrates the difference. Its only output is a plausible readiness milestone, but its process exits 7.
using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "vagrant",
DetectedVersion = SemanticVersion.Parse("2.4.9"),
};
var command = new VagrantUpCommand(binding.DetectedVersion)
{
MachineReadable = true,
};
var transport = new FakeProcessRunner([
new OutputLine(
"1700000000,web,ui,output,Machine booted and ready!",
OutputSource.StdOut),
], exitCode: 7);
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
await foreach (var evt in executor.StreamAsync(
binding.Identifier, command, VagrantOutputStrategies.Default))
if (evt is VagrantCommandExited exited)
Console.WriteLine($"Streaming still exposes exit {exited.ExitCode}");
var collected = await executor.ExecuteAsync(binding.Identifier, command,
VagrantOutputStrategies.Default, new VagrantUpCollector());
Console.WriteLine(collected.Match(
onSuccess: _ => "unexpected success",
onFailure: error => error.ToString()));using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "vagrant",
DetectedVersion = SemanticVersion.Parse("2.4.9"),
};
var command = new VagrantUpCommand(binding.DetectedVersion)
{
MachineReadable = true,
};
var transport = new FakeProcessRunner([
new OutputLine(
"1700000000,web,ui,output,Machine booted and ready!",
OutputSource.StdOut),
], exitCode: 7);
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
await foreach (var evt in executor.StreamAsync(
binding.Identifier, command, VagrantOutputStrategies.Default))
if (evt is VagrantCommandExited exited)
Console.WriteLine($"Streaming still exposes exit {exited.ExitCode}");
var collected = await executor.ExecuteAsync(binding.Identifier, command,
VagrantOutputStrategies.Default, new VagrantUpCollector());
Console.WriteLine(collected.Match(
onSuccess: _ => "unexpected success",
onFailure: error => error.ToString()));The parser can report readiness and later terminal failure without contradiction. A higher-level operation decides how to compensate or report that partial progress.
Cancellation, timeout and early disposal
The built-in runners own the process they start. Cancellation, a configured ProcessSpec.Timeout or disposal of an unfinished enumeration stops the process tree. Timeout and caller cancellation have distinct exception paths.
This matters for await foreach loops containing break, early-returning methods and Take-like stream consumers. Stopping enumeration does not merely stop displaying logs; it can terminate an operation that is still provisioning.
A caller that needs completion must drain the stream. A caller that wants to cancel should express that intentionally and account for partially applied infrastructure changes. The runner does not implement rollback.
What is and is not retained
The line transport removes line terminators. OutputLine.Text retains the resulting logical line, not a byte-for-byte recording with framing. Carriage-return redraws, binary output and NUL-delimited protocols need their own transport requirements.
Raw execution buffers full output and is appropriate for finite output. Typed streaming can process long-lived streams incrementally, but individual parsers may buffer multi-line documents under their own limits.
Typed output explains how parsers retain provenance while deriving semantic information from those logical lines.
Source snapshot
Source inspection: 2 October 2026. These implementation paths are relative to Net/FrenchExDev in the local ecosystem checkout. The v2 changes are largely uncommitted; these paths identify inspected code, without claiming that public branch links contain it.
BinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.Contracts/CoreContracts.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.Contracts/ProcessStreamEvent.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper/Code.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper/ProcessRunnerCore.csBinaryWrapper/test/FrenchExDev.Net.BinaryWrapper.Tests/ProcessLifecycleTests.cs
Previous · Series index · Next