Skip to main content
← Back to list
01Issue
BugShippedSwamp CLIPublic
Assigneeszack

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 --json

Expected 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-150 at 986f9022, lists the error code cancelled Operation was cancelled (e.g. Ctrl+C) and states Exit 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 --timeout as: "Surfaces code: "cancelled" on abort." At that revision src/libswamp/models/run.ts:578-582 yielded { 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 cancelled event that set the failed flag and print the run (src/presentation/renderers/model_method_run.ts:209-218 human, :330-333 JSON), 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 --server path 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=0

stdout: 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=0

The 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 creates abort = new AbortController() for SIGINT/shutdown, builds baseLibCtx from abort.signal, and for --timeout uses baseLibCtx.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 but abort.signal does not.
  • src/libswamp/models/run.ts:1073-1084: on the abort the generator marks the output cancelled and saves it. Then at :1174-1176 it does if (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's cancelled handlers never run and renderer.runFailed() stays false. The cancelled(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 if abort.signal.aborted (its own controller, which the timeout did not abort) or renderer.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.ts
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW+ 10 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/12/2026, 9:27:56 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
zack assigned zack10/12/2026, 8:56:44 PM

Sign in to post a ripple.