quiche で、CUBIC の cwnd(congestion window)が最小値のまま戻らなくなるバグを発見Cloudflare のブログ記事では、quiche という QUIC 実装に潜んでいた、かなりイヤなバグが紹介されています。
結論から言うと、CUBIC という congestion controller が、ある条件下で cwnd を最小値に固定したまま二度と増やせなくなることがあった、という話です。

ここでいう cwnd は、ざっくり言うと「一度に飛ばしてよいデータ量の上限」です。
この値が大きいほど通信は速くなり、逆に小さいと慎重になります。
CUBIC は「ネットワークが混んでいなそうなら増やす、混んでいそうなら減らす」という、いわば車のアクセルとブレーキを自動で踏み分ける仕組みです。
で、このバグのやっかいなところは、**“回復しない” こと自体が症状**になっていた点です。
パケットロスで一度しぼんだ cwnd が、ロスが止まったあとも戻らず、ずっと最低値に貼りついたままになる。これは通信制御としてかなり致命的だと思います。
正直、こういう「落ちる」より「戻らない」バグのほうが怖いです。原因を見つけにくいし、性能劣化としてじわじわ効くからです。
最初の兆候は、Cloudflare の ingress proxy の統合テストでした。
条件はこんな感じです。

quiche の HTTP/3 client/server普通なら、最初に少し痛めつけられても、loss が止まれば CUBIC が再び cwnd を増やして、4〜5 秒くらいで終わるはずです。
でも実際には、100 回試して約 60% が timeout したそうです。
これはかなり面白いというか、嫌な感じの失敗です。
毎回落ちるならまだ再現は楽なのですが、6 割しか落ちないのは「たぶんバグはある、でも気まぐれ」という厄介さがあります。
テストを作る側からすると、こういう不安定な失敗は本当に胃に悪いです。
調査のために、qlog の packet loss イベントを可視化したところ、奇妙なことが見えました。
ここで bytes_in_flight は、「まだ ACK を受け取っていない送信中のデータ量」です。
これが 0 に戻ると、送信側は「次を送っていい」と判断しやすくなります。
ところが今回は、ACK が来るたびに状態が切り替わってしまい、CUBIC がまともに前進できない。
しかも 14ms という周期は、RTT 10ms にかなり近い。つまり、ACK のリズムに合わせて不具合が律儀に発火しているわけです。こういう「通信の心臓拍動に同期して壊れる」感じ、かなり技術的には美しくないけど、バグとしては妙に芸術点が高いと思います。
Cloudflare はこれが CUBIC 特有かどうかも確認し、別の loss-based アルゴリズムである Reno では問題なく回復することを示しました。
これで「ネットワーク環境の問題」ではなく、CUBIC の実装まわりの問題だと絞れたわけです。
話の核心は、2017 年の Linux kernel の CUBIC 修正にあります。
元の問題はシンプルです。

now - epoch_start が大きくなりすぎるepoch は、CUBIC が「いつからこの成長カーブを描いているか」を覚える基準点です。
これが古いままだと、数式上の成長が暴れてしまう。
要するに「昼寝してる間に時計が進みすぎて、起きた瞬間にテンションが上がりすぎる」みたいな話です。ちょっと可愛いですが、ネットワークでは笑えません。
最初の修正案は epoch_start をアプリ再開時にリセットすることでした。
でもそれだと、CUBIC の成長曲線の形が変わってしまう。
そこで採られたのが、epoch をリセットするのではなく、idle だった時間だけ未来にずらす方法でした。
これは確かにきれいです。曲線の形はそのまま、時間軸だけスライドさせる。数学的にも実装的にもスマートだと思います。

quiche でもこの idle 対応が移植されました。
ただし、TCP の kernel 実装と QUIC では前提が少し違います。
quiche の実装では、on_packet_sent() の中で、
bytes_in_flight == 0 ならlast_sent_time との差分を使ってcongestion_recovery_start_time を進めるという処理をしています。
問題はここです。
Linux kernel の CUBIC は ACK 処理のタイミングで状態を更新するのに対し、quiche は 送信タイミングで idle を判定している。
このタイミングの違いが、あとで大きく効いてきました。
記事で紹介されている follow-up の kernel 修正では、こんなことが書かれています。
つまり、送信時刻を基準に補正した結果、回復開始時刻が未来に押し出されることがありえたわけです。
そうなると CUBIC は「まだ回復途中だ」と誤認し続け、recovery と congestion avoidance を往復してしまう。
今回の「999 回の状態遷移」は、まさにその副作用でした。
ここも地味に重要です。
このバグは、接続開始時には起きません。なぜなら、最初は slow start 中で、問題の分岐に入らないからです。
問題が出るのは、いったん loss を食らって cwnd が削られ、その後 slow start を抜けて congestion avoidance に入る局面です。
そのタイミングで congestion_recovery_start_time が効いてくるのですが、送信ベースの補正がズレると、まさに今回のようなループに入ります。
このへん、CUBIC の「状態機械」がかなり繊細だとわかります。
通信アルゴリズムって外から見ると単純に見えますが、実際には「今どの状態か」「何を基準に時刻を見るか」で挙動が全然変わる。
ちょっとした実装の違いが、性能問題ではなく**“回復不能”**というレベルのバグに化けるのが面白くもあり、怖くもあります。
最終的な修正は、かなり洗練されたものでした。
要するに、epoch_start や recovery start time を未来に飛ばさないようにする、というものです。
これは地味ですが、かなり本質的です。
“idle 分を加算する” という発想自体は正しい。
でも、その加算の結果として「時刻が未来になってしまう」なら、それはやりすぎ。
だから、境界を超えないようにして、CUBIC がちゃんと次の ACK で前進できるようにする。
こういう修正は派手さはないですが、実運用ではとても価値が高いです。
むしろ私は、こういう「理屈はシンプル、でも効き目が絶大」な修正が一番好きです。
個人的に、このバグの面白さは 3 つあります。
原因が“最適化”だったこと
バグは単純なミスというより、正しい問題を直そうとした結果として生まれた。ここがいかにもシステムソフトウェアらしいです。
TCP と QUIC の前提差が効いたこと
kernel ではうまくいった考え方が、user space の QUIC にそのままでは刺さらなかった。移植ってやっぱり難しいです。
症状がかなり観察しにくいこと
cwnd が増えない、状態が往復する、でもロスは止まっている。
こういうバグは、グラフを見て初めて「何か変だ」と気づくタイプです。ログだけでは気づきにくいはずです。
この記事は、単なる「バグを直しました」ではなく、通信制御のタイミング設計がどれだけ繊細かを教えてくれる話でした。
CUBIC はとても広く使われているアルゴリズムですが、その実装は「いつ ACK を見たか」「いつ送信したか」「idle をどう扱うか」で微妙に変わります。
その微妙さが、プロダクションで 60% のテスト失敗を生む。いやあ、システムは本当に油断ならないです。
個人的には、こういう話は「地味だけど超重要」なエンジニアリングの醍醐味が詰まっていて好きです。
派手な新機能より、こうした“見えないところのズレ”を丁寧に潰していく作業こそ、インターネットをちゃんと動かしているんだと思います。
参考: When "idle" isn't idle: how a Linux kernel optimization became a QUIC bug