注: 本記事は、私の初期メモと考えをもとに AI(Claude)が仕上げたものです。
何年も腑に落ちなかった謎がある。失敗するはずのネットワークリクエストが、ネットワークを繋ぎ直したあとに、なぜか勝手に送信されてしまうことがあるのだ。
最近 Flutter アプリでまた遭遇し、今度こそ経緯を解き明かせた。結論は拍子抜けするものだった——「再接続で自動同期する」気の利いた機能などではなく、TCP の接続タイムアウトとの競合(レース)だったのだ。
このアプリはオフラインファーストだ。レコードを保存するとき、まずローカルの暗号化データベースに書き込み、それからバックグラウンドで HTTP アップロードを「撃ちっぱなし(fire-and-forget)」で発射する。オフラインでもデータは失われず、アップロードはベストエフォートだ。
(以下のテストと数字はすべて iOS で測ったもの——接続タイムアウトやネガティブキャッシュの挙動は OS によって異なるので、秒数をクロスプラットフォームの定数とみなさないでほしい。)
テスト中、こんな光景を見た:
まるでアプリがネットワークの復帰を検知して再送したように見える。だが私は確信していた——このアプリには接続状態を監視するコードが一行もない。 では、いったいどうやって送られたのか?
これが最も直感的で、最も間違った解釈だ。connectivity listener がなければ、再送を引き起こす者などいない。リクエストは再送されたのではなく、そもそも一度しか発射されていない。その一度が、完了までにとても長くかかっただけだ。
「とても長くかかった」——これがすべての肝だ。
ネットワークがないとき HTTP POST が何をするかを分解してみよう:
そして Dart の http.Client.post はデフォルトでアプリ層のタイムアウトを持たないので、Future は接続タイムアウトの間ずっと pending のままになる。
さらにリクエストは撃ちっぱなし(unawaited、または await しない .then())だ。UI はとっくに先へ進んでいるが、リクエストはバックグラウンドで生きている:
// だいたいこんな感じ——実際のコードではない
unawaited(client.post(uri, body: payload)); // timeout なし、await なし
// ↑ オフラインだと、この Future は OS の接続タイムアウトの中で固まる——最長 ~30 秒
こうして「魔法」が起きる。接続タイムアウトが切れる前にネットワークを繋ぎ直せば、次に再送された SYN がついに返事を受け取り → 接続が確立し → バックグラウンドで並んでいた POST が完了する。
自動同期のように見えるが、実態は接続タイムアウトが切れる前に再接続できるかどうかのレースだ。
強調しておきたい——これは極めて不安定だ。一歩遅れて——タイムアウトがすでに切れたあとに——繋ぎ直せば、リクエストはとっくに失敗しており、誰かが明示的に再試行するまで何もアップロードされない。
メカニズムを裏づけるもう一つの手がかりがある。オフラインで保存する最初の一件はリクエストが約 30 秒固まるが、二件目・三件目は一瞬で失敗する。
これは典型的な**ネガティブキャッシュ(negative caching)**だ。到達できないホストへの最初の接続は OS の接続タイムアウトをまるごと待つが、一度失敗すると OS は「ホスト到達不能/DNS ネガティブ」の結果をキャッシュするので、以降の試行は待たずに即失敗する。
最初が遅く、あとは速い——アプリが何かをしているのではなく、ネットワークスタックの挙動だ。
「オフライン」を判定するために connectivity_plus の checkConnectivity() を使った。だがこれが読むのはOS のネットワークインターフェースの状態であって、実際にインターネットに到達できるかどうかではない。
これに噛まれた。wifi を切り、デバッグのために USB で Mac に繋いだ iPad では、checkConnectivity() は ConnectivityResult.other を返した——USB のデバッグ回線が「接続済み」のインターフェースとして数えられるのだ——ので、アプリは自分をオンラインだと思い込み、そのままアップロードを走らせ、固まった。最初の修正(any != none で判定)が間違っていたのは、まさにこの理由による。
正しいやり方は、本当に外部ネットに繋がるインターフェース(wifi / mobile / ethernet / vpn)だけをオンラインと数え、other / bluetooth / none はオフラインとして扱うことだ。
教訓はこうだ:インターフェースの存在 ≠ インターネット到達性。 connectivity_plus が答えるのは「up なインターフェースはあるか?」であって、「インターネットに到達できるか?」ではない。後者を問うには、実際に探りを入れる(短いタイムアウト付きの DNS 照会やリクエスト)必要がある——その代償として「一瞬」の応答をわずかな遅延と引き換えにする。
オフラインの挙動を、接続タイムアウトとの賭けではなく確定的にしたいなら、方向は明らかだ:
future.timeout(...))。固まりに上限を与え、運命を OS の接続タイムアウトに委ねない。onConnectivityChanged)。意図的に同期を起こすのであって、たまたま生き残った socket がいつか送ってくれるのを当てにするな。三行でまとめる:タイムアウトの境界は自分で引け、まだあえいでいる socket を信じるな、そして「インターフェースが up」は決して「インターネットに到達できる」を意味しない。