StatifierBlocks.Compiler.Cancels (StatifierBlocks v0.8.0)

Copy Markdown View Source

The scope-shaped cancel: a delayed send armed by a child block is cancelled in the <onexit> of the state that armed it (ADR-0004's 2026-08-29 amendment, "a delayed send's cancel, emitted in the arming state's <onexit>").

Which state is "the state that armed the send"

The nearest enclosing scope state: the innermost <state> in the parent's own emission that contains the child, whichever state that turns out to be.

For most block types that is the parent block's own state, because ADR-0004 decision 2 gives one block exactly one state and a child's emission is spliced directly inside it: a core.send in a core.sequence body is cancelled by the sequence, and one nested a sequence deep inside a core.group is cancelled by the sequence rather than the group, because the sequence is nearer.

A block type whose emission puts its children inside a region is the case that makes "nearest" do work of its own, and there are two of them in this vocabulary. An interruptible core.group puts its body in a region, and abandoning the group takes an internal transition to the group's own final - which exits the body region without exiting the group, so a cancel on the group's <onexit> never fired for it. That one is a fix as much as a move. core.parallel's lanes are regions of its <parallel>, and a lane is a scope: leaving the lane - which under complete: first is exactly what the losing lanes do - has to reach the sends that lane armed. So a lane child's cancel is emitted in the region's <onexit>, not the wrapper's, and that is ADR-0004's complete: first amendment, P3, applied to both completion rules at once. A delayed send in a lane of an all parallel is cancelled when its region is exited for exactly the same reason.

Only a <state> claims. A <parallel> between the child and its nearest state does not, which is what keeps an interrupt rail's cancel on the enclosing group's state where the record puts it ("the enclosing group for a rail"): a rail child is a region of the group's <parallel>, and the nearest state above it is the group's own. The one exception is a block whose emission is itself rooted at a <parallel> - not something this package emits, since decision 2 makes every block's emission a <state> - which is still offered the cancels rather than dropping them on the floor, in the same spirit as decision 1's "a finding, never a raise".

A send block that is the document's root has no enclosing scope and therefore no cancel. Nothing above it is a state this package emits.

How a scope learns what its children armed

Not through the child's SCXML, which ADR-0004 decision 4 keeps out of a parent's reach, and not through a new emit/2 callback either. Through the send id, which is a state id in disguise: ADR-0002's amendment of the same date fixes it as <the send block's state id>__send, and StatifierBlocks.Compiler.StateId.unstate_id/1 inverts that back to {block id, "send"} exactly. So this pass reads each direct child's compiled emission, keeps every <send> whose id inverts to that child's own block id under the reserved role "send", and emits one <cancel sendid="..."> per hit in the scope's <onexit>.

Three properties follow from reading the id rather than the emitter:

  • A grandchild's send is not this scope's. Its id names the grandchild, so it fails the direct-child test here and was already cancelled by the nearer scope on its own pass. No send is cancelled twice and none is missed.
  • core.wait's timer is one of these sends. It mints its own delayed <send> under this same reserved role rather than a role of its own, so a wait left before its delay elapses is cancelled by its scope exactly as a core.send is. The wait's own state bounds only what the interpreter holds: a delayed send a durable host has already scheduled outlives that state, and would fire into a run that no longer wants it.
  • A host block type opts in by minting the same role. Nothing here names StatifierBlocks.Core.Send, so a host type that arms a cancellable delayed send gets the scope cancel by using role_id/2 the way core.send does.

Only a send carrying a delay is cancelled. An undelayed <send> is on the external queue before the enclosing state can be exited, so a <cancel> for it would be bytes that can never match a pending timer.

Which scope a child belongs to, and how it is found

The parent's emission carries a {:child, block_id} placeholder wherever a child's subtree will be spliced, so the parent has already said where each child sits. The walk below reads that: one post-order pass over the emission, in which a placeholder reports its own block id upward and each <state> claims the armed ids reported from inside it before the rest travel further up. Nothing here reads a slot name, and nothing names a block type - a host type that arranges its children into regions of its own gets per-region cancels by placing its placeholders, exactly as core.parallel does.

Determinism

Children are visited in slot-declaration order and then document order - the order StatifierBlocks.Compiler already hands them over - and within one scope the cancels are emitted in that order, so ADR-0004 decision 6 holds. The <onexit> is prepended to its scope state's children, ahead of the transitions the type wrote, which is one fixed position rather than a position that depends on what the type emitted. A block type that wrote an <onexit> of its own keeps it; SCXML runs both.

The pass runs on the parent's own emission before StatifierBlocks.Compiler.Attribution stamps it, so the <onexit> and its cancels are owned by the scope block and carry the scope state's role, which is the block an author would recognise: the cancel is a consequence of where the send sits in that scope, not of the send block itself. That stays true when the scope is a region: the region is a state the same block emitted.

Summary

Functions

Adds the scope <onexit> cancels to emission, the emission of the block that compiled_children are the direct children of.

The role a cancellable armed send's id carries: "send".

Functions

arm(emission, compiled_children)

Adds the scope <onexit> cancels to emission, the emission of the block that compiled_children are the direct children of.

compiled_children is the {block id, emission} list the compiler already holds after emitting the children. Returns emission unchanged when no direct child armed a delayed send - which is every scope in every document that contains no core.send, so no existing chart moves a byte.

armed_role()

@spec armed_role() :: String.t()

The role a cancellable armed send's id carries: "send".

StatifierBlocks.Core.Send mints its send id with StatifierBlocks.Compiler.Context.role_id(context, armed_role()), and this module reads it back, so the two halves of the convention name one string in one place.