# 「アイドルじゃないのにアイドル？」quiche の CUBIC を蝕んだデススパイラルの正体！

やっほー、しぃちゃんだよ！ 今日はネットワークのいちばん奥、輻輳制御のバグ退治のおはなし。ゾクゾクするくらいカッコいい調査だから、いっしょに潜っていこう！

## なにが発表されたの？

Cloudflare の Blog で、[quiche](https://github.com/cloudflare/quiche)(Cloudflare がオープンソースで開発している QUIC / HTTP3 の実装)に潜んでいた輻輳制御バグの調査記録が公開されたよ。タイトルは「アイドルじゃないのにアイドル」。ひどいパケットロスのあと、CUBIC の輻輳ウィンドウ(cwnd)が最小値にピタッと張り付いて、二度と回復しなくなる…そんな「デススパイラル」を追いかけた話なの。

きっかけは、社内の ingress プロキシの結合テストが 61% の確率で失敗するという報告。10 秒のタイムアウトがあるのに、約 6 割のテストがダウンロードを終えられなかったんだって。

## 背景：CUBIC と「アイドル調整」のはなし

[CUBIC](https://www.rfc-editor.org/rfc/rfc9438.html) は Linux のデフォルトの輻輳制御アルゴリズムで、いまや公共インターネット上のほとんどの TCP と QUIC の通信を取り仕切っている存在なの。

物語のはじまりは 2017 年の Linux カーネル。アプリがデータを送り終えてしばらく黙り込む(アイドルになる)と、CUBIC の epoch(成長カーブの起点)が更新されないまま時間だけが過ぎて、再開したとたんに `now - epoch_start` がとんでもなく大きくなっちゃう。すると輻輳ウィンドウが一気に膨らんで、実態と合わない値になっちゃうの。

これを直したのが Eric Dumazet さんたち。epoch をリセットするんじゃなくて、アイドルだった時間ぶんだけ epoch を後ろへずらす、というエレガントな[修正](https://github.com/torvalds/linux/commit/30927520dbae297182990bb21d08762bcc35ce1d)だよ。カーブの形はそのままに、時間軸だけスライドさせる作戦。カーネルでは `CA_EVENT_TX_START` コールバックで「本当のアイドル」と「一瞬の詰まり」をちゃんと見分けられていたのがポイントなの。

## これで何が変わるの？

修正が入ったことで、ひどいパケットロスのあとでも CUBIC がちゃんと回復して、輻輳ウィンドウが本来の CUBIC カーブに沿って伸びるようになったよ。テストは 100% 成功、ダウンロードも数秒で完了。quiche を使っているサービス(Cloudflare のエッジもそう！)は、モバイル回線みたいにロスの多い環境で「なぜかスループットが伸びない」現象から救われるってわけ。

## 深く潜ってみよう

CUBIC が [quiche に移植されたのは 2020 年](https://blog.cloudflare.com/cubic-and-hystart-support-in-quiche/)。このアイドル調整も一緒に運ばれてきたんだけど、QUIC はカーネルじゃなくてユーザー空間で動くから、`CA_EVENT_TX_START` みたいなコールバックがない。そこで quiche は、パケットを送る `on_packet_sent()` の中で「`bytes_in_flight == 0` ならアイドルだったな」と判定していたの。

```
if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;   // ここが落とし穴
    self.congestion_recovery_start_time += delta;
}
```

ここが罠だった。cwnd がロスで最小値(2 パケット、2700 バイト)まで落ちると、こんなループにハマるの。

- 手持ちの 2 パケットを送る
- 約 1 RTT(≒ 14 ms)後に両方 ACK が返って、bytes_in_flight が 0 になる
- 次のバーストを送るとき `on_packet_sent()` が「bytes_in_flight == 0 だ！」と判定
- `delta = now - last_sent_time` を計算 → でも last_sent_time は前の RTT の**先頭**の時刻。だから delta は本当のアイドル時間(ほぼ 0)じゃなく、まるまる 1 RTT(≒ 14 ms)になっちゃう
- 膨らんだ delta で congestion_recovery_start_time が未来へ進む
- 回復開始時刻が未来にあるせいで `in_congestion_recovery()` が true を返し続け、CUBIC は「まだ回復中」と勘違いして成長をスキップ
- cwnd は 2 パケットのまま。次の ACK でパイプが空っぽになって、また最初へ…

こうして cwnd が最小値に張り付いたまま、回復と輻輳回避を ~14 ms ごとに 999 回も行き来する「デススパイラル」が完成しちゃうんだね。しかもこの罠が発動するには、(1) 実際のロスで回復境界がセットされている、(2) 輻輳回避モードにいる、(3) cwnd が 2 パケットの最小値、の 3 つが同時にそろう必要があった。だから Reno や、ウィンドウが大きい接続では表に出なかったの。

面白いのが、Linux カーネルチームも最初の修正の約 1 週間後に自分たちで同じ弱点に気づいて、[追いフォローの修正](https://github.com/torvalds/linux/commit/c2e7204d180f8efc80f27959ca9cf16fa17f67db)を入れていたこと。送信イベントを基準にアイドルを測ると epoch_start が未来へ飛んでしまう、というまったく同じ落とし穴だったんだよ。

修正はシンプル。ACK が届いた時刻 `last_ack_time` を覚えておいて、アイドルの起点を「最後に送った時刻」じゃなく「bytes_in_flight が本当に 0 になった時刻」から測るようにしたの。

```
if bytes_in_flight == 0 {
    let idle_start = max(last_ack_time, last_sent_time);
    let delta = now - idle_start;
    congestion_recovery_start_time += delta;
}
```

`max(last_ack_time, last_sent_time)` を使うと、最小ウィンドウのときは delta がほぼ 0 になって回復境界が未来へ飛ばない。逆に本当に長くアイドルだった接続では last_ack_time が遠い過去のままだから、Dumazet さんたちの「epoch を後ろへずらす」振る舞いもちゃんと残る。両立できてるのが気持ちいいね！

## まとめ

- quiche の CUBIC が、ひどいパケットロスのあと輻輳ウィンドウを最小値(2 パケット / 2700 バイト)に張り付かせ、~14 ms ごとに 999 回も状態遷移する「デススパイラル」に陥っていた
- 原因は、ユーザー空間のアイドル判定で `now - last_sent_time` を使い、本当のアイドル時間ではなく丸ごと 1 RTT を誤って足していたこと
- その結果、回復開始時刻が未来へずれて CUBIC が「まだ回復中」と勘違いし、成長をスキップし続けていた
- 修正は `last_ack_time` を導入し、bytes_in_flight が 0 になった実際の時刻から測るだけ。テストは 100% 成功に
- QUIC と輻輳制御の中身を追いかけたい低レイヤー好き、そして「なぜかロス環境でスループットが伸びない」に悩むネットワークエンジニアに刺さる一本だよ！
