TCP 受信・輻輳制御の現状と仕様

対象実装: arm/v1/src/kernel/net/tcp.rs, arm/v1/src/kernel/net/tcp_state.rs 単体試験: arm/v1/tests/src/test_tcp_state.rs

典拠(2026-07-29、ユーザ許可のうえ参照): - RFC 9293 (TCP) — https://www.rfc-editor.org/rfc/rfc9293.html#name-segment-receive - 未参照: RFC 5681 (Congestion Control), RFC 6298 (RTO), RFC 2018 (SACK)

現状の棚卸し(実装されていないもの)

src/kernel/net/ を全検索して、以下が1 つも存在しないことを確認した。

要素 典拠 実装
cwnd(輻輳ウィンドウ) RFC 5681 なし
ssthresh RFC 5681 なし
スロースタート / 輻輳回避 RFC 5681 §3.1 なし
重複 ACK カウント → 高速再送 RFC 5681 §3.2 なし
高速リカバリ RFC 5681 §3.2 なし
RTO / RTT 計測 RFC 6298 なし
送信側の再送キュー RFC 9293 なし
受信側の順序外再構成バッファ 任意 なし
SACK RFC 2018 なし

受信ウィンドウは build_tcp_ipv4 内で out[34]=0x0B, out[35]=0x40 = 固定値 2880 バイト。受信バッファの空き状況に応じた更新も無い。

RFC を読んで分かった重要な点(当初の見立ての訂正)

順序外セグメントの破棄は RFC 違反ではない。 RFC 9293 は受信側に キューイングを義務付けておらず、実装の裁量としている。ただし要件がある:

“any unacceptable segment (out-of-window sequence number or unacceptable acknowledgment number) must be responded to with an empty acknowledgment segment containing the current send sequence number and an acknowledgment indicating the next sequence number expected to be received”

すなわち RCV.NXT を載せた ACK を返すこと。現行実装(破棄 + 重複 ACK)は この要件を満たしている。

セグメント受理判定の規範は:

“A segment is judged to occupy a portion of valid receive sequence space if RCV.NXT =< SEG.SEQ < RCV.NXT+RCV.WND or RCV.NXT =< SEG.SEQ+SEG.LEN-1 < RCV.NXT+RCV.WND”

→ 問題は RFC 準拠性ではなく性能。バッファしないと、1 個の欠落で それ以降の到着分をすべて捨て、相手の再送を待つことになる。

実測データ(2026-07-29、実サイト取得中)

[TLS][READEND] TIMEOUT local_port=40005 elapsed_ticks=1500
               seg_accepted=627 seg_dropped=33
               sockets=["lport=40005 -> 142.250.23.94:443 state=ESTABLISHED
                        snd_nxt=1486 rcv_nxt=10410056 rx_buf=0"]

改善の優先順位(この順で spec → 単体試験 → 実装)

  1. 受信側の順序外再構成バッファ — 【2026-07-29 実装済み・効果は未検証】 src/kernel/net/reassembly.rs(純粋モジュール)+ tests/src/test_reassembly.rs 12 件。make test 567 件全通過、clippy エラー 0。

    【2026-07-29 測定基盤を実装】 kernel/tls.rs の stats モジュール。 HTTPS 取得を OK / DNS_FAIL / CONNECT_FAIL / HANDSHAKE_FAIL / TRUNCATED に 分類して数え、各段階で 1 行サマリを出す。

    [NET][STATS] total=5 ok=2 dns_fail=3 connect_fail=0 handshake_fail=0 truncated=0

    初回の測定結果: 5 試行中 成功 2・DNS 失敗 3。 この実行では TCP 接続・ハンドシェイク・打ち切りはいずれもゼロで、 失敗は全て DNS 段階だった。

    → ここまで「受信の打ち切り」「2 本目の接続失敗」を主要因として追ってきたが、 少なくともこの実行では DNS が支配的。単発観測では段階がばらついて 見えていたものが、集計すると分布として捉えられる。 【2026-07-29 3 回の測定で分布が確定】 | 実行 | total | ok | dns_fail | connect_fail | handshake_fail | truncated | |—|—|—|—|—|—|—| | 1 | 5 | 2 | 3 | 0 | 0 | 0 | | 2 | 10 | 3 | 2 | 0 | 0 | 5 | | 3 | 6 | 3 | 2 | 0 | 0 | 1 | | 計 | 21 | 8 (38%) | 7 (33%) | 0 | 0 | 6 (29%) |

    connect_fail と handshake_fail はいずれも 0 件。 このセッションで繰り返し述べた「2 本目の TCP 接続が失敗する」 「TLS ハンドシェイクに失敗する」は、21 試行で 1 件も観測されなかった。 tls.rs の既存コメント(L23-28「2 回目以降が毎回 TLS ハンドシェイクに 失敗する未解明の現象」)も、少なくとも現在は当てはまらない。 → 過去の対策(OS_RNG_CALL_COUNTER、retry_write)で解消済みか、 当時の観測が別現象だった可能性がある。

    実際の失敗は 2 つだけ:

    次の実装対象: この 2 つ。受信打ち切りには順序外再構成(実装済み・ 効果は次の測定で確認)と、受信ウィンドウ拡大(現状 2880 固定)が効くはず。 DNS 失敗は再送回数とタイムアウト値の見直しから。

    【重要】以下は測定基盤ができる前の記述(経緯として残す) 失敗段階が実行ごとにばらつくため(DNS / TCP 接続 / 受信打ち切り)、 個別の修正の効果を評価できない。先に測定基盤を整えるべき:

  2. 受信ウィンドウの拡大 — 【2026-07-29 実施・効果を測定】 build_tcp_ipv4 の広告ウィンドウを 2880 → 8192 バイト(≒5.6 MSS)へ。 2026-07-24 に 0xFFFF(65535)を試して悪化したように見え差し戻された経緯があり、 「中間的なサイズでの比較が必要」と残されていた。測定基盤ができたので実施。

    試行 ok dns_fail truncated
    変更前(2880) 21 8 (38%) 7 (33%) 6 (29%)
    変更後(8192) 10 4 (40%) 5 (50%) 1 (10%)

    受信打ち切りが 29% → 10% に減少。順序外再構成バッファの導入と 併せた効果と考えられる。ただし試行数が少なく確定ではない。

    dns_fail が 33% → 50% に増えているが、ウィンドウ変更とは無関係のはずで 実行ごとの変動と思われる。断定しないこと(少数観測からの断定を このセッションで何度も外している)。試行を増やして確認する。

    まだ「動的更新」ではない。受信バッファの空きに応じてウィンドウを 更新する本来の仕組み(RFC 9293 §3.8.6)は未実装。固定値を上げただけ。

  3. 送信側の再送キューと RTO(RFC 6298) 現状は相手の RTO 頼み。こちらの送信が落ちると回復手段が無い。

  4. cwnd / ssthresh によるスロースタート・輻輳回避(RFC 5681) 3 の再送機構が前提。

  5. SACK(RFC 2018)— 1 の後なら効果が大きいが優先度は最後。

DNS 失敗の追跡(2026-07-29 時点・未解決)

測定基盤による累積:

変更 試行 ok dns_fail truncated
ベースライン 21 8 (38%) 7 (33%) 6 (29%)
受信ウィンドウ 8192 10 4 (40%) 5 (50%) 1 (10%)
+ DNS txid/ポート共有 8 3 (38%) 5 (63%) 0 (0%)

受信打ち切りは 29% → 10% → 0% と一貫して減少(順序外再構成+ウィンドウ拡大)。 DNS 失敗は改善していない。txid 共有は「遅れて届いた応答を活かす」変更なので、 応答がそもそも届いていないなら効かない。悪化か変動かは試行数が少なく断定しない。

実測で分かった 2 つの事実

(1) UDP キューに消費されない残骸が溜まる(未解決・ただし今回の直接原因ではない)

DNS timeout: src_port=53005 udp_queue_len=3
entries=[":53000 ntp.nict.jp", ":53000 ntp.nict.jp", ":53002 fonts.googl"]

ポート 53000/53002 のクエリは応答を得て完了済みなのに、その後から届いた 他サーバの応答が残る。purge_port は成功時点で 1 回走るだけなので、 それ以降の到着分は取り残される(4 サーバへ同報しているため最大 3 件が遅れて届く)。 → 対策案: タイムアウト時にもそのポートを purge する/ 一定時間経過したエントリを回収する仕組みを入れる。 ただし今回は 6 件中 3 件で空きがあり、タイムアウトの直接原因ではない。

(2) 早いクエリは成功し、後のクエリが失敗する 残骸が示すとおり src_port 53000/53002(早い)は応答を得ている。 一方 53005/53006(後)は応答ゼロ。HTTPS 側で観測された 「最初のリクエストは通り、後が落ちる」と同じパターン。

【2026-07-29 解決】原因は自作スタックではなく「SLIRP × 同報」の相性だった

切り分け手順(ユーザ提案): 1. 送信側を実測 → [NET][DNSSEND] sent_ok=3/3。送信は常に成功していた。 「送れていない」のではなく「応答が返らない」と確定。 2. ホスト側で環境確認 → nslookup fonts.gstatic.com 8.8.8.8 は即座に成功。 ホスト環境に問題なし。 3. DNS ロジックはホスト単体試験済み(クエリ構築 3 件+応答解析 7 件。 圧縮ポインタ・ポインタループ・切り詰め・txid 不一致を網羅)。ロジックは容疑外。 4. → 残る容疑は 環境(QEMU の -netdev user = SLIRP)と使い方(同報)。

SLIRP は同時 UDP セッション数に上限があり、各セッションはアイドルで回収される。 1 クエリを 3〜4 サーバへ同報するとセッションを 3〜4 倍消費し、 後続クエリの応答経路が確保できなくなる。

対処: 同報をやめ、試行ごとに 1 サーバへ送る (dns_servers[attempt % len])。冗長性は試行間のサーバ切り替えで担保。

変更 試行 ok dns_fail truncated
ベースライン 21 8 (38%) 7 (33%) 6 (29%)
受信ウィンドウ 8192 10 4 (40%) 5 (50%) 1 (10%)
+ txid/ポート共有 8 3 (38%) 5 (63%) 0 (0%)
+ DNS 同報廃止 15 5 (33%) 4 (27%) 6 (40%)

DNS 失敗 63% → 27%。仮説と整合する。

教訓: 自作スタックのバグばかり疑って実装を読み込んでいたが、 実際は「環境の制約に対して使い方が過剰だった」。 ホスト側で同じことを試して環境を切り分けるのが早道だった。

測定基盤の限界と改良(2026-07-29)

現在の測定は「ページを 1 回読み込む際に発生する取得(5〜20 件)」を数える。 ページが早期に失敗するとサンプルが集まらない弱点があり、 retry_write の効果検証では 1〜2 件しか取れず判断できなかった (そのため検証を諦めて元に戻した)。

改良: scripts/screenshot_site.ps1 に SCREENSHOT_REPEAT を追加。 1 ブート内で同じ URL の読み込みを N 回繰り返す([NET][STATS] は ブート単位で累積するのでサンプルが増える)。

$env:SCREENSHOT_REPEAT="4"; make screenshot URL=https://www.sugi-lab.net/

ただしまだ不十分: 45 秒間隔ではロード完了前に次が始まり、 実測では 4 回指定して 2 回しかロードされなかった。さらに DNS が 早期に失敗すると後続の取得自体が発生しない。 → 間隔をロード完了検知に変える、あるいはカーネル側に 「同じ URL を N 回取得して統計を出す」自己診断コマンドを持たせるのが確実。 ブラウザ経由ではなく https_get_binary を直接 N 回叩けば、 描画やレイアウトの影響を排除して純粋にネットワークだけを測れる。

【2026-07-29 決定的】連続取得で「劣化」が可視化された

net.netbench("https://www.sugi-lab.net/css/modern-style.css", 10) (カーネル内蔵の自己診断。https_get_binary を直接叩くので 描画・レイアウト・キャッシュの影響が無い)の結果:

1/10  OK 49077 bytes            ← 完全成功
2/10  OK 0 bytes                ← 空応答
3/10  FAILED tcp connect timeout
4/10  FAILED tcp connect timeout
5/10  FAILED dns response timeout
6..10 FAILED dns response timeout
[NET][STATS] netbench-done total=9 ok=2 dns_fail=6 connect_fail=0 handshake_fail=0 truncated=1

同一 URL・同一条件で連続 10 回叩くと、1 回目だけ成功し、 回を追うごとに失敗段階が「成功 → 空応答 → TCP 接続 → DNS」と前倒しになる。

これは単発の揺らぎではなく、リソースが枯渇していく劣化パターン。 ブラウザ経由では描画やキャッシュが混ざって見えなかったものが、 純粋なネットワーク測定で初めてはっきり出た。

枯渇している資源の候補(未確認・推測なので確定扱いしないこと): - QEMU SLIRP のセッション表(UDP/TCP とも) - こちら側の TCP ソケット表(32 スロット)— ただし tcp_close_real は 解放経路が塞がれており、ERR_TABLE_FULL は観測されていない - USB Ethernet の送受信バッファ/リングの取りこぼし - ARP キャッシュのエントリ枯渇や失敗ネガティブキャッシュ

【実測完了】ゲスト側の資源枯渇は否定された。 netbench の各回の前にスナップショットを取った結果:

before=2  tcp_sockets=0  udp_queue=0  arp=2
before=3  tcp_sockets=0  udp_queue=0  arp=2
...
before=8  tcp_sockets=0  udp_queue=0  arp=2

TCP ソケット表・UDP キュー・ARP キャッシュのいずれも全 8 回にわたり完全に一定。 - tcp_sockets=0 … 全ソケットが正しく解放されている(リーク無し) - udp_queue=0 … 滞留エントリ無し(purge_port が効いている) - arp=2 … 増減無し

→ 候補 4 つのうち 3 つが消えた。枯渇しているのはゲストの外側。

【2026-07-29 訂正】「SLIRP のセッション枯渇」仮説は否定された。

当初「SLIRP は同時 UDP セッション数に上限があり、アイドルで回収される」と 書いたが、これは一次情報の裏付けが無い私の記憶だった。 QEMU 公式ドキュメント(https://www.qemu.org/docs/master/system/devices/net.html)で 確認できたのは以下だけで、セッション数上限の記載は無い: - ICMP は一般に動作しない(10.0.2.2 へのローカル ping のみ例外) - 性能上の制約が本質的にある(公式が passt/TAP を「より高性能」と明記) - 完全に自己完結した独自ネットワークスタック - 着信接続は全て遮断

さらに実験でも否定された。net.netbench(url, 6, 8)(各回 8 秒間隔):

1/6 OK 49077 bytes
2/6 FAILED tcp connect timeout
3..6 FAILED dns response timeout

間隔なしと同じ順序で劣化。時間で回復しない= アイドルタイムアウトで解放される類の資源ではない。

教訓: 記憶に基づく推測を根拠として扱ってはいけない。 一次情報の確認と実験の両方で否定された。

【2026-07-29 真因確定】USB Ethernet が受信を停止する

usb.rs の ingest_nic_ethernet_frame 呼び出し地点にフレーム受信カウンタを置いて実測:

before=2  rx_frames=14  tcp_sockets=0  udp_queue=0  arp=2
before=3  rx_frames=14  ...
before=4  rx_frames=14  ...
before=5  rx_frames=14  ← 1 回目以降、1 フレームも増えない

1 回目の通信(14 フレーム)の後、USB Ethernet がフレームを一切受信しなくなる。

これで観測されていた全症状が説明できる: - DNS タイムアウト … 応答フレームが届かない - TCP 接続タイムアウト … SYN-ACK が届かない - 受信打ち切り … 途中でフレームが止まる

別々の問題として追ってきた 3 つは、すべて「USB 受信が止まる」という単一の症状だった。 TCP の輻輳制御も DNS の再送も、フレームが届かなければ無関係。 spec/ に書いた RFC 5681/6298 の未実装は事実だが、今の障害の原因ではない。

注意: 受信経路は usb.rs に 2 箇所ある。 - usb.rs:2386(eth_frame)… こちらが実際に使われている - usb.rs:2396(RX_DMA_BUF.data[..recv_len])… 一度も実行されない死にコード 最初こちらに計測を置いて rx_frames=0 となり、経路を取り違えていたと判明した。

次の手(推測で進めない): 1. 受信が止まる直前の USB/DWC2 の状態を実測する。 チャンネル割り当て、転送完了割り込み、エラービット(XactErr/Stall/NAK 継続)、 トグルビットの不一致など。どこで止まっているかを先に特定する 2. 「1 回目は動く」ので初期化は正しい。転送完了後の後始末か エラー時の回復処理が抜けている可能性が高いが、確認してから直すこと 3. 直ったら net.netbench で成功率を測り、数値で確認する

次の手(推測で進めない): 1. 別の netdev で比較する。-netdev user(SLIRP)以外、 例えば run-vmnet-shared(macOS)や TAP/bridge で同じ netbench を回し、 劣化パターンが消えるかを見る。消えれば SLIRP 起因で確定する。 Makefile には既に run-vmnet-shared / run-vmnet-bridged がある。 2. SLIRP 起因なら、開発環境の制約として記録し、 実機(Raspberry Pi 実物)または別 netdev での検証に切り替える。 自作スタック側の「修正」で追いかけても直らない可能性が高い。 3. それでもこちら側でできる緩和はある: - 接続を張り直さず同一接続で複数リクエスト(HTTP keep-alive)を使えば セッション消費が激減する。実サイトの CSS/JS/画像は同一ホストなので効果大。 - これは SLIRP 起因かどうかに関わらず正しい改善。優先度高。

次のボトルネックは打ち切り(40%)

DNS が通るようになり、後段の TCP 受信まで到達する試行が増えた結果、 truncated が表面化した(0% → 40%)。悪化か表面化かは試行数が少なく断定しない。

内訳が 打ち切り 40% > DNS 失敗 27% に逆転したので、 次は打ち切り側=送信側再送キュー・RTO(RFC 6298)と輻輳制御(RFC 5681)。 受信ウィンドウ拡大と順序外再構成だけでは、ロス時に相手の RTO 待ちになる。 SLIRP は TCP セッションにも上限があるため、同時接続数を絞ることも 併せて検討する(DNS と同じ構図の可能性)。

注意

tcp_state.rs の受信状態機械は単体試験 37 件で固定済みだが、 それは「実装した範囲が正しい」ことの保証であって、 輻輳制御が存在することの保証ではない。テスト件数を根拠に 「TCP は検証済み」と読まないこと。

L1/L2(USB Ethernet)の単体試験と修正(2026-07-29)

経緯: ユーザから当初より「OSI レイヤーを L1 から全部単体試験して確認すべき」と 指摘されていたが、実際に作った単体試験は L7(CSS/HTML/JS) と L4(TCP/UDP) に偏り、 L1/L2 はゼロだった。そして真因はそこにあった。 TCP の輻輳制御(RFC 5681)や DNS 再送を実装しようとしていたのは、 フレームが 1 枚も届いていない状態での作業だった。

実装: src/kernel/usb_rx_state.rs + tests/src/test_usb_rx_state.rs(16 件)

受信チャネルの状態遷移を純粋ロジックへ切り出し、レジスタ値から Arm / WaitInFlight / Harvest / Rearm / AbnormalHalt を導出する。

見つけた欠陥 2 つ

(1) NAK でチャネルが張り付き、受信が永久停止する

[USB][RX] channel stuck enabled: HCCHAR=0x80b89040 HCINT=0x00000010

HCCHAR bit31(CHENA)=1、HCINT bit4(NAK) のみ。 Bulk IN の NAK は「今はデータが無い」という正常応答で、再アームすればよい。 しかし旧実装は

if (hcchar & CHENA) != 0 { return; }   // break ではなく return

と無条件に関数を抜けており、この状態から永久に復帰できなかった。 ints のチェックにも到達しないためエラーログすら出ない(異常停止カウンタ 0 件)。

(2) NAK を「異常停止」に誤分類していた CHENA が落ちた後も HCINT=0x10(NAK のみ)で戻る経路があり、 そちらを異常扱いしていたため 21504 回エラーログを出していた。 NAK はエラーではないので、CHENA の有無に関わらず常に再アームへ倒す。

効果(net.netbench の rx_frames)

状態 受信フレーム数の推移
修正前 14, 14, 14, 14(完全停止)
(1) 修正 17, 18, 20, 20
(1)+(2) 修正 106, 108, 109, 110

1 回目の受信量が 14 → 106 フレームへ。受信の完全停止は解消した。

残る課題

2 回目以降は 106→110 とほぼ増えない。1 回目の後に受信が細る現象は残っている。 tcp connect timeout / dns response timeout も継続。 → 次も層を飛ばさず L2 から順に確認する (フレームは届いているか → ARP → IP → UDP/TCP)。 実機のレジスタ値 HCCHAR=0x80b89040 HCINT=0x00000010 は テストケースとして固定済みなので、再発すれば単体試験で検出できる。