Skip to content

Cancellation cannot reach a coroutine that never suspends #285

Description

@volodymyr-aloware

Summary

Coroutine::cancel() is honoured only at a suspension point. A coroutine that runs PHP code
without awaiting anything ignores it completely, and there is currently no way for a host to
bound such a coroutine.

The engine's interrupt machinery does reach it — a pcntl signal handler runs inside the same
loop. Cancellation just is not wired to that path.

Asking for an opt-in forceful cancellation that rides the existing VM interrupt, plus a way to
observe a cancellation that has gone unanswered.

Environment: trueasync/php-true-async:0.9.12-php8.6, PHP 8.6.0-dev ZTS, TrueAsync ABI v0.26.0,
php --ri true_async reports 0.9.8. Reproduced identically on 0.9.6-php8.6 (ABI v0.24.0,
ext 0.9.3), so this is long-standing rather than a regression.

Reproducer

<?php
$t0 = microtime(true);
$el = fn () => sprintf('%5.2fs', microtime(true) - $t0);

$c = Async\spawn(function () {
    $until = microtime(true) + 6.0;
    $n = 0;
    while (microtime(true) < $until) { $n++; }   // no suspension point
    return $n;
});

pcntl_async_signals(true);
pcntl_signal(SIGALRM, function () use ($c, $el) {
    echo "[{$el()}] cancel() called from inside the loop\n";
    $c->cancel();
    echo "[{$el()}]   isCancellationRequested=" . var_export($c->isCancellationRequested(), true)
        . " isCancelled=" . var_export($c->isCancelled(), true) . "\n";
});
pcntl_alarm(2);

try { Async\await($c, Async\timeout(10000)); }
catch (Throwable $e) { echo "[{$el()}] await threw " . get_class($e) . "\n"; }

Output:

[ 2.00s] cancel() called from inside the loop
[ 2.00s]   isCancellationRequested=true isCancelled=false
[ 6.00s] await threw Async\AsyncCancellation

The coroutine was cancelled at 2.00s and ran for another four seconds — 281M iterations —
before the cancellation materialised at its natural end.

Note the second line. The signal handler executed inside the non-suspending loop, so the
engine's interrupt path already reaches this coroutine. Only cancellation does not use it.

What is being asked

1. Opt-in forceful cancellation

Something along the lines of $coroutine->cancel($cancellation, forceful: true), delivered at
the next VM interrupt rather than the next suspension point, unwinding normally so finally
blocks and destructors still run.

Opt-in matters: unwinding a coroutine at an arbitrary opcode boundary is a stronger claim than
cooperative cancellation and should not become the default meaning of cancel().

If the semantics are contentious, a narrower version would still solve the practical problem:
a scope- or host-level setting that turns an unanswered cancellation into a forceful one after a
grace period.

2. Observability of an unanswered cancellation

Strictly smaller, and useful even if #1 is rejected. isCancellationRequested() already
distinguishes requested from landed; what is missing is how long it has been unanswered.

Something like getCancellationRequestedAt(): ?float, or exposing the elapsed time directly.

With that, a host running many coroutines in one process can report which one is unresponsive,
and for how long, before falling back to a process-level action. Today it can observe that the
process as a whole is stuck but cannot attribute it. getSuspendLocation() and
getAwaitingInfo() already cover the suspended case; this is the gap for the running one.

Explicitly not being asked

Interrupting a blocking C call. That boundary is real and I do not think the extension can or
should cross it:

$c = Async\spawn(function () {
    password_hash('x', PASSWORD_BCRYPT, ['cost' => 17]);   // ~6s inside one C function
});
pcntl_signal(SIGALRM, fn () => $c->cancel());
pcntl_alarm(2);
[ 0.00s] entering password_hash (blocking C, cost 17)
[ 5.97s] SIGALRM handler RAN -> cancel()
[ 5.97s] password_hash returned on its own

The alarm was armed for 2s and the handler ran at 5.97s, because there is no VM interrupt inside
a C function. Nothing in userspace can preempt this, and a forceful cancel would not change it.

By contrast the reactor-aware surface behaves well — sleep(6) cancelled in 1.00s. The
difference between those two is invisible to an application author, so it would help to document
which blocking calls the reactor hooks.

Why this matters

Hosting N units of work as N coroutines in one process removes the per-unit deadline that
process-per-unit gives for free. Laravel's queue worker, for example, enforces a job timeout with
pcntl_alarm and then posix_kill(getmypid(), SIGKILL) — safe precisely because the process
owns exactly one job and the kernel reclaims everything.

With coroutines the only enforcement left is process-level, which takes the healthy siblings with
it. Their in-flight work is redelivered, so one unresponsive unit costs N.

Forceful cancellation would make the common case (a runaway PHP loop) bounded per coroutine.
The process-level fallback would remain for the C-call case, but as a rare last resort rather
than the only mechanism.

Is this implementable in the extension alone?

I think so, and the engine side already works.

The engine already supports a clean preemptive throw

A pcntl signal handler that throws does exactly what a forceful cancel would need: it takes
control at an opcode boundary inside a non-suspending loop and unwinds normally.

$c = Async\spawn(function () {
    $tracked = new Tracked(...);          // destructor observed below
    try {
        $until = microtime(true) + 6.0;
        while (microtime(true) < $until) {}   // no suspension point
    } finally {
        echo "finally ran\n";
    }
});
pcntl_signal(SIGALRM, fn () => throw new ForcedCancel('cancelled at the interrupt'));
pcntl_alarm(2);
[ 0.00s] busy loop start
[ 2.00s] handler at an opcode boundary -> throwing
[ 2.00s] finally ran
[ 2.00s] destructor of a local object ran during the unwind
[ 2.00s] await threw ForcedCancel: cancelled at the interrupt

Interrupted at 2.00s, finally ran, the destructor ran, and the exception surfaced from
await() as the coroutine's result. So the unwinding machinery a forceful cancel needs is
present and behaves correctly — the missing piece is only that cancellation does not use it.

The place to change is already holding the exception

async_coroutine_cancel() in coroutine.c says so itself, at the branch for a coroutine that
is currently running (around L889):

// An attempt to cancel a coroutine that is currently running.
// In this case, nothing actually happens immediately;
// however, the coroutine is marked as having been cancelled,
// and the cancellation exception is stored as its result.

That branch already sets ZEND_COROUTINE_SET_CANCELLED and stores the exception object.
Everything else goes through zend_async_waker_define() and
ZEND_ASYNC_WAKER_APPLY_CANCELLATION, which is a suspension-point mechanism — a coroutine that
is not waiting has no waker to apply anything to, which is exactly the observed behaviour.

A forceful variant looks like: in that same branch, additionally request a VM interrupt, and
throw the already-stored exception from the interrupt handler. EG(vm_interrupt) and
zend_interrupt_function are ordinary php-src API — pcntl uses them, which is what the probe
above demonstrates. I could not find either symbol anywhere in the extension today
(async.c, async_API.c, php_async.h, coroutine.h, coroutine.c), so this would be new
plumbing rather than a change to existing plumbing.

What I would expect to be genuinely awkward

  1. zend_interrupt_function is a single global hook. It has to be chained: save the previous
    handler at startup and call it. pcntl, Xdebug and others use the same slot, so getting the
    chaining wrong breaks unrelated things quietly.

  2. Throwing while a PHP callback runs inside a C function. usort() comparators,
    preg_replace_callback(), stream filters, array_map() — core mostly copes with an exception
    from a callback, but third-party extensions vary, and unwinding through a C frame that was not
    written expecting it is the real hazard. This is the main argument for opt-in rather than
    changing what cancel() means.

  3. JIT'd loops. Interrupt checks exist at loop back-edges, but this is a bug-prone corner and
    the tracker already carries a tracing-JIT correctness issue (isset($obj->prop[$key]) misses under the tracing JIT when prop is unset() and served by &__get() #223).

  4. Not fixable here at all: the blocking C call. Covered above — no VM interrupt exists inside
    a C function, so no amount of extension work reaches it. The password_hash probe is the
    evidence, and I am not asking for this.

Ask 2 needs none of the above

Recording when a cancellation was requested is bookkeeping in the same branch that already sets
the flag, plus a getter. No interrupt, no unwinding, no engine involvement. It is useful on its
own even if the forceful cancel is rejected.

Aside: ThreadPool::cancel()

Possibly related, possibly intended. A worker submitted to Async\ThreadPool and cancelled while
spinning kept running: it was still writing progress two seconds after $pool->cancel().
Is cancel() meant to stop a running worker, or only to drain the queue and stop accepting work?
Worth a line in the docs either way.

Also minor: ThreadPool::submit() returns Async\Future, which has no getResult(). Not obvious
from the class surface how a submitted task's result is meant to be retrieved.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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