
# AbortControllerの使い方

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

## fetchのキャンセル

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

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

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

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

## タイムアウト

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

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

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

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

```js
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
```

## レイヤー間での伝播

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

```js
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)`は、ページのループが実行され続けてしまうリクエスト間の隙間をカバーします。

## 中止イベントの監視

```js
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トランスポート](https://phasis.inline0.dev/ja/docs/interop/fetch-transport) — カスタムトランスポート用のリクエスト、レスポンス、シグナルの全インターフェース。
- [Web API（WPT）](https://phasis.inline0.dev/ja/docs/compatibility/web-apis) — AbortController/AbortSignalのWPT合格率。
