run method
Future<WorkspaceHookOutcome>
run({
- required WorkspaceHookKind kind,
- required String workspacePath,
- required HooksWorkflowConfig hooks,
Runs kind for workspacePath using config in hooks.
When the hook is unset for kind, returns a successful outcome with
empty output. When the hook fails or times out and kind is fatal, the
caller is expected to throw WorkspaceException with the proper code.
Implementation
Future<WorkspaceHookOutcome> run({
required WorkspaceHookKind kind,
required String workspacePath,
required HooksWorkflowConfig hooks,
}) async {
final script = _scriptFor(kind, hooks);
if (script == null || script.trim().isEmpty) {
return WorkspaceHookOutcome(
kind: kind,
succeeded: true,
timedOut: false,
exitCode: 0,
output: '',
);
}
logger.detail('[hook ${kind.keyName}] starting in $workspacePath');
final invocation = _resolveShellInvocation(hooks, script);
final Process process;
try {
process = await Process.start(
invocation.executable,
invocation.arguments,
workingDirectory: workspacePath,
runInShell: false,
);
} catch (e) {
logger.err('[hook ${kind.keyName}] failed to spawn: $e');
return WorkspaceHookOutcome(
kind: kind,
succeeded: false,
timedOut: false,
exitCode: null,
output: 'failed to spawn: $e',
);
}
// Bound the captured output at write time so a runaway hook script that
// floods stdout/stderr cannot OOM the orchestrator. The streams are still
// drained from the OS pipe buffer (so the child doesn't block on a full
// pipe), the data past `maxOutputBytes` is just dropped on the floor.
final outputBuffer = StringBuffer();
var truncated = false;
void appendBounded(String chunk) {
if (outputBuffer.length >= maxOutputBytes) {
truncated = true;
return;
}
final remaining = maxOutputBytes - outputBuffer.length;
if (chunk.length <= remaining) {
outputBuffer.write(chunk);
} else {
outputBuffer.write(chunk.substring(0, remaining));
truncated = true;
}
}
// Drain both streams to completion rather than cancelling them when the
// exit code arrives: `process.exitCode` can complete before buffered
// output has been delivered, and cancelling at that point silently drops
// the tail of the hook's output (observed on fast Linux CI runners).
final stdoutDone = process.stdout
.transform(utf8.decoder)
.forEach(appendBounded);
final stderrDone = process.stderr
.transform(utf8.decoder)
.forEach(appendBounded);
int? exitCode;
var timedOut = false;
exitCode = await process.exitCode.timeout(
hooks.timeout,
onTimeout: () {
timedOut = true;
process.kill(ProcessSignal.sigterm);
return -1;
},
);
// The streams close once the process is gone; the short timeout guards
// against a SIGTERM-ignoring child keeping the pipes open forever.
try {
await Future.wait([
stdoutDone,
stderrDone,
]).timeout(const Duration(seconds: 5));
} catch (_) {
// Decode errors or straggling pipes: keep whatever was captured.
}
final captured = truncated
? '${outputBuffer.toString()}...[truncated]'
: outputBuffer.toString();
final succeeded = !timedOut && exitCode == 0;
if (timedOut) {
logger.err(
'[hook ${kind.keyName}] timed out after ${hooks.timeout.inSeconds}s',
);
} else if (!succeeded) {
logger.err('[hook ${kind.keyName}] exited with code $exitCode');
} else {
logger.detail('[hook ${kind.keyName}] completed successfully');
}
return WorkspaceHookOutcome(
kind: kind,
succeeded: succeeded,
timedOut: timedOut,
exitCode: timedOut ? null : exitCode,
output: captured,
);
}