NAME

Acme::Parataxis::CancellationToken - Cooperative Wait Interruption and Cancellation Tokens

SYNOPSIS

use v5.40;
use Acme::Parataxis qw[async fiber await_sleep with_timeout];
use Acme::Parataxis::CancellationToken;

my $cancel = Acme::Parataxis::CancellationToken->new;    # nobody has cancelled it yet

async {
    fiber {
        await_sleep(50);
        $cancel->cancel;                                  # ... until this fiber says so
    };

    my $done = eval { with_timeout( 10_000, $cancel, sub { await_sleep(10_000) } ) };
    say $done // "gave up: $@";    # gave up: The operation was cancelled at await_sleep (...)
};

DESCRIPTION

A token is a flag paired with a registry of fiber IDs. A fiber that wants to be interruptible registers itself on the token; whoever wants that work stopped calls cancel, and the wait each registered fiber is sitting in throws an exception (Acme::Parataxis::Error::Cancelled by default) instead of returning.

Cancelling does not kill anything violently. It marks the registered fibers and wakes them. The exception is thrown at the re-entry of the specific wait they were parked in, so each one unwinds from its own execution point: eval guards, defers, finally blocks, and object destructors all run on the way out. An interrupted wait also unregisters itself from whatever primitive it was waiting on, so the fiber is not left attached to a channel, future, or semaphore that could wake it again later.

You rarely have to build a token manually:

  • with_cancel creates one and hands it to its block.

  • with_timeout arms one per deadline.

  • A nursery owns one to cancel siblings on failure.

  • run takes one as on_shutdown.

See "Tokens You Get for Free". Instantiate your own when the work is not yours to wrap in a block, or when multiple independent fibers need the authority to call off shared work.

METHODS

new( [ kind => 'cancel' ] )

my $cancel   = Acme::Parataxis::CancellationToken->new;
my $deadline = Acme::Parataxis::CancellationToken->new( kind => 'timeout' );

Constructs a fresh, uncancelled token.

  • kind => $str

    Picks the exception class thrown by interrupted waits: 'cancel' (the default) throws Acme::Parataxis::Error::Cancelled, while 'timeout' throws Acme::Parataxis::Error::Timeout. Any string is accepted and passed down to the diagnostic error object. Internal deadlines use 'timeout' so the scheduler raises timeout diagnostics through the unified cancellation path.

Arguments are keyword-only.

cancel( )

$cancel->cancel;    # returns true the first time, undef thereafter

Wakes every fiber currently registered with the token. If a registered fiber is currently executing CPU work rather than parked, it is flagged so its very next wait throws at entry.

Idempotent: the second and subsequent calls do nothing and return undef, meaning an if ($token->cancel) check fires exactly once. Call it from any context—another fiber, a signal handler, or a health check.

cancelled( )

if ( $token->cancelled ) { ... }

Returns true if cancel has been called on this token. Cancellation is permanent; once cancelled, a token cannot be reset. Create a new token for subsequent batches of work.

register( )

$token->register;    # returns the calling fiber's id

Registers the calling fiber so that cancel will interrupt it. Must be called from inside a scheduled fiber (croaks otherwise). Registering multiple times from the same fiber is a no-op; the registration list contains unique IDs, keeping $token->waiters accurate.

Registering against an already cancelled token does not queue anything: it marks the current fiber immediately so its next wait throws upon entry, turning what would have been a lost-wakeup race into an immediate error.

unregister( )

$token->unregister;    # returns the calling fiber's id

Removes the calling fiber from the token's registry so subsequent cancel calls ignore it. Safe to call any number of times, and safe to call outside a fiber (e.g. from a destructor). Pair this with register inside a defer block so completed work does not leak an armed slot (see "Behavior Notes").

waiters( )

my $armed = $token->waiters;

Returns the count of fibers currently registered on this token. Calling cancel clears the list, so a cancelled token reports 0.

t0( )

Returns the timestamp (via Time::HiRes::time) of when the token was instantiated. Used for trace diagnostics.

Tokens You Get for Free

  • with_cancel - with_cancel( sub ($token) { ... } )

    Runs the block under a fresh scope token and passes it as an argument so child fibers can cancel the scope from within. Returns the token in scalar context, or ($token, @values) in list context.

  • with_timeout - with_timeout( $ms, [$token,] $code )

    A token passed in second position allows manual cancellation alongside the deadline. Whichever fires first terminates the wait. A pre-cancelled token fails fast without running $code.

  • $nursery->token

    The nursery's internal cancellation switch. Cancelling it unwinds every enrolled child as if a sibling had died.

  • run( on_shutdown => $token )

    Binds $token as the graceful shutdown switch for the run loop, allowing in-process shutdown without delivering OS signals.

Recipes

Graceful shutdown via health check or signal

use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::CancellationToken;

my $shutdown = Acme::Parataxis::CancellationToken->new;

my $status = Acme::Parataxis->run(
    on_shutdown => $shutdown,
    code        => sub {
        fiber {
            $health_monitor->on_unhealthy( sub { $shutdown->cancel } );
        };
        await_sleep(60_000);    # aborted immediately if token fires
    },
);

# $status is 130 (SIGINT) or 143 (SIGTERM) if cancelled via on_shutdown

Every parked wait in the run—sleeps, socket polls, channel reads—is interrupted and drained. run catches the cancellation and returns the exit status rather than propagating an unhandled die.

Cancelling deeply nested waits you cannot thread a token into

When a wait is buried inside code whose signature you cannot change, register the fiber beforehand:

use v5.40;
use experimental 'defer';
use Acme::Parataxis qw[nursery await_sleep];
use Acme::Parataxis::CancellationToken;

sub fetch_cached ( $token, $key ) {
    $token->register;
    defer { $token->unregister };    # unregister even if compute throws
    return $cache->compute($key)->await;
}

my $abort = Acme::Parataxis::CancellationToken->new;

nursery( sub ($n) {
    $n->spawn( sub { fetch_cached( $abort, $_ ) } ) for @keys;
    $n->spawn( sub { await_sleep(5000); $abort->cancel } );
} );

Relocating or extending a deadline

my $deadline = Acme::Parataxis::CancellationToken->new( kind => 'timeout' );

my $job = fiber {
    $deadline->register;
    defer { $deadline->unregister };
    my $res = eval { $work->await };    # throws Error::Timeout, not Error::Cancelled
    return $res;
};

# The deadline is just another fiber that can be extended or rescheduled:
fiber {
    await_sleep(250);
    $deadline->cancel if $need_to_abort;
};

Behavior Notes

  • Where the exception lands

    At the wait boundary, never at the cancel call site. If the target fiber is currently parked, that wait throws as it resumes. If the target fiber is actively executing CPU code, the throw is deferred until it enters its next wait.

  • After catching a cancellation

    Inside a with_cancel scope, any subsequent wait also fails fast because the scope's token remains cancelled. Treat a caught cancellation as an instruction to exit the scope. In contrast, with a manual register call, the immediate interrupt flag is cleared once thrown; a subsequent wait will park normally unless the token is re-checked or re-registered.

  • Error hierarchy

    Default tokens throw Acme::Parataxis::Error::Cancelled; kind => 'timeout' tokens throw Acme::Parataxis::Error::Timeout. Both inherit from Acme::Parataxis::Error. A timeout is not a subclass of cancellation; check $err->kind (which returns 'cancelled' or 'timeout') rather than testing inheritance.

    $err->wait_reason returns [ $reason, $file, $line, $backtrace ] describing the wait that was interrupted. On a fast-fail path (where the token was already cancelled before entering the wait), $reason is set to 'cancel' or 'timeout' and the backtrace array is empty.

  • A leaked registration arms the next fiber

    Fiber IDs are recycled once a completed fiber is reaped. If a fiber registers on a token, finishes its work, and exits without calling unregister, its ID remains in the token's list. If a subsequent, unrelated fiber is later allocated that same ID, a future cancel on the old token will interrupt the new fiber mid-operation. Always unregister in a defer or finally block.

  • With with_cancel, the return value is the token

    with_cancel( sub ($t) { 'done' } ) returns the token itself in scalar context. The block's actual return value is only accessible in list context (my ($t, @res) = with_cancel ...). This means you cannot use the return value of with_cancel to cancel the block from the outside—it only returns once the block is already done. To cancel from the outside, pass the inner $t to a concurrent fiber.

SEE ALSO

Acme::Parataxis, Acme::Parataxis::Error, Acme::Parataxis::Nursery, Acme::Parataxis::Signal

AUTHOR

Sanko Robinson https://github.com/sanko

LICENSE

Copyright (C) Sanko Robinson.

This library is free software; you can redistribute it and/or modify it under the terms found in the Artistic License 2.