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[] = [];