【ITニュース解説】The test that passed on Windows and failed on Linux
2026年10月03日に「Dev.to」が公開したITニュース「The test that passed on Windows and failed on Linux」について初心者にもわかりやすく解説しています。
ITニュース概要
Windowsで成功したポート利用可否テストがLinuxで失敗した。原因は、`bind()`でポート再利用を試みる確認方法が古い接続状態に影響され誤判断したため。`connect()`で接続拒否を確認する方法に修正し、テスト内の接続漏れも直した。テストは実行環境や確認方法の選択が重要だ。
ITニュース解説
プルリクエストが作成され、Windows環境では全てのテストに合格したにもかかわらず、Linux環境の継続的インテグレーション(CI)で失敗したという状況から話は始まる。開発者は、自身の変更が正しいと確信していたため、この予期せぬ失敗の原因究明に取り組むことになった。
修正の対象は、コマンドラインツール内で使用されるOAuth認証ヘルパーだった。以前のヘルパーは、利用可能な空きポートを見つけるために、まずポートに一時的にバインドし、そのポート番号を読み取った後、すぐにソケットを閉じるという手順を踏んでいた。しかし、この「バインドして閉じる」までの短い間に、他のプロセスがそのポートを占有してしまう可能性があった。今回の修正では、このポートが奪われる「窓」をなくすため、ポートに一度バインドしたら、そのソケットを直接HTTPサーバーに引き渡す方式に変更された。開発者は自身の環境でこの修正が意図通り機能することを確認し、関連するテストを追加して変更をプッシュした。
しかし、CIからの結果は、Linux環境で複数のテストが失敗していることを示した。特に、開発者が追加したばかりのテストが OSError: [Errno 98] Address already in use というエラーメッセージと共に失敗したのだ。このエラーは、プログラムが特定のポートにソケットをバインドしようとした際に、そのポートがすでに他のプロセスによって使用されているために失敗したことを意味する。
失敗したテストは、「OAuth認証フローが完了した後、リダイレクトに使われたポートが適切に解放されているか」ということを検証する目的で書かれていた。このテストでは、_port_is_free という関数を使ってポートの空き状態を判断していた。この関数は、指定されたポートに新しいソケットをバインドできるかどうかを試すことで、ポートが空いているかを判断する。バインドに成功すればポートは空いているとみなし、OSError が発生すればポートは使用中であると判断する仕組みだった。開発者にとって、この「バインドを試す」という方法は、ポートの空き状況を確認する上で最も直感的で一般的な手法だと考えられたが、これがまさに問題の原因だった。
最初の仮説として、ポートがサーバーから閉じられた後も、その接続がTCPの TIME_WAIT 状態にしばらく留まっているため、再バインドがブロックされているのではないかと考えられた。TIME_WAIT 状態は、ネットワーク上でデータが遅延した場合に古い接続のデータが新しい接続に混入するのを防ぐために、一定期間ポートを保持する状態である。この状態にあるポートを再利用可能にするためには、通常 SO_REUSEADDR というソケットオプションを設定する。開発者はこのオプションをプローブ用のソケットに設定し、再度CIにプッシュしたが、結果は変わらず、再びテストは失敗した。
SO_REUSEADDR を設定しても解決しなかったという事実は、TIME_WAIT が根本原因ではないことを強く示唆した。なぜなら、SO_REUSEADDR はTIME_WAIT 状態のポート再利用を許可する目的のオプションだからだ。もしTIME_WAITが原因であれば、このオプションで問題は解決するはずだった。さらなる調査の結果、同じテストスイート内には二つの関連テストがあったことが判明した。一つはOAuthフローが完全に完了する場合(これが失敗したテスト)、もう一つはフローが途中で完了しない場合(これは成功した)。両方のテストがポートの再バインドを試みているにもかかわらず、フローが完了するケースだけが失敗したのだ。この違いから、クライアントが実際にサーバーに接続し、レスポンスを受け取った場合にのみ問題が発生していることが推測された。これは、接続が FIN_WAIT_2 や ESTABLISHED といった、「生きている」状態、つまりまだ完全にクローズされていない状態にある可能性が高いことを意味する。SO_REUSEADDR はTIME_WAIT状態のポートは再利用を許容するが、このように「生きている」接続がぶら下がっているポートの再利用は意図的に許可しない。これは、二つのアプリケーションが同じポートとIPアドレスの組み合わせで通信しようとすると、データの衝突や混乱が生じるのを防ぐためだ。
最終的に判明した問題の根本原因は二つあった。一つは、テストがポートの空き状況を判断するために使用していた bind メソッドが、開発者が直接制御できないTCP接続の内部状態に敏感すぎたこと。もう一つは、テスト自体の設計に「リーク」があったことだ。テストで使われていた偽のクライアントがサーバーからのレスポンスを適切に閉じなかったため、接続が開放されずにぶら下がったままになっていた。これは皮肉なことに、今回の本番コードの修正が防ごうとしていた種類の「ポートのリーク」と全く同じ性質の問題だった。
この発見を受け、開発者はテストのアプローチを変更した。ポートに「何かをバインドできるか」と問う代わりに、「まだ何かここでリッスンしているか」と問うようにしたのだ。新しい関数 _nothing_is_listening では、bind の代わりに connect メソッドが使われた。connect() は、特定のポートでリッスンしているサーバーに接続を試みる。もし接続が拒否される(ConnectionRefusedError や OSError が発生する)ならば、そのポートでリッスンしているサーバーは存在しないと判断できる。この方法の利点は、過去の接続がどのようなTCP状態にあったとしても、その影響を受けることなく、現在アクティブなリスナーが存在するかどうかだけを確認できる点にある。connect() は既存の接続の識別情報(クライアントIP、クライアントポート、サーバーIP、サーバーポートからなる4つの組み合わせ)には触れないため、古い接続の状態に関係なく、純粋に新しいリスナーの有無を判定できるのだ。同時に、偽のクライアントもレスポンスを適切に閉じるように修正され、接続のリークも解消された。
これらの変更を適用して再度CIにプッシュしたところ、全てのテストが合格となり、無事グリーンとなった。
しかし、これで終わりではない。新しい _nothing_is_listening 関数は「何もリッスンしていない」という否定的な主張を行う。このような否定的なアサーションには、「常に真になってしまい、実際には何も検証できていない」という「空虚な真」の問題が潜んでいる可能性がある。もしこの関数がどのような状況でも常に「何もリッスンしていない」と答えてしまうとしたら、テストは常に合格するが、それは何の意味も持たない。このような事態を防ぐため、開発者は「コントロールアーム」と呼ばれる追加のテストケースを導入した。これは、プローブが正しく機能していることを証明するために、意図的に失敗するはずの状況でテストを実行することだ。具体的には、実際にリスナーが起動している状態で _nothing_is_listening を実行し、それが期待通り False(何かリッスンしている)を返すことを確認した。このコントロールアームがあることで、テストで True が返されたときに、それが本当に「何もリッスンしていない」という状態を表していることが保証される。
この一連の経験から、OAuthやPythonといった特定の技術にとどまらない、三つの普遍的な教訓が得られた。
一つ目は、修正に対するテストも、修正そのものの一部であるという点だ。開発者が行った変更は単にコードの機能改善だけでなく、その改善を複数のプラットフォームで正しく検証するテストまでを含んで初めて完了すると考えるべきだ。自分の開発環境でのテスト成功はあくまで一つのサンプルに過ぎず、異なるOS環境でCIが失敗した場合、問題は必ずしも本番コードにあるとは限らず、テストの計測方法自体にバグがある可能性も考慮する必要がある。
二つ目は、否定的なアサーションを行う際には、制御できない状態に影響されないツールを選ぶことの重要性だ。「ポートが解放された」という主張は、サーバーがそのポートで稼働していないことを意味する。ポートへの bind が成功するかどうかで判断しようとすると、カーネルが管理するTCP接続の状態という、開発者が直接制御できない情報が紛れ込んでしまうことがある。対照的に、ポートへの connect が拒否されるかどうかで判断する方が、より純粋に「サーバーが存在するかどうか」を問うことができる。つまり、意図する意味を正確に表現できるようなテスト手法を選択すべきだ。
三つ目は、否定的なプローブには必ずコントロールアームを追加することだ。常に True を返すようなテストは、実質的に何も証明しない。プローブが「別の答え」(つまり False)を返すはずの、少なくとも一つの追加ケースをテストに含めることで、そのテストが本当に意味のある検証を行っていることを証明できる。
今回の問題解決において、最終的に採用されたアプローチは、最初の試みよりもシンプルで、実装にかかった時間も短かった。しかし、最も重要な、そして時間を要した部分は、修正すべき対象がコード本体ではなく、テストが使用している「計測器」(ポートの空き状態を判断する方法)であったことに気づくという洞察だった。この気づきが、最終的な解決へと導いたのである。