fix(bash): returned on stalled cancellation
Raced bash execution against the JavaScript abort signal and timeout so the tool returns even when native shell cleanup does not settle. Added regression coverage for native cleanup stalls on ESC abort and timeout. Fixes #1347
This commit is contained in:
@@ -48,8 +48,6 @@ export interface BashResult {
|
||||
artifactId?: string;
|
||||
}
|
||||
|
||||
const HARD_TIMEOUT_GRACE_MS = 5_000;
|
||||
|
||||
const shellSessions = new Map<string, Shell>();
|
||||
const brokenShellSessions = new Set<string>();
|
||||
|
||||
@@ -106,8 +104,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
// sink.push() is synchronous — buffer management, counters, and onChunk
|
||||
// all run inline. File writes (artifact path) are handled asynchronously
|
||||
// inside the sink. No promise chain needed.
|
||||
let acceptingChunks = true;
|
||||
const enqueueChunk = (chunk: string) => {
|
||||
sink.push(chunk);
|
||||
if (acceptingChunks) sink.push(chunk);
|
||||
};
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
@@ -144,21 +143,22 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
void shellSession.abort();
|
||||
}
|
||||
};
|
||||
const abortDeferred = Promise.withResolvers<"abort">();
|
||||
const abortHandler = () => {
|
||||
abortCurrentExecution();
|
||||
abortDeferred.resolve("abort");
|
||||
};
|
||||
if (userSignal) {
|
||||
userSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
let hardTimeoutTimer: NodeJS.Timeout | undefined;
|
||||
const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">();
|
||||
let timeoutTimer: NodeJS.Timeout | undefined;
|
||||
const timeoutDeferred = Promise.withResolvers<"timeout">();
|
||||
const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
|
||||
const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS;
|
||||
hardTimeoutTimer = setTimeout(() => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
abortCurrentExecution();
|
||||
hardTimeoutDeferred.resolve("hard-timeout");
|
||||
}, hardTimeoutMs);
|
||||
timeoutDeferred.resolve("timeout");
|
||||
}, baseTimeoutMs);
|
||||
|
||||
let resetSession = false;
|
||||
|
||||
@@ -198,10 +198,13 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
|
||||
const winner = await Promise.race([
|
||||
runPromise.then(result => ({ kind: "result" as const, result })),
|
||||
hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
|
||||
timeoutDeferred.promise.then(kind => ({ kind })),
|
||||
abortDeferred.promise.then(kind => ({ kind })),
|
||||
]);
|
||||
|
||||
if (winner.kind === "hard-timeout") {
|
||||
if (winner.kind === "timeout" || winner.kind === "abort") {
|
||||
acceptingChunks = false;
|
||||
void runPromise.catch(() => undefined);
|
||||
if (shellSession) {
|
||||
resetSession = true;
|
||||
// Fall back to one-shot execution for the rest of the process once
|
||||
@@ -211,9 +214,17 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
|
||||
...(await sink.dump(
|
||||
winner.kind === "timeout"
|
||||
? `Command timed out after ${Math.round(baseTimeoutMs / 1000)} seconds`
|
||||
: "Command cancelled",
|
||||
)),
|
||||
};
|
||||
}
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
timeoutTimer = undefined;
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (winner.result.timedOut) {
|
||||
@@ -268,8 +279,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
resetSession = true;
|
||||
throw err;
|
||||
} finally {
|
||||
if (hardTimeoutTimer) {
|
||||
clearTimeout(hardTimeoutTimer);
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (userSignal) {
|
||||
userSignal.removeEventListener("abort", abortHandler);
|
||||
|
||||
Reference in New Issue
Block a user