MimiCatLab
← ホームに戻る
Unity

タイムアウトはキャンセルではない:非同期初期化に上限を付けたら見捨てた Task がリークした

Task.WhenAny でモジュール初期化に上限を付けた。上限後に完了した Task の後始末、CancellationTokenSource の寿命、ワーカースレッドからのログ、という 3 つの落とし穴。

何に困っていたか

MimiCatHub は Discord・YouTube・Steam など外部サービスへ接続するモジュールを持つ。どれかの初期化が応答待ちのまま返ってこないと、全体の起動が止まる。後続モジュールも初期化されず、初期化完了を待つ側も永久に待つ。ネットワーク相手の処理に上限がない、という古典的な欠陥だった。

どう設計したか

上限はモジュール 1 件ごとに掛ける。既定は 0 で、0 は「上限なし」。既存プロジェクトの挙動を変えないためである。設定は McSettings の秒数と、Asset に依存しない McBuilder.SetInitializationTimeout(TimeSpan) の 2 経路を用意した。

超過を新しい概念にはしない。超過したモジュールは「初期化に失敗したモジュール」として、重要度に応じた既存の経路(必須ならロールバック、任意なら無効化して継続)へそのまま流す。実装は呼び出し元の CancellationToken と連結した CancellationTokenSource をモジュールへ渡し、次で待つ。

var completed = await Task.WhenAny(moduleTask, Task.Delay(timeout, delayCts.Token));
if (completed == moduleTask)
{
    delayCts.Cancel();
    await moduleTask;   // 例外もそのまま伝える
    return;
}
linkedCts.Cancel();
throw new McException(McErrorCode.ModuleInitializationTimedOut, ...);

モジュールの Task には「遅れて終わったら警告ログを出す」継続だけを付け、await はしない。未観測例外にしないためである。

ここまでは素直だった。壊れたのは、意図的に対象外とした「超過したモジュールの強制停止」の周辺である。

何が起きたか

上限後に完了したモジュールがリークした。 CancellationToken のキャンセルを要求しても、モジュールが無視すれば処理は裏で続く。遅れて完了したモジュールは Runtime の管理対象から外れているので、終了処理の対象にならず、接続やハンドルが解放されないまま残った。24 時間運用で再起動のたびにソケットとスレッドが積み上がった。「失敗としてログに出るから安全」ではなかった。

CancellationTokenSource を早く捨てすぎた。 タイムアウト成立の時点で連結 CTS を Dispose していた。モジュールはまだ走っているので、モジュール側の token.Register(...)ObjectDisposedException になった。走行中の Task が参照している CTS は、その Task が終わるまで捨ててはいけない。

ワーカースレッドからログを出していた。 遅延完了の警告を継続の中でそのまま出していたが、Unity では Application.isPlayingTime.frameCount にメインスレッド外から触ると例外になる。初期化時に捕捉した SynchronizationContext へ戻してから出すようにした。

どう直したか

  • 上限後に完了したモジュールも終了処理の対象に入れ、接続とハンドルを解放する。
  • 連結 CTS はモジュールの Task の完了時に破棄する。
  • 遅延完了の警告は SynchronizationContext.Post でメインスレッドへ戻す。

同時に、RestartModuleAsync 実行中の終了要求が InvalidState で拒否されて終了処理が一度も呼ばれない問題や、ロールバック時にスケジューラーの予定が解除されず発火し続ける問題も直した。この領域は「失敗経路と終了経路の交差」にバグが集中していた。

教訓

  1. タイムアウトはキャンセルではない。 上限が来ても相手の Task は走り続ける。「待つのをやめた」と「止めた」は違う。
  2. 見捨てた Task の後始末まで設計に含める。 放棄したリソースには必ず回収経路が要る。
  3. CTS の寿命は、それを渡した Task の寿命に合わせる。 タイムアウト成立=破棄可、ではない。
  4. 既存の失敗経路に合流させると設計が小さくなる。 超過を McException として投げ直しただけで、ロールバック・スキップ・診断表示がそのまま使えた。
  5. 既定値は「従来どおり」にする。 0=無効のおかげで導入版で挙動が変わらなかった。
  6. ログの出力スレッドも仕様のうち。 裏で走る継続からログを出すならメインスレッドへ戻す。

対象外のまま

リトライは Core では行わず、再接続は各パッケージの責務のままにした。Runtime 全体の合計時間の上限もない(モジュール数 × 上限だけ待ちうる)。強制停止もしない。モジュールがキャンセルを無視すれば処理は裏で続くが、終了処理の対象にはなる、というのが今の到達点である。

Related Articles