Relationships
#3317 model method run --timeout exits 0 and prints nothing when the timeout cancels the run
Opened by zack · 10/12/2026· Shipped 10/12/2026
Severity: medium — a run that was cancelled and produced no data is reported to the caller as a success (exit 0, empty --json output), so scripts and agents that use --timeout to bound a run and retry or stop on failure will carry on as if the method had completed. The run record itself is correct (status: "cancelled"), so there is a workaround: read swamp model method history get <model> --json after every run.
Affects: swamp 20261012.194115.0-sha.9b7f54f4: reproduced by hand and by the UAT test below, and the new preferred spelling swamp behavior run sleeper sleep --timeout 1s --json does the same (exit 0, nothing on stdout or stderr, run recorded as cancelled). First written up against 20261009.215038.0-sha.986f9022; the paths and line numbers below are at that revision. src/libswamp/models/run.ts and src/cli/commands/model_method_run.ts have changed since, so the lines have moved, but the silent if (output.status === "cancelled") { return; } is still in run.ts on source main at 3b7bb2ee.
Summary
When a local swamp model method run ... --timeout <d> is cancelled by its timeout, the command exits 0. With --json it writes nothing at all to stdout or stderr; in human mode it prints the "Resolved" and "Executing" lines and then stops, with no "Cancelled" line and no error. The run is correctly recorded as cancelled with error message timed out, and the per-model lock is released; only the exit code and the output of the command are wrong.
Cancelling the same run with Ctrl+C (SIGINT) does exit 1, because the command checks its own abort controller. The timeout uses a different signal that this check does not see.
Steps to reproduce
Needs a model whose method honours context.signal (the built-in command/shell does not).
mkdir /tmp/timeout-repro && cd /tmp/timeout-repro
swamp init
mkdir -p extensions/models/test
cat > extensions/models/test/abort-aware-sleep.ts <<'EOF'
import { z } from "npm:zod@4";
export const model = {
type: "@test/abort-aware-sleep",
version: "2026.05.05.1",
inputAttributesSchema: z.object({}),
resources: {},
methods: {
sleep: {
description: "Sleep 30s, respecting ctx.signal so --timeout aborts cleanly",
arguments: z.object({}),
execute: async (_args: unknown, context: Record<string, unknown>) => {
const signal = context.signal as AbortSignal | undefined;
if (signal?.aborted) {
throw signal.reason ?? new DOMException("aborted", "AbortError");
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 30_000);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("aborted", "AbortError"));
});
});
return { dataHandles: [] };
},
},
},
};
EOF
swamp model create @test/abort-aware-sleep sleeper
swamp model method run sleeper sleep --timeout 1s --json; echo "exit=$?"
swamp model method run sleeper sleep --timeout 1s; echo "exit=$?"
swamp model method history get sleeper --jsonExpected behaviour
The cancelled run exits non-zero (1) and says it was cancelled: under --json either the run object with "status": "cancelled" on stdout, or an error envelope with "code": "cancelled"; in human mode a "Cancelled" line.
What establishes this:
- The command's own help,
src/cli/commands/model_method_run.ts:148-150at 986f9022, lists the error codecancelled Operation was cancelled (e.g. Ctrl+C)and statesExit codes: 0 = success, 1 = general error, 75 = lock contention. A cancelled run is not a success. - The commit that added the flag, dbe5588e (swamp-club#235, #1305), describes
--timeoutas: "Surfacescode: "cancelled"on abort." At that revisionsrc/libswamp/models/run.ts:578-582yielded{ kind: "error", error: cancelled(error) }on an abort, which the CLI turns into an error and exit 1. - The renderer already has handlers for a
cancelledevent that set the failed flag and print the run (src/presentation/renderers/model_method_run.ts:209-218human,:330-333JSON), and the event type declares it (src/libswamp/models/run.ts:183). - The same command cancelled by SIGINT exits 1 (
src/cli/commands/model_method_run.ts:567-570), and the--serverpath of the same command aborts the CLI's own controller on--timeout(:642-646), so the local timeout path is the odd one out.
Actual behaviour
Observed with swamp 20261009.215038.0-sha.986f9022:
$ swamp model method run sleeper sleep --timeout 1s --json; echo "exit=$?"
exit=0stdout: 0 bytes. stderr: 0 bytes.
$ swamp model method run sleeper sleep --timeout 1s; echo "exit=$?"
Resolved sleeper (@test/abort-aware-sleep)
Executing sleep · 14:28:10 UTC
exit=0The run record shows that the run was in fact cancelled by the timeout:
$ swamp model method history get sleeper --json
{
...
"methodName": "sleep",
"status": "cancelled",
"startedAt": "2026-10-10T14:28:10.425Z",
"completedAt": "2026-10-10T14:28:11.416Z",
"durationMs": 991,
...
"error": {
"message": "timed out"
}
}Root cause
Paths and lines at 986f9022.
src/cli/commands/model_method_run.ts:428-442: the command createsabort = new AbortController()for SIGINT/shutdown, buildsbaseLibCtxfromabort.signal, and for--timeoutusesbaseLibCtx.withTimeout(timeoutMs).withTimeout(src/libswamp/context.ts:76-82) builds a new signal,AbortSignal.any([signal, AbortSignal.timeout(ms)]). When the timeout fires, that derived signal aborts butabort.signaldoes not.src/libswamp/models/run.ts:1073-1084: on the abort the generator marks the output cancelled and saves it. Then at:1174-1176it doesif (output.status === "cancelled") { return; }and ends without yielding any event. The declared{ kind: "cancelled" }event (:183) is never yielded anywhere for a model method run (the only emitters of that kind are in the workflow code), so the renderer'scancelledhandlers never run andrenderer.runFailed()stays false. Thecancelled(error)error event just below (:1178-1182) is unreachable for this case.src/cli/commands/model_method_run.ts:567-575: after the event stream ends the command sets exit code 1 only ifabort.signal.aborted(its own controller, which the timeout did not abort) orrenderer.runFailed()(false). Neither holds, so it exits 0 having rendered nothing.
The silent return was added by 89973acb (#808, #1683, "execution cancellation"), together with the abort.signal.aborted check, which covers SIGINT. Before that commit, a timeout abort yielded the cancelled error event and so exited 1. Nothing in that commit says a timeout should exit 0; the unit test modelMethodRun marks output cancelled on abort (src/libswamp/models/run_test.ts:440-474) pins "no error event should be yielded" for the library, which leaves the exit code to the CLI, and the CLI check does not cover the timeout signal.
How the UAT found it
Test file tests/cli/adversarial/method_run_timeout_test.ts in swamp-uat, test "swamp model method run --timeout exits non-zero and reports the run as cancelled".
Before the audit this test did not exist. The file had one test, "swamp model method run --timeout releases per-model locks on abort", which ran the timed-out command through the helper runExpectingFailure. Despite its name that helper did not check the exit code: it only captured the output, and the test ignored the result and asserted only that the run took under 10 seconds and that a follow-up data delete was not blocked by a leaked lock. A timed-out run exiting 0 with no output passed.
The audit made runExpectingFailure assert a non-zero exit code (src/cli/helpers/error_helpers.ts), which exposed the exit 0. The lock test was then switched to a helper that accepts either exit code, so it keeps testing only the lock, and was tightened to check that the run lasted at least 1s and is recorded as cancelled / timed out. The exit code and output contract were split into the new test, which asserts that the command exits 1 and that --json reports the cancellation either as a run object with status: "cancelled" on stdout or as an error envelope with code: "cancelled" on stderr. It fails at the first of these:
AssertionError: Command "swamp model method run sleeper sleep --timeout 1s --json" was expected to fail but exited 0:To run it from the swamp-uat repo:
deno test --allow-read --allow-write --allow-env --allow-run --allow-net \
--filter "exits non-zero and reports the run as cancelled" \
tests/cli/adversarial/method_run_timeout_test.tsShipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.