Split out of the PR #17 review, where it was measured and ruled pre-existing: callingBridge has carried the same generic catch since #12, and the interpreter has never checked for cancellation anywhere (the only mention of isCancelled in the tree is the generated UnsafeCurrentTask bridge, which exposes the flag to scripts — nothing consults it on the interpreter's own behalf).
"Cancellation" here means the host cancelling the Swift Task running interpreter.eval(...) — an embedder's kill switch for a runaway or timed-out script. Two independent gaps keep that switch from working:
1. Compute-bound scripts never notice
Swift cancellation is cooperative — someone must call Task.checkCancellation() or await an API that does. The evaluation loops never check, so after the host cancels, a script busy in
runs forever. Cancellation only surfaces at all if the script happens to await a host API that honors it (URLSession, Task.sleep, …).
2. When it does surface, scripts can swallow it
A cancelled await inside a bridge or builtin body throws CancellationError, which hits the generic catch in callingBridge / callingBuiltin and is boxed into a catchable ScriptError like any other host error. So script-side try? — or any bare catch — absorbs the host's stop signal and execution continues:
do {
let data = try await URLSession.shared.data(from: url) // host cancels here
} catch {
// script decides to shrug and carry on — host's cancel is gone
}
Suggested shape
Both halves are small now that #17's sentinel infrastructure exists:
- Pass-through: treat
CancellationError as host control flow, same as ScriptExit and ScriptUncatchableError conformers — either extension CancellationError: ScriptUncatchableError {} inside the interpreter, or an explicit catch clause ahead of the generic one in both wrappers. Script catch/try? can then never absorb it, and the host reliably gets its typed error back.
- Check: a periodic
try Task.checkCancellation() in the statement-execution path (execute(item:) is the single funnel every statement passes through) so compute-only loops stop too. Per-statement is likely cheap enough; if not, every Nth statement or only in loop back-edges.
The one real design decision
Stock Swift treats CancellationError as an ordinary, catchable error — a cancelled script-side Task { } whose body wants to clean up would, in native Swift, be allowed to catch it. Making it uncatchable diverges from stock semantics in exactly the way ScriptExit already does, on the theory that in an embedded interpreter the error is the host's kill switch, not the script's business. If script-side cancellation handling ever matters, the pass-through could be scoped to the root task (host cancel) while script-spawned tasks keep stock semantics — but that's complexity to add when someone actually needs it.
Not blocking
No current embedder is known to rely on cancellation; today they get correct results by never cancelling. But any host that runs untrusted or agent-authored scripts with a timeout needs the kill switch to actually kill.
Split out of the PR #17 review, where it was measured and ruled pre-existing:
callingBridgehas carried the same generic catch since #12, and the interpreter has never checked for cancellation anywhere (the only mention ofisCancelledin the tree is the generatedUnsafeCurrentTaskbridge, which exposes the flag to scripts — nothing consults it on the interpreter's own behalf)."Cancellation" here means the host cancelling the Swift
Taskrunninginterpreter.eval(...)— an embedder's kill switch for a runaway or timed-out script. Two independent gaps keep that switch from working:1. Compute-bound scripts never notice
Swift cancellation is cooperative — someone must call
Task.checkCancellation()or await an API that does. The evaluation loops never check, so after the host cancels, a script busy inruns forever. Cancellation only surfaces at all if the script happens to await a host API that honors it (URLSession,
Task.sleep, …).2. When it does surface, scripts can swallow it
A cancelled
awaitinside a bridge or builtin body throwsCancellationError, which hits the generic catch incallingBridge/callingBuiltinand is boxed into a catchableScriptErrorlike any other host error. So script-sidetry?— or any barecatch— absorbs the host's stop signal and execution continues:Suggested shape
Both halves are small now that #17's sentinel infrastructure exists:
CancellationErroras host control flow, same asScriptExitandScriptUncatchableErrorconformers — eitherextension CancellationError: ScriptUncatchableError {}inside the interpreter, or an explicit catch clause ahead of the generic one in both wrappers. Scriptcatch/try?can then never absorb it, and the host reliably gets its typed error back.try Task.checkCancellation()in the statement-execution path (execute(item:)is the single funnel every statement passes through) so compute-only loops stop too. Per-statement is likely cheap enough; if not, every Nth statement or only in loop back-edges.The one real design decision
Stock Swift treats
CancellationErroras an ordinary, catchable error — a cancelled script-sideTask { }whose body wants to clean up would, in native Swift, be allowed to catch it. Making it uncatchable diverges from stock semantics in exactly the wayScriptExitalready does, on the theory that in an embedded interpreter the error is the host's kill switch, not the script's business. If script-side cancellation handling ever matters, the pass-through could be scoped to the root task (host cancel) while script-spawned tasks keep stock semantics — but that's complexity to add when someone actually needs it.Not blocking
No current embedder is known to rely on cancellation; today they get correct results by never cancelling. But any host that runs untrusted or agent-authored scripts with a timeout needs the kill switch to actually kill.