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.
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
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
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";
},
});
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.
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");
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.