startShellJob method
Future<Result<ShellJob, ExecutionError> >
startShellJob(
- String command, {
- required String id,
- required String logPath,
- ShellExecOptions? options,
override
Starts command detached: stdout/stderr append to logPath and the
returned ShellJob keeps running until it exits or is stopped.
ShellExecOptions.timeout and ShellExecOptions.cancelToken still
apply (both stop the job).
Implementation
@override
Future<Result<ShellJob, ExecutionError>> startShellJob(
String command, {
required String id,
required String logPath,
ShellExecOptions? options,
}) async {
final token = options?.cancelToken;
if (token?.isCancelled ?? false) {
return const Err(ExecutionError(ExecutionErrorCode.aborted, 'aborted'));
}
// Issue #919 (review): build the ceiling BEFORE anything is spawned —
// a bad ceiling used to throw after `Process.start` plus the eager log
// open, stranding an orphan child and leaking the fd with no job
// object to stop or settle. Here it degrades to a plain Err.
final warn = options?.onJobLogWarning;
final JobLogCeiling ceiling;
try {
ceiling = JobLogCeiling(
maxBytes: options?.jobLogMaxBytes ?? defaultJobLogMaxBytes,
probe: diskFreeProbe == null
? null
: () => diskFreeProbe!(File(logPath).parent.path),
onWarn: warn == null
? null
: (message) => warn('background job $id: $message'),
);
} on ArgumentError catch (error) {
return Err(
ExecutionError(
ExecutionErrorCode.spawnError,
'invalid jobLogMaxBytes: ${error.message}',
cause: error,
),
);
}
final ownGroup = LocalShell.ownProcessGroupAvailable;
final started = await _start(command, options, ownSession: ownGroup);
if (started.isErr) return Err(started.errorOrNull!);
final process = started.valueOrNull!;
// Feed optional stdin data (bash tool `stdin` param). With a live
// stdin channel (issue #367) the pipe stays OPEN for the process's
// lifetime so a mid-run password ask can be answered; otherwise it
// closes right after start — background jobs are not interactive
// beyond this.
final liveStdin = options?.liveStdin;
if (liveStdin != null) {
liveStdin.bind(process.stdin.write);
}
if (options?.stdinData != null) {
try {
process.stdin.write(options!.stdinData);
await process.stdin.flush();
} on Object {
// Process already gone — the settle path reports the real status.
}
}
if (liveStdin == null) unawaited(process.stdin.close());
final RandomAccessFile logSink;
try {
// Issue #925: open the log eagerly and guard it HERE. `File.openWrite`
// starts its open lazily-but-eagerly with no owner for the failure —
// an error (missing directory, permissions, ENOSPC) surfaced as an
// unlistened future and reached the root-zone handler, killing the
// whole fa process. An awaited open turns every open-class failure
// into this clean Err instead.
logSink = await File(logPath).open(mode: FileMode.append);
} on Object catch (error) {
process.kill();
return Err(
ExecutionError(
ExecutionErrorCode.spawnError,
'cannot open job log file $logPath: $error',
cause: error,
),
);
}
// Issue #919: bound the log — the ceiling (size ceiling with
// head+marker+rolling tail, plus the low-disk guard with the probe
// injectable via [LocalShell]) was built pre-spawn above.
return Ok(
_LocalShellJob(
id: id,
command: command,
logPath: logPath,
process: process,
logSink: logSink,
ceiling: ceiling,
timeout: options?.timeout,
token: token,
ownGroup: ownGroup,
),
);
}