非同期処理のタイムアウト時の処理をPromiseで書く方法の考察

# 背景

JavaScriptで非同期処理をコールバック関数としてセットしたとき、そのコールバック関数がいつまでたっても呼ばれないケースをケアするために、タイムアウト処理を書きたいことがある。

例:

* HTTPのリクエストを送信したがレスポンスがいつまでたっても返ってこない
* WebSocketで返信を期待して送信したが返信が返ってこない (ping を送ったが pong が返ってこない, etc.)

JavaScriptでは非同期処理をうまく扱うためにPromiseが言語組み込みでサポートされている。
したがって、以下のようなPromiseを作るのが言語の作法としても良さそうだし `await` キーワードとも相性がよいだろう。

* 成功したら `fulfilled` になる
* 失敗したら `rejected` になる（失敗の原因はタイムアウトに限らないが `reason` でわかる）

# やりたいこと

今回考えるシナリオの概要は
「Node.js上で、処理を移譲するため子プロセスを起動する。子プロセスの準備ができるまで待ち、準備ができたら実際に通信する」
というものだ。
具体的には以下のとおり。

1. Node.jsの親プロセス (Main) が、子プロセス (Sub) を起動する
2. Main は、Sub から `Ready` の通知を受け取るまで待つ
3. Sub は、準備が整ったら Main に `Ready` の通知を送る
4. Main は、一定時間内に Sub から `Ready` の通知を受け取らなかったら、 Sub の起動に失敗したとみなす (タイムアウト)

![シーケンス図](http://www.plantuml.com/plantuml/png/TOzBJWGn34JtFaLTeFi08nhDbWKhJc0o7h2KJnGxeBazwUw8tMnI5PVqNBwQS_EU8jMX6hHoThopPWfDs0KV_PFsFvRNtUE26R21OMDIMmbYHXmTkM2ajySmhdSDx5eoVjM_1y5A4hWc-GSUUi6N_WXwXMQNjamUGacfvyaWYIQm7lRetkE9U5bcfsFT0-fGEniGRHlUdWDnN6xODbhNbIJVw3gUdk8V)

子プロセスのラッパーとなるクラスは以下のようなものを想定することにする。

```typescript
interface ISomeService {
  start(): void;
  stop(): void;
  onReady(callback: () => void): void;
}
```

`onReady` にコールバックを仕掛けておいて `start` で子プロセスを起動する。
子プロセスの準備ができたら `onReady` に仕掛けておいたコールバックを発火させる。
このインターフェースだけでは `start` と `stop` しかできずありがたみが無いが、実際は他にも様々な機能がメソッドとして公開されている情景を想像して欲しい。
今時RPC的な呼び出しは珍しいかもしれないが、ありうるシナリオではあると考えている。

## トライ1: Promise.race を使う

「Promise タイムアウト」などで検索すると、 `Promice.race` を利用する例が多くヒットする。
このアイデアで書いてみる。

```javascript:startService1.js
const startService = (timeoutSec) => {
  const service = new SomeService();
  const successPromise = new Promise((resolve, _) => {
    service.onReady(() => resolve(service));
    service.start();
  });
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(`Proc did not launch within ${timeoutSec} seconds.`), timeoutSec * 1000);
  });
  return Promise.race([successPromise, timeoutPromise]);
};
```

成功時の Promise とタイムアウト時の Promise を競わせるわけだ。これはよいアイデアだ。
この `startService()` を使う時は、以下のようにする。

```javascript
startService(5)
.then((service) => {
  console.log('Proc launched successfully.');
  service.xxxxx();
}, () => {
  console.log('Attempt to launch proc was timed out.');
});
```

ダミーのサービスを作って動作確認してみた。
Pythonで数秒sleepしてファイルをtouchするだけのサービスと、ファイルが touch されたら `onReady` コールバックを実行する JavaScript のラッパークラスがこちら。
https://gist.github.com/kosuke-suzuki/95f951901e71c65e232d5993caa4853e

### 欠点

タイムアウトしたときにもサービスが起動したままになってしまう。

タイムアウトしたとき、返す Promise 自体は rejected の状態になっているので、呼び出している側で `then` の第二引数に指定した関数が実行される。
しかし、 `startService` 内で `successPromise` として定義した処理自体は止まらない。
サービスの起動がタイムアウトしたとき、サービスプロセスは存在するが `Ready` 状態ではないということになる。このような中途半端なプロセスは殺してしまいたい。タイムアウトとして扱うにも、これを殺したうえで `then` の第二引数の関数を実行したいのだ。

## トライ2: Promise.race + タイムアウト時の処理

シーケンス図更新。 kill を追加した。
![シーケンス図2](http://www.plantuml.com/plantuml/png/TL11JWCn3Bpx5VE1tGTKBEgL0oTUO58NB19djNP0_JtPRSG1gPVCn3CQZtpsu2rQoRHsrAGhM-2LrIXjmY5uQ--q_p4zxnmd384T3E-JTQj9t8bpm2gwUhbr4dhLnA7LyAFngGQlHH1Qv2_mm1C-z5lGLgY5RDF8GwgbD1iP_pfyQSuasGNUqkw-jdotF4t3SZIuGGFgzr94ouANnpjm7CEnB3JFCubTwDoVLl8l)

`catch` をチェーンしてその中で中途半端なプロセスを殺す処理を書く。`Promise.race` が rejected となったときだけこの処理が実行される。

```javascript:startService2.js
const startService = (timeoutSec) => {
  const service = new SomeService();
  const successPromise = new Promise((resolve, _) => {
    service.onReady(() => resolve(service));
    service.start();
  });
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(`Proc did not launch within ${timeoutSec} seconds.`), timeoutSec * 1000);
  });
  return Promise.race([successPromise, timeoutPromise])
    .catch((reason) => {
      service.stop();
      return Promise.reject(reason); // これがないと呼び出し元に返す Promise が fulfilled になってしまう
    });
};
```

注意点としては、エラーを呼び出し元に伝播させるためには、明示的に rejected な Promise を返す必要があること。
これをやらないと、呼び出し元は fulfilled として扱ってしまう。
これで、動作としては期待通りとなる。

### 欠点

`Promise.reject` を明示的に呼び出さないといけないのはまどろっこしく感じる。
そもそも元の Promise コンストラクタに渡す executor の仮引数 `resolve, reject` も、それぞれ片方しか活かせていない。

## トライ3: Promiseコンストラクタに渡す executor の中で行う

すべてひとつの Promise でやってしまおう、という案である。
タイムアウト時にサービスを止める処理は `setTimeout` の中で行う。
タイムアウト処理をセットする、というこのようなユースケースでは `setTimeout` という名前はピッタリに思えますね。

```javascript:startService3.js
const startService = (timeoutSec) => {
  const service = new SomeService();
  return new Promise((resolve, reject) => {
    service.onReady(() => {
      clearTimeout(onTimedout); // 成功時は監視を止める必要あり
      resolve(service);
    });
    service.start();
    const onTimedout = setTimeout(() => {
      service.stop();
      reject(`Proc did not launch within ${timeoutSec} seconds.`);
    }, timeoutSec * 1000);
  });
};
```

これで Promise としてはひとつに集約される。
今度の注意点としては、サービスが正常に起動できた場合、タイムアウト監視を止めなければならない点である。これをしないと、サービスが起動して呼び出し元に返す Promise も fulfilled になっているにもかかわらず、タイムアウト監視によってサービスが止められてしまう。

### 欠点

タイムアウト監視を止めるコードを書き忘れると、成功時もサービスが止まる。
成功時に失敗時のケアをしないといけないのはイマイチな感じがする。

## まとめ

```javascript

function(timeoutSec) {
  const successPromise = new Promise((resolve, _) => {
    // 成功時の処理のなかで resolve()
    // 処理開始
  });
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(`Timeout: ${timeoutSec} seconds.`), timeoutSec * 1000);
  });
  return Promise.race([successPromise, timeoutPromise])
    .catch((reason) => {
      // Timeoutの後処理
      return Promise.reject(reason);
    });
}
```

もしくは

```javascript
function(timeoutSec) => {
  return new Promise((resolve, reject) => {
    // 成功時の処理のなかで clearTimeout(onTimedout); resolve();
    // 処理開始
    const onTimedout = setTimeout(() => {
      // Timeoutの後処理
      reject(`Timeout: ${timeoutSec} seconds.`);
    }, timeoutSec * 1000);
  });
};
```

# Web API では

## XMLHttpRequest

`XMLHttpRequest` は `timeout` プロパティ、 `ontimeout` イベントハンドラを指定できる。
https://developer.mozilla.org/docs/Web/API/XMLHttpRequest

したがってこのような感じになるだろう（動作未確認）。

```javascript
const request = (timeout) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.timeout = timeout * 1000;
    xhr.ontimeout = () => reject(`Request was not responded within ${timeout} sec.`);
    xhr.onload = () => resolve(xhr.response);
    xhr.send();
  });
};
```

## fetch

Fetch API は、組み込みで Promise を返してくれる使い勝手のよいAPIなのだが、なんとタイムアウトをサポートしていない！こんなことってありますか？
https://developer.mozilla.org/docs/Web/API/Fetch_API

見ると、 AbortSignal という実験的なAPIを使えばタイムアウトも書けるようだ。
https://developer.mozilla.org/docs/Web/API/AbortSignal
こんな感じだろうか（動作未確認）。

```javascript
const fetchWithin = (url, timeoutSec) => {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), timeoutSec * 1000);
  return fetch(url, { signal: controller.signal });
};
```