Skip to content

Host cancellation is unreliable: compute-bound scripts never observe it, and scripts can swallow CancellationError #20

Description

@odrobnik

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

while true { }

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions