exec method
Future<Result<ShellExecResult, ExecutionError> >
exec(
- String command, {
- ShellExecOptions? options,
override
Executes a shell command. Must never throw: all failures are encoded in the returned Result.
Implementation
@override
Future<Result<ShellExecResult, ExecutionError>> exec(
String command, {
ShellExecOptions? options,
}) async {
final token = options?.cancelToken;
if (token?.isCancelled ?? false) {
return const Err(ExecutionError(ExecutionErrorCode.aborted, 'aborted'));
}
// gh-1053: start the child in its own session/process group when the
// host can (same probe the background jobs use) — the timeout and
// cancel paths below then reap the WHOLE tree with one group signal
// instead of stranding a surviving grandchild on the output pipe.
final ownGroup = LocalShell.ownProcessGroupAvailable;
final started = await _start(command, options, ownSession: ownGroup);
if (started.isErr) return Err(started.errorOrNull!);
final process = started.valueOrNull!;
final stdout = StringBuffer();
final stderr = StringBuffer();
ExecutionError? callbackError;
await _wireProcessStdin(process, options);
final stdoutDone = process.stdout
.transform(utf8.decoder)
.forEach(
(chunk) => _collect(
stdout,
chunk,
options?.onStdout,
process,
(error) => callbackError = error,
),
);
final stderrDone = process.stderr
.transform(utf8.decoder)
.forEach(
(chunk) => _collect(
stderr,
chunk,
options?.onStderr,
process,
(error) => callbackError = error,
),
);
// Open-pipe tracker for the kill guards below: both futures complete
// when the pipe write end closes. A late stream error is consumed HERE
// only for the counting future — the awaiters below keep the original
// propagation semantics.
var openStreams = 2;
void streamClosed() => openStreams--;
unawaited(
stdoutDone.then(
(_) => streamClosed(),
onError: (Object _) => streamClosed(),
),
);
unawaited(
stderrDone.then(
(_) => streamClosed(),
onError: (Object _) => streamClosed(),
),
);
var childGone = false;
unawaited(process.exitCode.then((_) => childGone = true));
Timer? timer;
var timedOut = false;
final timeout = options?.timeout;
if (timeout != null) {
timer = Timer(timeout, () {
// The exec is fully settled: the child exited AND both pipes
// closed — nothing to reap, never signal (mirrors the job
// registry's isRunning check, which this approximates). With the
// child gone but a drain still in flight the signal still fires:
// a live group member is what's holding the pipe, so the group is
// ours — the residual recycled-pid window (pid freed by the reap
// and re-led before the signal lands) is theoretical and accepted,
// as in the job path.
if (childGone && openStreams == 0) return;
timedOut = true;
unawaited(_stopTree(process, ownGroup: ownGroup));
});
}
void onCancel(_) {
if (childGone && openStreams == 0) return;
unawaited(_stopTree(process, ownGroup: ownGroup));
}
token?.onCancel.then(onCancel);
final exitCode = await process.exitCode;
// gh-1053: the timer stays ARMED after the direct child exits — an
// orphaned descendant can hold the pipes past the child's death, and
// this timer is what bounds the call ("≤ timeout + kill grace + drain
// grace regardless of what descendants do"). It no-ops once the exec
// is fully settled (guard above); the settled drain
// cancels it below. Cancel-on-exit used to strand exactly the
// run-36421037356 shape (shell long dead, grandchild on the pipe).
if (options?.liveStdin != null) {
unawaited(process.stdin.close().catchError((_) {}));
}
// gh-1053 (review rework): the drain is capped UNCONDITIONALLY. This
// point is only reached after `process.exitCode` resolved, so every
// remaining byte on the pipes comes from an ORPHANED descendant
// holding the write end — waiting for it full-unbounded has no
// legitimate use (a caller wanting daemon output should use
// `run_in_bg`), with or without a timeout. The race never delays a
// healthy call: after the child's death the pipe buffer drains in
// milliseconds. Timeout/cancel'd calls complete in
// ≤ timeout + _killGrace + _drainGrace; a no-timeout call in
// ≤ child runtime + _drainGrace. No kill round for the no-timeout
// case — a detached daemon is the caller's on purpose.
final drained = Future.wait([stdoutDone, stderrDone]);
await Future.any([drained, Future<void>.delayed(_drainGrace)]);
// Once the grace won the race, a late stream error (malformed bytes
// from the dying tree) must never surface unhandled.
drained.ignore();
timer?.cancel();
// Read AFTER the waits: a cancel that lands mid-drain must still mark
// the result (the flag used to be captured pre-drain and lost).
final cancelled = token?.isCancelled ?? false;
return _result(
callbackError: callbackError,
timedOut: timedOut,
timeout: timeout,
cancelled: cancelled,
stdout: stdout,
stderr: stderr,
exitCode: exitCode,
);
}