OpenAICloudflareMay 12, 2026, 1:00 PM

When "idle" isn't idle: how a Linux kernel optimization became a QUIC bug

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

openaienmodel: gpt-5-mini-2025-08-07

When "idle" isn't idle: how a Linux kernel optimization became a QUIC bug

Key Points

  • cwnd stuck at minimum after early loss
  • send-time idle-adjustment advanced epoch into the future
  • fix: don't advance epoch/recovery into the future

Summary

A port of a Linux CUBIC idle-period optimization into Cloudflare's quiche QUIC implementation caused CUBIC's congestion window (cwnd) to become permanently pinned at its minimum after an early heavy-loss phase. The bug reproduces deterministically in an early-loss then idle scenario: every ACK drove bytes_in_flight to zero, a send-time adjustment advanced the CUBIC epoch/recovery boundary into the future, and the algorithm oscillated between recovery and congestion-avoidance once per RTT instead of growing cwnd. The kernel later avoided this by not setting epoch_start into the future; quiche needed the same guard.

Key Points

  • Reproduction (practical):

    • quiche HTTP/3 client/server on localhost, RTT=10ms
    • 10 MB download, CUBIC congestion control
    • 30% random loss during first 2s, then no loss; 10s test timeout
    • Observed ~60% failures (download times out)
  • Observable symptoms:

    • cwnd pinned at minimum (≈2700 bytes, two packets) after loss
    • ~999 recovery/avoidance transitions in ~6.7s (~1 per RTT)
    • bytes_in_flight drops to 0 each ACK and sender emits a two-packet burst
    • Reno unaffected (100% pass), confirming CUBIC-specific behavior
  • Root cause (concise):

    • Ported idle-time adjustment ran at packet-send time (user-space) and advanced the CUBIC epoch / congestion_recovery_start_time into the future when bytes_in_flight == 0.
    • That future epoch made bictcp_update() compute an inflated target, which immediately triggered a recovery vs. avoidance flip each RTT, preventing cwnd growth.
    • Kernel follow-up fix: do not set epoch_start in the future; quiche needed the same protection.
  • Fix and mitigation (practical):

    • Apply the kernel-style guard: do not advance epoch/recovery-start into the future (clamp the adjustment or skip it when it would move the timestamp ahead).
    • Alternatively, perform the idle-duration shift on ACK processing rather than at send time.
    • Add a regression test: early heavy loss for first N seconds followed by no loss; assert cwnd recovers and download completes within timeout.
  • Engineering notes:

    • The bug requires three simultaneous conditions: a real loss event (recovery boundary set), being in congestion-avoidance, and cwnd collapsed to the two-packet floor.
    • Tests should exercise the "idle-after-loss" corner-case not covered by steady-state or slow-start-only suites.

Recommended action items

  • Patch quiche to clamp/skip send-time epoch advancement (one-line guard), or move adjustment to ACK handling.
  • Add an automated regression reproducing the exact scenario described.
  • Backport or audit similar user-space CUBIC ports for the same pattern.

Full Translation

Translations

A translation section that keeps the flow of the original article.

openaijamodel: gpt-5-mini-2025-08-07

「idle」がアイドルでないとき:Linuxカーネルの最適化がQUICのバグになった経緯

「idle」がアイドルでないとき:Linuxカーネルの最適化がQUICのバグになった経緯

2026-05-12 — Esteban Carisimo, Antonio Vicente — 読了目安 10分

CUBIC(RFC 9438で標準化済み)はLinuxでのデフォルトの輻輳制御器(congestion controller)であり、そのためパブリックインターネット上の多くのTCPおよびQUIC接続が利用可能な帯域をどのように探り、損失を検出したときにどのように後退(back off)し、その後どのように回復するかを決定します。Cloudflareでは、オープンソースのQUIC実装である quiche がデフォルトでCUBICを使っているため、このコードは当社が扱うトラフィックの重要な経路にあります。

本稿では、CUBICの輻輳ウィンドウ(cwnd)が最小値に永続的に固定され、輻輳崩壊から決して回復しないバグの経緯を説明します。物語は、CUBICをRFC 9438 §4.2-12に記載されたアプリ限定(app-limited)除外に合わせることを目的としたLinuxカーネルの変更から始まります。この変更はTCPの実際の問題に対する修正でしたが、quicheに移植した際に予期しない振る舞いを浮き彫りにしました。結末は幸いにも美しく、ほぼ1行の修正で循環が断たれました。

CUBICのロジックを一言で

本題に入る前に、輻輳制御アルゴリズム(CCA)の簡単な復習をします。CCAが操作する中心的なダイヤルは cwnd(輻輳ウィンドウ)で、送信側でどれだけのバイトが「飛行中」(送信済みだがまだackされていない)で良いかを制限します。cwndが大きければ送信側は1ラウンドトリップあたり多くのデータを押し出せますし、小さければ抑制されます。

損失ベースのCCA(CUBICを含む)は本質的に、ネットワークが健全に見えるときに cwnd をどう増やすか、そして健全でないときにどう縮めるかを決めるポリシーです。損失ベースのアルゴリズム群は次の前提に基づきます:

  • 損失がなければ送信レートを上げる(帯域利用率を増やす)。
  • 損失があればネットワークの容量が超過されたと見なし、送信側はバックオフする(帯域利用率を下げる)。

これらのロジックはいくつかの仮定の上に成り立っており、長年にわたり見直されてきましたが、その議論は別の機会に譲ります。

症状:61%の確率で失敗するテスト

調査は、ingress proxyの統合テストパイプラインでの予期しない失敗報告から始まりました。問題は、接続の初期に強いパケット損失があるシナリオでCUBICを評価したテストに現れていました。輻輳崩壊からの回復は稀な状態ですが、まさに輻輳制御器が対処すべき領域です。多くのテストは定常状態や増加フェーズを検証しますが、cwnd が最小になった後にどうなるかを調べるテストは少なく、この隅のバグはスループットダッシュボードでは見えず、静的レビューでも検出されず、CCAを意図的にその状態に追い込んで回復できるかを観察したときにのみ表面化します—今回のテストがまさにそれでした。

シミュレートされたテスト環境の詳細は次のとおりです:

  • Quiche HTTP/3 クライアントとサーバをローカルで実行(localhost)
  • RTT = 10ms(設定で指定)
  • HTTP/3での10 MBファイルダウンロード
  • CUBIC輻輳制御を使用
  • 接続の最初の2秒間に30%のランダムパケット損失を注入
  • 2秒経過後は損失は完全に停止

テストにはダウンロード完了までに余裕を持たせた10秒のタイムアウトがあります。ダウンロードは4〜5秒で完了する見込みです。期待される振る舞いは単純で、損失フェーズ中にCUBICが打撃を受けて cwnd を減らし、損失が止まれば徐々に再増加してタイムアウトの前にダウンロードを完了することです。

ところが、100回の実行を複数回行ったところ、約60%のテストが余裕のある10秒のタイムアウト内にダウンロードを完了できませんでした。

異常:ゼロ損失で999回の状態遷移

我々は quicheqlog をパケット損失イベントで計測し、輻輳制御器内部で何が起きているかを可視化しました。

  • T=2s(2000ms)でパケット損失は完全に停止する。
  • しかし cwnd は最小フロアに固定され続け、輻輳状態は約14msごとに recovery と congestion_avoidance を行ったり来たりする。
  • 約6.7秒間で999回の状態遷移(= 約14msごと)を記録した。
  • その間、cwnd は最小フロアの 2700 bytes(= フルサイズパケット2個分)にロックされている。

損失が無いにもかかわらずin-flightのバイト数が平坦のままであり、これはCUBICの核心的なロジックと矛盾します:損失が無ければ cwnd を増やす(より多くのバイトを飛行させる)べきです。ここでの手がかりは振動周期です:約14msはRTT(10ms)に近く、トリガーは1ラウンドトリップごとに発生しているように見えます。自己クロック化されたリズム、すなわち各ラウンドトリップでクライアントから返ってくるACKがサーバ側の次の送信を駆動する動作に同期しています。

今回のケースはダウンロード(サーバ→クライアント)なので、問題のACKはクライアント→サーバに向かい、CUBICの状態機械はサーバ側で動作します。ACKが到着するたびに bytes_in_flight がゼロになり、サーバが次の2パケットバーストを送る──これがバグをトリガーしていました。

CUBIC固有の問題であることを確認するために、同じテストを別の損失ベースのアルゴリズムである Reno でも実行しました。結果は決定的で、Renoでは100%成功し、損失フェーズ終了後に正常に回復してダウンロードを完了しました。これにより今回の不具合がCUBICに関連するものであることが明らかになりました。

原因追跡

損失ベースのアルゴリズムにはガスとブレーキの二つがあり、加速の仕方が異なります。CUBICはいくつかの余分な機能を備えていますが、本稿では bytes_in_flight == 0 に焦点を当てます。

TCP CUBICの idle 後(Linux, 2017)

このバグを理解するためには、それが由来した最適化をまず理解する必要があります。2017年にLinuxカーネルのCUBIC実装で問題が発見されました。コミットメッセージは要約すると次の懸念を述べています:

  • epoch は初期化時と損失を経験したときにのみ更新/リセットされる。
  • アプリのアイドル後には now - epoch_start(= delta t)が任意に大きくなり得る。
  • その結果、スロープ(ca->cnt の逆数)が非常に大きくなり、最終的に ca->cnt は最低値2に制限され、遅延ACKスロースタートのような振る舞いになる。
  • 特に slow_start_after_idle を無効にしている場合、数秒のアイドル後に危険な cwnd の膨張(1.5x RTT)が現れる。

ここでいう epoch はCUBICが成長曲線を固定するために使う参照時刻です。W_cubic(delta_t) は delta_t = now - epoch_start によってパラメータ化され、CUBICは成長関数を再開する際(最も顕著なのは損失で cwnd が減った後)に epoch をリセットします。リセットの間、delta_t は実時間とともに単調増加します。

アプリがしばらく送信を停止してから再開した場合、epoch がアイドル中に更新されていないと delta_t は巨大になり、W_cubic(delta_t) が極端に大きな目標ウィンドウを返してしまいます。Jana Iyengar の当初の修正案はアプリが送信を再開したときに epoch_start をリセットするものでした。しかし Neal Cardwell はそのアプローチに欠陥を指摘しました:

  • それはCUBICアルゴリズムに現在の cwnd から再び急峻に上昇し始めるように要求してしまう(損失直後と同様の挙動)。理想的には、成長曲線の形状を保ちつつ、アイドル期間だけ時間軸上でシフトさせたい。

Eric Dumazet、Yuchung Cheng、Neal Cardwell によるエレガントな解決は、epochをリセットするのではなくアイドル継続時間分だけ前方にシフトすることでした。これによりCUBICの成長曲線の形状は保持され、単に時間軸上でスライドしてアルゴリズムが中断したところから再開できます。

quicheへの移植(2020)

CUBICが quiche に最初に実装されたとき、このアイドル期間調整は移植されました。しかしQUICはユーザ空間で動作するため、TCPのカーネルレベルの CA_EVENT_TX_START コールバックを持ちません。代わりに quiche 実装は on_packet_sent() の内部でアイドル条件をチェックしています。

// cubic.rs — on_packet_sent() (simplified)
/// Updates the state when a packet is sent.
fn on_packet_sent(&mut self, bytes_in_flight: usize, now: Instant, ...) {
    // If the sending burst is restarting (i.e., bytes_in_flight was zero before this send),
    // adjust the congestion recovery start time to account for the gap in sending.
    if bytes_in_flight == 0 {
        let delta = now - self.last_sent_time;
        self.congestion_recovery_start_time += delta;
    }

    // Record the time of this send event.
    self.last_sent_time = now;
}

(上のコードは簡略化されていますが、重要な点は bytes_in_flight == 0 を検出したときに congestion_recovery_start_time を送信時間差だけ進めている点です。)

どこで壊れるか:QUICの違い

移植された修正には、元のカーネル変更にあったバグが混入しており、そのバグは1週間ほどでカーネル側の追加修正で直されました。セカンドコミットのメッセージは次のように説明しています:

  • tcp_cubicepoch_start を未来に設定しないこと
  • bictcp_cwnd_event() 内でのアイドル時間追跡は不正確であり、epoch_start は通常は送信時ではなくACK処理時に設定される。
  • もし epoch_start を未来に設定してしまうと、bictcp_update() がオーバーフローしてCUBICが再び cwnd を急速に成長させてしまう。

要点は、回復開始時刻(recovery start time)はACK処理中に設定され、送信時間に基づく調整が回復開始時刻を未来に押し上げてしまう可能性がある、ということです。これは今回テストで観察した recovery と congestion_avoidance の間の振動を説明します。

この罠が一貫して発動するのは、着信する各ACKが bytes_in_flight を完全に0にする場合だけです。実際にはこれは cwnd が最小(2パケット)に崩壊し、アプリケーションがACK到着の瞬間に別のフルウィンドウを送るデータを準備している場合に起きます。そうでない通常の状態では、bytes_in_flight == 0 が毎回成り立つことは稀であり、バグが発動しにくくなります。

なぜ接続開始時に起きないのか?

このバグが発動するためには三つの条件が同時に必要です:

  • 実際の損失イベントがあり回復境界(recovery boundary)が設定されていること
  • 接続がスロースタートを抜けて congestion_avoidance を実行していること(CUBIC曲線が効いていること)
  • cwnd が2パケットの最小フロアまで崩壊していること

接続開始時やスロースタート中は congestion_recovery_start_time が設定されていないため、on_packet_sent のバグのある分岐は進めるべき回復境界を持たず、発動しません。CUBICのキュービック曲線とそれが congestion_recovery_start_time に敏感になるのは congestion_avoidance に入ってからです。

自己増殖する回復トラップ

最小 cwnd の状態では、各ACKサイクルがアイドル期間調整(idle period adjustment)をトリガーし、過大に膨らんだ delta が回復開始時刻を未来へ進めてしまいます。最小 cwnd(2パケット)では接続の動的挙動が「デススパイラル」にシフトし、アイドル最適化が自己成就的に...