AbortControllerの使い方
AbortControllerとAbortSignalはPhasisにおけるWHATWGの完全実装です。標準の合成ヘルパー(AbortSignal.any、AbortSignal.timeout)も利用できます。このページでは、よく使うパターンと、PHP境界を越えてキャンセルが伝播する仕組みを説明します。
fetchのキャンセル
const ctrl = new AbortController();
const p = fetch(url, { signal: ctrl.signal });
ctrl.abort(); // p rejects with AbortError DOMException
デフォルトでは理由はAbortErrorですが、独自の理由を指定できます。
ctrl.abort(new Error('user cancelled'));
fetchは渡した値を理由として拒否されます。
タイムアウト
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()を進める必要があります。
競合:最初のキャンセルが優先
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
レイヤー間での伝播
シグナルを確認するループで、独自の非同期処理をラップします。
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)は、ページのループが実行され続けてしまうリクエスト間の隙間をカバーします。
中止イベントの監視
ctrl.signal.addEventListener('abort', () => {
releaseDatabaseLock();
cancelDownstreamWork();
});
リスナーが実行される前にsignal.reasonが設定されます。
カスタムfetchトランスポートでのシグナル
setFetchTransport()でトランスポートを差し替えると、シグナルはJsObjectとして呼び出し可能オブジェクトに渡されます。次のように定期的に確認して反映してください。
$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合格率。