Phasis
Interop

AbortController patterns

AbortController and AbortSignal are full WHATWG implementations in Phasis. The standard composition helpers (AbortSignal.any, AbortSignal.timeout) ship too. This page covers the patterns you actually reach for and how cancellation propagates across the PHP boundary.

Cancelling a fetch

const ctrl = new AbortController();
const p = fetch(url, { signal: ctrl.signal });
ctrl.abort();                     // p rejects with AbortError DOMException

The reason is AbortError by default, but you can supply your own:

ctrl.abort(new Error('user cancelled'));

fetch rejects with whatever you passed.

Timeout

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

AbortSignal.timeout(ms) returns a signal that aborts itself after ms. The timer fires through the engine's event loop, the same one that drives setTimeout / queueMicrotask (see API: Event loop). The fetch transport polls the signal during transfer — for the default CurlTransport, via CURLOPT_PROGRESSFUNCTION — so the request is aborted promptly once the deadline lands.

The loop is virtual-time by default: it advances its clock to the next pending deadline rather than sleeping PHP, so a fetch(url, { signal: AbortSignal.timeout(5000) }) either completes before the deadline or aborts on the next loop tick — it never makes PHP wait. Real-time pacing needs the host to drive tickEventLoop() against a wall clock.

Race: first cancellation wins

const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(10_000)]);
const res = await fetch(url, { signal });
// res rejects if EITHER the user clicks cancel OR 10s elapses

Propagation through layers

Wrap your own async work with a signal-aware loop:

async function fetchPaged(url, signal) {
  const out = [];
  for (let page = 1; ; page++) {
    if (signal.aborted) throw signal.reason;
    const r = await fetch(`${url}?page=${page}`, { signal });
    const data = await r.json();
    if (data.length === 0) break;
    out.push(...data);
  }
  return out;
}

The fetch inherits the signal directly; the manual if (signal.aborted) covers gaps between requests where the page-loop would otherwise keep running.

Listening for the abort event

ctrl.signal.addEventListener('abort', () => {
  releaseDatabaseLock();
  cancelDownstreamWork();
});

signal.reason is set before listeners fire.

Signal in a custom fetch transport

If you swap the transport via setFetchTransport(), the signal is passed to your callable as a JsObject. Honor it by polling:

$engine->setFetchTransport(function (array $req, $signal) {
    $ch = curl_init($req['url']);
    // ...
    if ($signal !== null) {
        curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
            fn () => $signal->get('aborted')->value ? 1 : 0);
        curl_setopt($ch, CURLOPT_NOPROGRESS, false);
    }
    $body = curl_exec($ch);
    if (curl_errno($ch) === CURLE_ABORTED_BY_CALLBACK) {
        throw new \Phasis\BuiltIn\Fetch\TransportException(
            'aborted', 'aborted',
        );
    }
    // ...
});

Throwing TransportException with kind: 'aborted' makes the JS-side fetch reject with AbortError DOMException — same as if the spec abort-loop had run.

For backends with native cancellation (Guzzle's on_progress, Symfony's ResponseInterface::cancel), wire the same poll-and-throw shape.

Patterns that won't work yet

  • signal.addEventListener('abort', () => fetch(cleanupUrl)) — listener firing is synchronous; the cleanup fetch starts but won't complete inside the abort dispatch. Schedule it via the surrounding await.

setTimeout(() => ctrl.abort(), N) works (the timer family ships and is WPT-covered), but AbortSignal.timeout(N) is shorter and conveys intent better when all you want is an abort-after-deadline signal.

See also

  • Fetch transport — full request/response/signal interface for custom transports.
  • Web APIs (WPT) — AbortController/AbortSignal WPT pass rate.

On this page