Android VpnService:TUN の読み取り停止と ENOBUFS 書き込みエラーの違い
決して戻らない読み取りと ENOBUFS で失敗する書き込みは、原因が正反対です。告知済みウィンドウとパケット長を測って、正しい修正を選びましょう。
同じ VpnService のループに 二つの症状が現れ、どちらも同じ原因のせいにされます。決して戻らない読み取りと、ENOBUFS で失敗する書き込みです。これは一つのバグの変種ではありません。止まった読み取りはピアとのフロー制御をめぐる不一致であり、失敗した書き込みはインターフェースが受け取らないパケットです。両者を一つの問題として扱うと、まったく無関係なバッファの調整に 一日を費やすことになります。以下の切り分けは、ループのどちら半分を先に計測するかを決めるためのものです。
一つのループ、二つの独立した故障点
サービスは TUN ファイルディスクリプタから IP パケットを読み、検査し、同じディスクリプタへ応答を書き戻します。この経路で壊れるものはすべて、この二つの呼び出しのどちらかに現れます。だからこそ両方がトンネルの故障として報告されるのです。呼び出しの契約は異なります。読み取りはカーネルがパケットを渡すのを待ち、書き込みはカーネルに拒否される可能性のあるパケットを渡します。
読み取りは考えるのが易しい側です。何も返さない読み取りは、決して来ないトラフィックを待っているか、ピアが送信をやめたために待っています。
止まった読み取りはフロー制御の不一致
TUN デバイスの読み取りは、パケットが利用可能になるまでブロックします。これは設計された動作であり、アイドル状態のリンクで read の中で止まっているスレッドが正常に見える理由です。これがハングになるのは、自分の TCP スタックがローカルアプリからのデータを受け取り続けているのに、リモートのピアがさらに送れるだけのウィンドウを告知しない場合です。ピアは告知されたウィンドウを埋め、ACK とウィンドウ更新を待って停止します。すると読み取り側に返すものがなくなり、アプリには開いているのに沈黙した接続に見えます。
よくある原因は、告知済みウィンドウを追跡せずにソケットへ書き込むスタックと、バッファを空にした後にウィンドウ更新を送り忘れるスタックです。外から見ると両者は同じです。バイトは出ていくのに何も戻らず、エラーも発生しません。読み取りスレッドをパース、DNS 解決、ディスク I/O でブロックしても、無関係な理由で同じ形の症状になります。フロー制御を疑う前に、スレッドが本当に read の中にいることを確認してください。
ピアの送信キューと、ピアが最後に見たウィンドウを確認します。自分のウィンドウがゼロだからピアがデータを保持しているなら、修正すべきは ACK の経路であり、TUN の読み取りではありません。
ENOBUFS はサイズの問題で、バッファ不足ではない
TUN インターフェースで ENOBUFS を返す書き込みは、たいていパケット長を拒否しています。インターフェースには MTU があり、それより大きいパケットはキューに入りません。同じパケットを再試行すれば毎回同じように失敗します。この決定性が手がかりです。本当のバッファ不足は負荷が下がれば解消しますが、サイズ拒否はどれだけ待っても変わりません。
TCP にはまさにこのための仕組みがあり、それが見落とされがちな手順、MSS クランプです。ユーザー空間でセグメントを合成するとき、ペイロード長を選ぶのは自分です。トンネルの MTU ではなくローカルインターフェースの MTU から選ぶと、トンネルが運べないセグメントを送り出します。二つのうち小さい方に制限してください。クランプが対象とするのは TCP だけなので、大きすぎる UDP データグラムはコード側で分割するか破棄する必要があります。
デバッグ中は書き込むすべてのパケットの長さを記録します。失敗する長さが答えであり、再試行しても変わりません。
ケースを分ける三つの測定
どちらの故障にもデバッガは必要ありません。三つの観察で正しい節へ導けますし、再ビルドより短時間で済みます。
| What you see | Where to look |
|---|---|
| Reader parked, peer send queue empty, last advertised window zero | Window updates in your ACK path |
| Write fails on one length and succeeds on a smaller one | MSS clamp and tunnel MTU |
| Both directions look fine, app still sees no data | Protected socket and routing loop |
三行目が当てはまるなら、ループ自体は正常で、問題はトンネルから見えない場所にあります。自分の VPN トラフィックをトンネルへ戻してしまう保護されていないトランスポートソケットで、通常は接続に成功したのに何も届かない形で現れます。
両方の経路が依存する配管を直す
インターフェースが正しく構築されるまで、どの症状も追う価値はありません。重要な builder 呼び出しは、アドレス、ルート、MTU、同意の順です。
Builder()
.addAddress(10.8.0.2, 32)
.addRoute(0.0.0.0, 0)
.setMtu(1400)
.establish()MTU はサンプルが使った値のままにせず、トンネルの実際の値に設定します。後で書き込むものはすべてその数値に制約され、設定した MTU とクランプ先の MSS の不一致は、修正後に書き込み失敗を再発させる最も一般的な原因です。次に、VPN サーバー自体へのトラフィックを運ぶソケットを保護します。protect がないと、そのパケットは自分のトンネルへ戻され、接続は完了しません。止まった読み取りに見えますが、実際はルーティングループです。
同意は回避できません。prepare が null を返さずに開始したサービスはインターフェースを取得せず、establish は例外を投げずに null を返します。ループを開始する前にディスクリプタを確認してください。サンプルをコピーするのではなく、VpnService リファレンス で builder の契約を確認しましょう。
一度に一方向ずつ検証する
TCP に触れる前に、トンネル経由で UDP エコーを送り、パケット長を観察します。次にハンドシェイクを開き、セグメントが計算した MSS 未満に収まることを確認します。接続ではなくパケットを数えると、停止はソケットのハングではなくカウンタの空白として現れます。書き込みの連発の後に読み取りが止まるならウィンドウが容疑者で、書き込みが即座に失敗するなら長さです。
最後にデバイスを再起動して 一度繰り返します。最初のネットワーク変更までしか動かない VpnService は、フロー制御ではなくルーティングの問題です。