diff --git a/integration/shell_timeout_test.ts b/integration/shell_timeout_test.ts new file mode 100644 index 00000000..5006e8a7 --- /dev/null +++ b/integration/shell_timeout_test.ts @@ -0,0 +1,127 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +/** + * In-repo regression guard for swamp-club#247: built-in command/shell must + * honor `--timeout` end-to-end. Drives the CLI binary against a long-running + * shell command and asserts the run aborts well before the natural sleep + * duration. Catches regressions in PRs before swamp-uat picks them up on + * a separate release cadence. + */ + +import { assertEquals } from "@std/assert"; +import { initializeTestRepo, runCliCommand } from "./test_helpers.ts"; +import { YamlDefinitionRepository } from "../src/infrastructure/persistence/yaml_definition_repository.ts"; +import { Definition } from "../src/domain/definitions/definition.ts"; +import { SHELL_MODEL_TYPE } from "../src/domain/models/command/shell/shell_model.ts"; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "swamp-shell-timeout-" }); + try { + await fn(dir); + } finally { + if (Deno.build.os === "windows") { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(dir, { recursive: true }); + } + } +} + +Deno.test({ + name: + "CLI: --timeout aborts a long-running command/shell run well before completion", + // Uses POSIX `sleep`. PowerShell variant via `Start-Sleep` is plausible + // but would need a separate model definition; defer until needed. + ignore: Deno.build.os === "windows", + fn: async () => { + await withTempDir(async (repoDir) => { + await initializeTestRepo(repoDir); + + // Sleep 30s — picked so a working --timeout cannot be confused with + // natural completion under any reasonable CI jitter. + const definitionRepo = new YamlDefinitionRepository(repoDir); + const definition = Definition.create({ + name: "long-sleep", + methods: { + execute: { + arguments: { + run: "sleep 30", + workingDir: "/tmp", + }, + }, + }, + }); + await definitionRepo.save(SHELL_MODEL_TYPE, definition); + + const start = performance.now(); + const result = await runCliCommand( + [ + "model", + "method", + "run", + "long-sleep", + "execute", + "--repo-dir", + repoDir, + "--timeout", + "1s", + "--json", + ], + Deno.cwd(), + ); + const elapsed = performance.now() - start; + + // Non-zero exit confirms the run did not complete naturally. + assertEquals( + result.code !== 0, + true, + `expected non-zero exit on --timeout abort, got ${result.code}. ` + + `stdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + // Generous 10s ceiling absorbs CI jitter and CLI startup time + // (worktree may need to compile fresh deps); still well below the + // 30s natural duration that would indicate the abort never fired. + assertEquals( + elapsed < 10_000, + true, + `expected --timeout to abort within 10s, took ${elapsed.toFixed(0)}ms`, + ); + // The output should mention the abort/cancellation. The exact + // envelope shape (`code: "cancelled"` vs generic + // `method_execution_failed`) depends on libswamp's TimeoutError + // handling — match loosely so this guards the user-visible + // contract without pinning the wire format. + // The JSON envelope must carry `code: "cancelled"` so callers can + // distinguish a deadline abort from a generic execution failure. + // Without the driver-layer AbortError preservation, this surfaces + // as `method_execution_failed` and the contract regresses. + const envelope = JSON.parse(result.stdout) as { + code?: string; + error?: string; + }; + assertEquals( + envelope.code, + "cancelled", + `expected envelope.code === "cancelled", got ${envelope.code}. ` + + `full stdout: ${result.stdout}`, + ); + }); + }, +}); diff --git a/src/cli/commands/model_method_run.ts b/src/cli/commands/model_method_run.ts index b941c5ac..36c73b28 100644 --- a/src/cli/commands/model_method_run.ts +++ b/src/cli/commands/model_method_run.ts @@ -138,7 +138,7 @@ export const modelMethodRunCommand = new Command() ) .option( "--timeout ", - "Cancellation deadline — seconds (e.g. 30, 1800) or duration string (e.g. 30s, 5m, 1h). Cooperative — only honored by methods that check AbortSignal.", + "Cancels the run when reached — seconds (e.g. 30, 1800) or duration string (e.g. 30s, 5m, 1h). Extension model methods must check ctx.signal to honor cancellation.", ) .action( // @ts-expect-error - Cliffy custom type returns unknown instead of string diff --git a/src/cli/commands/workflow_run.ts b/src/cli/commands/workflow_run.ts index 07e18157..8eeee31d 100644 --- a/src/cli/commands/workflow_run.ts +++ b/src/cli/commands/workflow_run.ts @@ -129,7 +129,7 @@ export const workflowRunCommand = new Command() ) .option( "--timeout ", - "Cancellation deadline — seconds (e.g. 30, 1800) or duration string (e.g. 30s, 5m, 1h). Cooperative — only honored by methods that check AbortSignal.", + "Cancels the run when reached — seconds (e.g. 30, 1800) or duration string (e.g. 30s, 5m, 1h). Extension model methods must check ctx.signal to honor cancellation.", ) // @ts-expect-error - Cliffy custom type returns unknown instead of string .action(async function (options: AnyOptions, workflowIdOrName: string) { diff --git a/src/domain/drivers/execution_driver.ts b/src/domain/drivers/execution_driver.ts index c9ebf89f..5fd8e8d2 100644 --- a/src/domain/drivers/execution_driver.ts +++ b/src/domain/drivers/execution_driver.ts @@ -89,6 +89,13 @@ export interface ExecutionResult { status: "success" | "error"; /** Error message if status is "error". */ error?: string; + /** + * True when the error originated from an aborted AbortSignal (e.g. + * --timeout fired). Lets the caller distinguish cancellation from a + * generic execution failure even though `error` flattens the exception + * to a string. + */ + cancelled?: boolean; /** Outputs produced during execution. */ outputs: DriverOutput[]; /** Log lines captured during execution. */ diff --git a/src/domain/drivers/raw_execution_driver.ts b/src/domain/drivers/raw_execution_driver.ts index 3c438595..c38863c8 100644 --- a/src/domain/drivers/raw_execution_driver.ts +++ b/src/domain/drivers/raw_execution_driver.ts @@ -200,9 +200,12 @@ export class RawExecutionDriver implements ExecutionDriver { handle, })); + const cancelled = error instanceof DOMException && + error.name === "AbortError"; return { status: "error", error: error instanceof Error ? error.message : String(error), + cancelled, outputs, logs, durationMs, diff --git a/src/domain/drivers/raw_execution_driver_test.ts b/src/domain/drivers/raw_execution_driver_test.ts index 3e88f460..74b776dd 100644 --- a/src/domain/drivers/raw_execution_driver_test.ts +++ b/src/domain/drivers/raw_execution_driver_test.ts @@ -444,3 +444,61 @@ Deno.test("RawExecutionDriver: explicit queryData wins over dataQueryService der assertEquals(usedExplicit, true); assertEquals(usedDerived, false); }); + +// AbortError preservation across the driver boundary — guards swamp-club#247. +// The driver flattens exceptions to a string for the wire result, but it must +// also set `cancelled: true` when the failure was an AbortError so callers +// (method_execution_service) can re-throw an AbortError DOMException, which +// libswamp's run handler routes to the `cancelled` envelope. + +Deno.test( + "RawExecutionDriver: marks result.cancelled=true when method throws AbortError", + async () => { + const executor: MethodExecutor = { + execute: () => { + throw new DOMException("The operation was aborted.", "AbortError"); + }, + }; + + const context = createMockContext(); + const driver = new RawExecutionDriver( + executor, + testDefinition, + testMethod, + testModelDef, + context, + "test", + ); + + const result = await driver.execute(createMockRequest()); + + assertEquals(result.status, "error"); + assertEquals(result.cancelled, true); + }, +); + +Deno.test( + "RawExecutionDriver: leaves result.cancelled falsy for non-abort errors", + async () => { + const executor: MethodExecutor = { + execute: () => { + throw new Error("boom"); + }, + }; + + const context = createMockContext(); + const driver = new RawExecutionDriver( + executor, + testDefinition, + testMethod, + testModelDef, + context, + "test", + ); + + const result = await driver.execute(createMockRequest()); + + assertEquals(result.status, "error"); + assertEquals(result.cancelled, false); + }, +); diff --git a/src/domain/models/command/shell/shell_model.ts b/src/domain/models/command/shell/shell_model.ts index da1e20d4..287a0125 100644 --- a/src/domain/models/command/shell/shell_model.ts +++ b/src/domain/models/command/shell/shell_model.ts @@ -125,6 +125,7 @@ async function executeCommand( timeoutMs: args.timeout, logger: context.logger, redactor: context.redactor, + signal: context.signal, onOutput: context.onEvent ? (line: string, stream: "stdout" | "stderr") => context.onEvent!({ type: "output", line, stream }) @@ -136,6 +137,12 @@ async function executeCommand( exitCode = result.exitCode; durationMs = result.durationMs; } catch (error) { + // AbortError must escape ahead of the swallow paths below so that + // libswamp's run.ts handler can convert it to a `cancelled` envelope. + // Without this, --timeout aborts get buried as exit code -1. + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } // Handle execution errors (command not found, timeout, etc.) const rawError = error instanceof Error ? error.message : String(error); stderr = redact(rawError); diff --git a/src/domain/models/command/shell/shell_model_test.ts b/src/domain/models/command/shell/shell_model_test.ts index fa15fd27..afba9e26 100644 --- a/src/domain/models/command/shell/shell_model_test.ts +++ b/src/domain/models/command/shell/shell_model_test.ts @@ -533,6 +533,75 @@ posixOnlyTest( }, ); +// Abort signal propagation — guards swamp-club#247. +// shell_model must surface AbortError ahead of its silent-swallow paths so +// libswamp's run.ts can convert it to a `cancelled` envelope. + +posixOnlyTest( + "shellModel.methods.execute surfaces AbortError when ctx.signal aborts mid-run", + async () => { + const controller = new AbortController(); + const args: ShellInputAttributes = { run: "sleep 5" }; + + const { context } = createTestContext({ signal: controller.signal }); + setTimeout(() => controller.abort(), 100); + + const caught = await assertRejects(() => + shellModel.methods.execute.execute(args, context) + ); + // Must be an AbortError — NOT the generic "Command exited with code -1" + // wrapper that the catch block swallows other errors into. + assertEquals(caught instanceof DOMException, true); + assertEquals((caught as DOMException).name, "AbortError"); + }, +); + +posixOnlyTest( + "shellModel.methods.execute writes no data record on abort", + async () => { + const controller = new AbortController(); + const args: ShellInputAttributes = { run: "sleep 5" }; + + const { context, getResults } = createTestContext({ + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 100); + + await assertRejects(() => + shellModel.methods.execute.execute(args, context) + ); + + // AbortError must escape before context.writeResource is called, so no + // data artifacts should have been persisted for the aborted run. + assertEquals(getResults().length, 0); + }, +); + +posixOnlyTest( + "shellModel.methods.execute does not let ignoreExitCode swallow AbortError", + async () => { + // ignoreExitCode is a data-plane signal ("don't throw on non-zero exit"). + // A timeout is a control-plane signal — the deadline elapsed. The two + // must not be conflated: even with ignoreExitCode=true, an aborted run + // must surface AbortError so the user sees a `cancelled` envelope, not + // a "successful" record with exit code 143. + const controller = new AbortController(); + const args: ShellInputAttributes = { + run: "sleep 5", + ignoreExitCode: true, + }; + + const { context } = createTestContext({ signal: controller.signal }); + setTimeout(() => controller.abort(), 100); + + const caught = await assertRejects(() => + shellModel.methods.execute.execute(args, context) + ); + assertEquals(caught instanceof DOMException, true); + assertEquals((caught as DOMException).name, "AbortError"); + }, +); + Deno.test("ShellInputAttributesSchema rejects invalid attributes", () => { const result = ShellInputAttributesSchema.safeParse({ notRun: "value" }); assertEquals(result.success, false); diff --git a/src/domain/models/method_execution_service.ts b/src/domain/models/method_execution_service.ts index 94b75d29..5b41187e 100644 --- a/src/domain/models/method_execution_service.ts +++ b/src/domain/models/method_execution_service.ts @@ -668,9 +668,16 @@ export class DefaultMethodExecutionService implements MethodExecutionService { currentHandles = await processDriverOutputs(driverResult.outputs); if (driverResult.status === "error") { - const err = new Error( - driverResult.error ?? "Method execution failed", - ); + // Preserve AbortError type so libswamp's run handler routes it to + // the `cancelled` envelope. Drivers flatten exceptions to a string + // for the wire result, so use the explicit `cancelled` flag rather + // than string-matching the message. + const err: Error = driverResult.cancelled + ? new DOMException( + driverResult.error ?? "The operation was aborted.", + "AbortError", + ) + : new Error(driverResult.error ?? "Method execution failed"); (err as unknown as Record).dataHandles = currentHandles; throw err; @@ -724,6 +731,16 @@ export class DefaultMethodExecutionService implements MethodExecutionService { })); if (driverResult.status === "error") { + // Preserve AbortError type so libswamp's run handler routes it to + // the `cancelled` envelope. Drivers flatten exceptions to a string + // for the wire result, so use the explicit `cancelled` flag rather + // than string-matching the message. + if (driverResult.cancelled) { + throw new DOMException( + driverResult.error ?? "The operation was aborted.", + "AbortError", + ); + } throw new Error(driverResult.error ?? "Driver execution failed"); } diff --git a/src/infrastructure/process/process_executor.ts b/src/infrastructure/process/process_executor.ts index 1ce658a7..1889bca8 100644 --- a/src/infrastructure/process/process_executor.ts +++ b/src/infrastructure/process/process_executor.ts @@ -60,13 +60,54 @@ export interface ProcessResult { durationMs: number; } +/** + * Attaches a SIGTERM-on-abort listener to the given child process. Returns a + * cleanup function that detaches the listener — call it from a `finally` block + * to avoid leaking listeners when the process exits normally. + * + * Why a manual listener rather than `Deno.CommandOptions.signal`: the native + * option works for direct AbortControllers but does not reliably propagate + * `AbortSignal.any([..., AbortSignal.timeout(...)])` on Linux (observed in + * CI for swamp-club#247). The manual `addEventListener('abort')` path is + * proven to work uniformly across platforms. + */ +function attachSignalKill( + process: Deno.ChildProcess, + signal: AbortSignal, +): () => void { + if (signal.aborted) { + try { + process.kill("SIGTERM"); + } catch { + // Process may have already exited + } + return () => {}; + } + const handler = () => { + try { + process.kill("SIGTERM"); + } catch { + // Process may have already exited + } + }; + signal.addEventListener("abort", handler, { once: true }); + return () => signal.removeEventListener("abort", handler); +} + /** * Reads lines from a ReadableStream, calling onLine for each complete line. * Returns the full accumulated output as a string. + * + * When `signal` is provided, the read loop releases the reader and returns + * as soon as the signal aborts. This unblocks callers when the underlying + * pipe stays open due to grandchildren that inherited it (e.g. dash on Linux + * forks `sleep 30` from `sh -c`; SIGTERM kills sh but the orphan keeps the + * write end of the pipe open, so the deno-side reader never sees EOF). */ export async function streamLines( stream: ReadableStream, onLine?: (line: string) => void, + signal?: AbortSignal, ): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -75,10 +116,25 @@ export async function streamLines( try { while (true) { - const { done, value } = await reader.read(); - if (done) break; + // Race the next read against the abort signal so an aborted run + // doesn't block on an inherited-pipe orphan that never EOFs. + const readPromise = reader.read(); + const result = signal && !signal.aborted + ? await Promise.race([ + readPromise, + new Promise<{ done: true; value: undefined }>((resolve) => { + signal.addEventListener( + "abort", + () => resolve({ done: true, value: undefined }), + { once: true }, + ); + }), + ]) + : await readPromise; + + if (signal?.aborted || result.done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(result.value, { stream: true }); const bufferLines = buffer.split("\n"); // Process all complete lines @@ -140,6 +196,9 @@ export async function executeProcess( const process = command.spawn(); let timeoutId: number | undefined; let timedOut = false; + const detachSignal = options.signal + ? attachSignalKill(process, options.signal) + : () => {}; if (options.timeoutMs) { timeoutId = setTimeout(() => { @@ -152,32 +211,14 @@ export async function executeProcess( }, options.timeoutMs); } - // Kill subprocess when abort signal fires - let abortHandler: (() => void) | undefined; - if (options.signal) { - if (options.signal.aborted) { - try { - process.kill("SIGTERM"); - } catch { - // Process may have already exited - } - } else { - abortHandler = () => { - try { - process.kill("SIGTERM"); - } catch { - // Process may have already exited - } - }; - options.signal.addEventListener("abort", abortHandler, { once: true }); - } - } - try { const logger = options.logger; const redact = (line: string) => options.redactor?.hasSecrets ? options.redactor.redact(line) : line; const onOutput = options.onOutput; + // Pass the abort signal down so the read loops bail out when an + // orphaned grandchild keeps the pipe write end open after the parent + // shell dies (dash on Linux, see streamLines docs). const [stdoutResult, stderrResult, status] = await Promise.all([ streamLines(process.stdout, (line) => { const redacted = redact(line); @@ -187,7 +228,7 @@ export async function executeProcess( } else { logger.info(redacted); } - }), + }, options.signal), streamLines(process.stderr, (line) => { const redacted = redact(line); if (onOutput) { @@ -196,7 +237,7 @@ export async function executeProcess( } else { logger.warn(redacted); } - }), + }, options.signal), process.status, ]); @@ -207,16 +248,17 @@ export async function executeProcess( if (timeoutId !== undefined) { clearTimeout(timeoutId); } - if (abortHandler && options.signal) { - options.signal.removeEventListener("abort", abortHandler); - } + detachSignal(); } if (timedOut) { throw new Error(`Command timed out after ${options.timeoutMs}ms`); } - // Re-throw as AbortError if signal was responsible for the kill + // Surface AbortError if the signal aborted — the manual SIGTERM in + // attachSignalKill resolves `process.status` with `code: 143` rather + // than rejecting, so this normalization is load-bearing for libswamp's + // run.ts handler to route it to the `cancelled` envelope. if (options.signal?.aborted) { throw new DOMException("The operation was aborted.", "AbortError"); } @@ -224,6 +266,9 @@ export async function executeProcess( // Buffered with timeout const process = command.spawn(); let timedOut = false; + const detachSignal = options.signal + ? attachSignalKill(process, options.signal) + : () => {}; const timeoutId = setTimeout(() => { timedOut = true; @@ -241,17 +286,36 @@ export async function executeProcess( exitCode = output.code; } finally { clearTimeout(timeoutId); + detachSignal(); } if (timedOut) { throw new Error(`Command timed out after ${options.timeoutMs}ms`); } + + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } } else { - // Simple buffered execution - const output = await command.output(); - stdout = new TextDecoder().decode(output.stdout); - stderr = new TextDecoder().decode(output.stderr); - exitCode = output.code; + // Simple buffered execution. Use spawn() + output() so the manual + // abort listener can call process.kill() — `command.output()` does + // not expose the underlying child handle. + const process = command.spawn(); + const detachSignal = options.signal + ? attachSignalKill(process, options.signal) + : () => {}; + try { + const output = await process.output(); + stdout = new TextDecoder().decode(output.stdout); + stderr = new TextDecoder().decode(output.stderr); + exitCode = output.code; + } finally { + detachSignal(); + } + + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } } const durationMs = Date.now() - startTime; diff --git a/src/infrastructure/process/process_executor_test.ts b/src/infrastructure/process/process_executor_test.ts index 9bace5f9..e491473d 100644 --- a/src/infrastructure/process/process_executor_test.ts +++ b/src/infrastructure/process/process_executor_test.ts @@ -202,9 +202,9 @@ Deno.test("executeProcess handles timeout with logger", async () => { Deno.test({ name: "executeProcess: AbortSignal aborted mid-execution surfaces AbortError (streaming mode)", - // sleep(1) and SIGTERM via process.kill are POSIX-only contracts. The - // production code only attaches abort handling in streaming mode (when - // a logger is provided), so we exercise that path here. + // sleep(1) and SIGTERM via process.kill are POSIX-only contracts. This + // test pins the streaming-mode (logger-attached) signal path; the two + // tests below cover the buffered+timeoutMs and simple-buffered paths. ignore: Deno.build.os === "windows", fn: async () => { const mockLogger = { @@ -278,6 +278,162 @@ Deno.test({ }, }); +// AbortSignal handling in the buffered branches — guards swamp-club#247. +// The streaming-mode signal test at line 200+ already covers the +// logger-attached path. These two pin the contract for the buffered +// branches (with timeoutMs, and simple buffered) so the documented +// `signal` option works uniformly across all three execution modes. + +Deno.test({ + name: + "executeProcess: AbortSignal aborts buffered+timeoutMs run with AbortError", + ignore: Deno.build.os === "windows", + fn: async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + + const start = performance.now(); + let caught: unknown; + try { + await executeProcess({ + command: "sleep", + args: ["5"], + timeoutMs: 10_000, + signal: controller.signal, + }); + } catch (err) { + caught = err; + } + const elapsed = performance.now() - start; + + const err = caught as { name?: string }; + assertEquals(err?.name, "AbortError"); + // Sanity: the abort short-circuited well before the 10s natural timeout. + assertEquals(elapsed < 5_000, true, `elapsed ${elapsed}ms exceeded 5s`); + }, +}); + +Deno.test({ + name: + "executeProcess: AbortSignal aborts simple buffered run with AbortError", + ignore: Deno.build.os === "windows", + fn: async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + + const start = performance.now(); + let caught: unknown; + try { + await executeProcess({ + command: "sleep", + args: ["5"], + signal: controller.signal, + }); + } catch (err) { + caught = err; + } + const elapsed = performance.now() - start; + + const err = caught as { name?: string }; + assertEquals(err?.name, "AbortError"); + assertEquals(elapsed < 5_000, true, `elapsed ${elapsed}ms exceeded 5s`); + }, +}); + +// Regression for swamp-club#247: libswamp's `withTimeout` builds a signal as +// `AbortSignal.any([userSignal, AbortSignal.timeout(ms)])`. An earlier fix +// attempt relied on Deno.Command's native `signal` option, which propagated +// for direct AbortControllers but not reliably for `AbortSignal.any` of a +// timeout (observed on Linux CI). This test pins the actual production +// pattern so a future regression to native-signal-only fails here, not in +// flaky integration coverage. +Deno.test({ + name: + "executeProcess: AbortSignal.any + AbortSignal.timeout aborts streaming run", + ignore: Deno.build.os === "windows", + fn: async () => { + const mockLogger = { + info: () => {}, + warn: () => {}, + } as unknown as import("@logtape/logtape").Logger; + + const userController = new AbortController(); + const combined = AbortSignal.any([ + userController.signal, + AbortSignal.timeout(100), + ]); + + const start = performance.now(); + let caught: unknown; + try { + await executeProcess({ + command: "sleep", + args: ["5"], + logger: mockLogger, + signal: combined, + }); + } catch (err) { + caught = err; + } + const elapsed = performance.now() - start; + + const err = caught as { name?: string }; + assertEquals(err?.name, "AbortError"); + assertEquals(elapsed < 5_000, true, `elapsed ${elapsed}ms exceeded 5s`); + }, +}); + +// Regression for the Linux-CI failure mode of swamp-club#247: dash forks +// rather than exec'ing single-command `sh -c` invocations, so SIGTERM to +// the parent shell leaves an orphaned grandchild holding the stdout/stderr +// pipes open. Without abort-aware stream reads, Promise.all blocks until +// the orphan exits naturally. /bin/dash is reliably present on Linux CI +// runners and on macOS too (this test exercises the same dash binary on +// both platforms so the local result mirrors CI). +Deno.test({ + name: + "executeProcess: aborts even when child shell forks a long-running grandchild (dash)", + ignore: Deno.build.os === "windows", + fn: async () => { + try { + await Deno.stat("/bin/dash"); + } catch { + // dash absent — skip rather than fail; the regression this test + // guards against requires a fork-then-exec shell. + return; + } + + const mockLogger = { + info: () => {}, + warn: () => {}, + } as unknown as import("@logtape/logtape").Logger; + + const combined = AbortSignal.any([AbortSignal.timeout(500)]); + + const start = performance.now(); + let caught: unknown; + try { + await executeProcess({ + command: "/bin/dash", + args: ["-c", "sleep 30"], + logger: mockLogger, + signal: combined, + }); + } catch (err) { + caught = err; + } + const elapsed = performance.now() - start; + + const err = caught as { name?: string }; + assertEquals(err?.name, "AbortError"); + assertEquals( + elapsed < 5_000, + true, + `elapsed ${elapsed}ms exceeded 5s — orphan-pipe regression?`, + ); + }, +}); + Deno.test("executeProcess redacts secrets from streamed stdout lines", async () => { const infoLines: string[] = []; const warnLines: string[] = [];