コンテンツへスキップ

AbortControllerの使い方

AbortControllerとAbortSignalはPhasisにおけるWHATWGの完全実装です。標準の合成ヘルパー(AbortSignal.any、AbortSignal.timeout)も利用できます。このページでは、よく使うパターンと、PHP境界を越えてキャンセルが伝播する仕組みを説明します。

fetchのキャンセル

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

デフォルトでは理由はAbortErrorですが、独自の理由を指定できます。

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

fetchは渡した値を理由として拒否されます。

タイムアウト

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

AbortSignal.timeout(ms)はms後に自身を中止するシグナルを返します。タイマーは、setTimeout / queueMicrotaskを動かすものと同じエンジンのイベントループで実行されます(API: イベントループを参照)。fetchトランスポートは転送中にシグナルを確認します。デフォルトのCurlTransportではCURLOPT_PROGRESSFUNCTIONを通じて確認するため、期限に達するとすぐにリクエストが中止されます。

デフォルトではループは仮想時間で動作します。PHPだけ待つのではなく、次の保留中の期限まで時計を進めます。そのため、fetch(url, { signal: AbortSignal.timeout(5000) })は期限前に完了するか、次のループの反復で中止され、PHPを待たせることはありません。実時間に合わせるには、ホスト側で実時間に基づいてtickEventLoop()を進める必要があります。

競合:最初のキャンセルが優先

JavaScript
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

レイヤー間での伝播

シグナルを確認するループで、独自の非同期処理をラップします。

JavaScript
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;
}

fetchはシグナルを直接引き継ぎます。手動のif (signal.aborted)は、ページのループが実行され続けてしまうリクエスト間の隙間をカバーします。

中止イベントの監視

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

リスナーが実行される前にsignal.reasonが設定されます。

カスタムfetchトランスポートでのシグナル

setFetchTransport()でトランスポートを差し替えると、シグナルはJsObjectとして呼び出し可能オブジェクトに渡されます。次のように定期的に確認して反映してください。

PHP
$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',
        );
    }
    // ...
});

kind: 'aborted'を指定してTransportExceptionをスローすると、JS側のfetchはAbortError DOMExceptionで拒否されます。仕様どおりの中止ループが実行された場合と同じです。

ネイティブのキャンセルに対応するバックエンド(Guzzleのon_progress、SymfonyのResponseInterface::cancel)でも、同じように定期確認して例外をスローしてください。

現時点では使えないパターン

  • signal.addEventListener('abort', () => fetch(cleanupUrl)) — リスナーは同期的に実行されます。後処理用のfetchは開始しますが、中止イベントの処理中には完了しません。周囲のawait経由でスケジュールしてください。

setTimeout(() => ctrl.abort(), N)は動作します(タイマー機能があり、WPTのテスト対象です)が、中止期限を指定するだけならAbortSignal.timeout(N)のほうが簡潔で意図も明確です。

関連項目

  • Fetchトランスポート — カスタムトランスポート用のリクエスト、レスポンス、シグナルの全インターフェース。
  • Web API(WPT) — AbortController/AbortSignalのWPT合格率。