Skip to main content
Version: 1.0.0

Cancellation, kill switch and run timeout

Distinguish a successful run, a pending user interaction and controlled cancellation. The timeout is cooperative: this example waits two seconds in a real handler, then refuses the next dispatch after a one-second run budget. It is not an OS hard kill and cannot undo an acquired external effect.

Prerequisites: Touba distribution, Node.js 24 and npm. Use a macOS/Linux terminal.

Terminal
mkdir -p cancel-demo
cd cancel-demo
mkdir -p handlers vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz

Program and handler​

cancel-demo.tb
module examples.cancel_demo

tool Wait
description "Wait locally for the specified number of milliseconds without writing external state."
takes milliseconds as Int
gives Text

strategy RunCancelDemo
gives Text
do
show
with message as "run started"
ask
with prompt as "Continue?"
into answer
show
with message as "answer received: " ++ answer
give answer

strategy RunTimeoutDemo
gives Text
uses Wait
do
Wait
with milliseconds as 2000
into first
Wait
with milliseconds as 1
into second
give second
handlers/wait.mjs
import { setTimeout } from "node:timers/promises";
import { defineTool } from "@touba/sdk";
await defineTool({ toolRef: "tool:examples.cancel_demo/Wait",
inputSignature: [{ name: "milliseconds", type_ref: "builtin:Int" }], outputType: "builtin:Text",
requiredGrants: [], run: async ({ request }) => {
await setTimeout(request);
return "wait completed";
},
});
configure.mjs
import { writeFileSync } from "node:fs";
writeFileSync("touba.manifest.json", JSON.stringify({ manifest_version: "touba-manifest-v1", bindings: {
"tool:examples.cancel_demo/Wait": {
handler: { kind: "process", command: process.execPath, args: ["handlers/wait.mjs"], cwd: "." },
grants: [], secrets: [], effect_contract: { kind: "none" },
timeout_ms: 10000, max_payload_bytes: 65536,
},
} }, null, 2));

Check the outcomes​

The script obtains the real interaction_ref from the pending result. The local variable name answer is not the interaction identifier. A pending run exits with code 3; cancellation, kill switch and timeout exit with code 5. Each result is saved separately, including its trace. No response is fabricated on cancel.

exercise.mjs
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
function run(name, extra, exitCode, reason, entry = "RunCancelDemo") {
const child = spawnSync("touba", ["run", "cancel-demo.air.json", "--entry",
"strategy:examples.cancel_demo/" + entry, "--manifest", "touba.manifest.json", "--output", "json", ...extra],
{ encoding: "utf8" });
assert.equal(child.status, exitCode, child.stderr || child.stdout);
const result = JSON.parse(child.stdout);
assert.equal(result.reason, reason);
writeFileSync(name + ".result.json", JSON.stringify(result, null, 2));
return result;
}
const pending = run("pending", [], 3, "user_input_pending");
assert.equal(pending.status, "stopped");
const ref = pending.pending_interaction.interaction_ref;
assert.equal(typeof ref, "string");
const complete = run("complete", ["--user-inputs-json", JSON.stringify({ [ref]: "yes" })], 0, "completed");
assert.equal(complete.result, "yes");
for (const [name, flag, reason] of [
["before", "--cancel-before-dispatch", "cancelled"],
["pause", "--cancel-on-pause", "cancelled"],
["kill", "--kill-switch-enabled", "kill_switch"],
]) {
const result = run(name, [flag], 5, reason);
assert.equal(result.status, "cancelled");
assert.equal(result.result, null);
assert.ok(!JSON.stringify(result.messages).includes("answer received:"));
assert.ok(result.trace.events.some(event => event.kind === "run_cancelled"));
}
const timeout = run("timeout", ["--run-timeout-ms", "1000"], 5, "timeout", "RunTimeoutDemo");
assert.equal(timeout.status, "cancelled");
assert.ok(timeout.trace.events.some(event => event.kind === "run_timeout_exceeded"));
assert.ok(timeout.trace.events.some(event => event.kind === "dispatch_refused_cancelled"));
console.log("PAUSE_ANSWER_CANCEL_KILL_AND_TIMEOUT_OK");
Terminal
node configure.mjs
touba compile cancel-demo.tb --out cancel-demo.air.json
node exercise.mjs

Expected output: PAUSE_ANSWER_CANCEL_KILL_AND_TIMEOUT_OK. Cancellation does not provide durable recovery or distributed kill-switch coordination; those are separate execution and host responsibilities.