GCC 16.2 GNAT.Sockets が FreeBSD で accept() 失敗後に EAGAIN を上書きする問題

概要

FreeBSD 15.1 amd64 上の GCC/GNAT 16.2.0 では、ノンブロッキング accept()-1 を返し、errnoEAGAIN が設定された後、 GNAT.Sockets.Accept_SocketBAD_FILE_DESCRIPTOR を報告する挙動を再現できます。

この挙動は、GNAT.Sockets と Ada 標準ライブラリのパッケージだけに依存する 独立した Ada プログラムで再現しました。システムコールトレースを見ると FreeBSD カーネルは EAGAIN を返しています。その後 GNAT ランタイムが、失敗を示す戻り値 -1 を SIGPIPE 無効化ヘルパーへ渡します。FreeBSD ではこのヘルパーが setsockopt(-1, SOL_SOCKET, SO_NOSIGPIPE, ...) を呼び出します。この呼び出しが EBADF で失敗し、GNAT.Sockets がエラーを Ada 例外へ変換する前に、accept() が 設定した EAGAIN を上書きします。

観測した呼び出しとエラーの順序は次のとおりです。

FreeBSD kernel
  accept(listener) -> -1, errno = EAGAIN

GNAT runtime
  GNAT.Sockets.Thin.C_Accept
  -> Disable_SIGPIPE(-1)
  -> setsockopt(-1, SO_NOSIGPIPE, ...) -> -1, errno = EBADF

GNAT.Sockets
  -> Socket_Error: [9] Bad file descriptor

C_SocketSyscall_Socket 後にも、同じように Disable_SIGPIPE を無条件で 呼び出すパターンがあります。今回の調査では失敗する socket() 呼び出しを実行する ランタイムテストは行っていないため、このケースは再現済みのランタイム結果ではなく、 ソース監査で確認した事項として記録します。

ソースパッチでは、低レベルのソケット操作が成功した場合(R /= Failure)にだけ Disable_SIGPIPE を呼び出します。

テスト環境

この挙動を再現した環境は次のとおりです。

OS:       FreeBSD 15.1-RELEASE-p3
Kernel:   FreeBSD 15.1, __FreeBSD_version 1501000
Arch:     amd64
GNAT:     GNATMAKE 16.2.0
GCC src:  GCC 16.2.0 release

今回使用した Ada 対応 GCC 16.2 port ツリーは GCC 16.2.0 リリース tarball を 基にしています。

SHA256 (gcc-16.2.0.tar.xz) =
e6738e29597f733270731aa90600f37ffdc045079dfc27ec7e8192cc81085c3e

このコンパイラのビルドに使用したローカルの FreeBSD port のパッチセットも監査しました。 それらのパッチは g-socthi.adbC_AcceptC_SocketDisable_SIGPIPESO_NOSIGPIPEsocket.c のいずれも変更していません。アップストリームの GCC 16.2.0 ソースにも、再現した障害で観測されたものと同じ制御フローがあります。

アップストリームソースの参照先:

https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/libgnat/g-socthi.adb
https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/socket.c
https://github.com/gcc-mirror/gcc/blob/releases/gcc-16.2.0/gcc/ada/libgnat/g-socket.adb

独立した再現プログラム

次のプログラムは TCP リスニングソケットを作成してノンブロッキングモードに切り替え、 待機中のクライアントがいない状態で Accept_Socket を呼び出します。

with Ada.Exceptions;
with Ada.Text_IO;
with GNAT.Sockets;

procedure Accept_Eagain is
   use Ada.Exceptions;
   use Ada.Text_IO;
   use GNAT.Sockets;

   Server : Socket_Type;
   Client : Socket_Type;
   Peer   : Sock_Addr_Type;
   Req    : Request_Type := (Name => Non_Blocking_IO, Enabled => True);
begin
   Initialize;
   Create_Socket (Server, Family_Inet, Socket_Stream);
   Bind_Socket
     (Server,
      (Family => Family_Inet,
       Addr   => Inet_Addr ("127.0.0.1"),
       Port   => Any_Port));
   Listen_Socket (Server);
   Control_Socket (Server, Req);

   Accept_Socket (Server, Client, Peer);
   Put_Line ("unexpected accept success");
   Close_Socket (Client);
   Close_Socket (Server);
exception
   when E : Socket_Error =>
      Put_Line (Exception_Name (E) & ": " & Exception_Message (E));
      Close_Socket (Server);
end Accept_Eagain;

インストール済みの GNAT 16.2 コンパイラで次のようにビルドして実行します。

gnatmake16 -q accept_eagain.adb
./accept_eagain

観測結果:

GNAT.SOCKETS.SOCKET_ERROR: [9] Bad file descriptor

以下のトレースから、有効なリスニングソケット上で accept()EAGAIN を返した後、 続く setsockopt()errnoEBADF に変更していることが分かります。

システムコールトレース

再現プログラムを truss で実行すると、次の errno の変化が確認できます。

setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...)                              ERR#35 'Resource temporarily unavailable'
setsockopt(-1,SOL_SOCKET,SO_NOSIGPIPE,...) ERR#9 'Bad file descriptor'

最初の setsockopt() 呼び出しは、有効なリスニングディスクリプタ 3 に対して実行されています。

続く二つの呼び出しが errno の変化を示しています。ノンブロッキングのリスニングソケットで 利用可能な接続がない場合、accept()EAGAIN とともに -1 を返します。その直後、 GNAT ランタイムはディスクリプタ -1setsockopt() を呼び出します。この二回目の システムコールが errnoEBADF に変更します。

トレースには accept() からの EAGAIN と、その後の setsockopt() 呼び出しだけから 生じた EBADF が記録されています。元の EAGAIN は、accept() から復帰した後に GNAT ランタイム内部で 上書きされています。

GCC 16.2.0 のソースコード上の経路

gcc/ada/libgnat/g-socthi.adbC_Accept は、Syscall_Accept の結果を R に 保存します。実装ではノンブロッキングソケットの管理処理を R /= Failure で保護して いますが、その後の SIGPIPE 設定は無条件に行われます。

if not SOSC.Thread_Blocking_IO
  and then R /= Failure
then
   ...
end if;

Disable_SIGPIPE (R);
return R;

したがって Syscall_AcceptFailure-1)を返しても、ランタイムは次を 実行します。

Disable_SIGPIPE (-1);

Disable_SIGPIPEgcc/ada/socket.c から __gnat_disable_sigpipe として インポートされています。SO_NOSIGPIPE を定義するシステムでは、その実装が次を 呼び出します。

setsockopt (fd, SOL_SOCKET, SO_NOSIGPIPE, ...)

FreeBSD は SO_NOSIGPIPE を提供するため、失敗を示す戻り値 -1setsockopt(-1, ...) に渡され、そこで errnoEBADF に設定されます。

上位実装の g-socket.adb は、その後おおむね次のように処理します。

Res := C_Accept (...);

if Res = Failure then
   Raise_Socket_Error (Socket_Errno);
end if;

この時点で Socket_Errno が読み取るのは EBADF であり、accept() からの EAGAIN ではありません。

再現した経路では、accept() が返した Failure 値に対して後処理が行われ、その後の setsockopt() 呼び出しが、呼び出し元が errno を読む前に値を上書きします。

関連する C_Socket の所見

同じ GCC 16.2.0 ファイルの C_Socket にも、次のパターンがあります。

R := Syscall_Socket (Domain, Typ, Protocol);
...
Disable_SIGPIPE (R);
return R;

Syscall_SocketFailure を返した場合、同じ論理によって SO_NOSIGPIPE を定義する ターゲットで Disable_SIGPIPE (-1) に到達する可能性があります。

C_Accept の失敗は再現済みです。今回の調査では失敗する Syscall_Socket 呼び出しを 実行するランタイムテストは行っていないため、C_Socket のケースは独立して再現した ランタイム結果ではなく、関連するソース監査の所見として記録します。

パッチ

パッチではシステムコールの結果が Failure でない場合にだけディスクリプタの後処理を 行います。GCC 16.2.0 の g-socthi.adb に対する完全なパッチは次のとおりです。

--- gcc/ada/libgnat/g-socthi.adb.orig
+++ gcc/ada/libgnat/g-socthi.adb
@@ -187,7 +187,10 @@
          Discard := C_Ioctl (R, SOSC.FIONBIO, Val'Access);
       end if;

-      Disable_SIGPIPE (R);
+      if R /= Failure then
+         Disable_SIGPIPE (R);
+      end if;
+
       return R;
    end C_Accept;

@@ -533,7 +536,10 @@
          Discard := C_Ioctl (R, SOSC.FIONBIO, Val'Access);
          Set_Non_Blocking_Socket (R, False);
       end if;
-      Disable_SIGPIPE (R);
+      if R /= Failure then
+         Disable_SIGPIPE (R);
+      end if;
+
       return R;
    end C_Socket;

パッチは C_AcceptC_Socket の両方に同じ条件を適用します。どちらの変更箇所も GCC 16.2.0 のリリースソースに問題なく適用できます。

検証状況

C_Accept のパッチは次の項目で検証しました。

[PASS] standalone Ada reproducer using only GNAT.Sockets and the Ada standard library
[PASS] installed GNAT 16.2.0 reproduces BAD_FILE_DESCRIPTOR
[PASS] baseline truss records accept() returning EAGAIN first
[PASS] baseline truss records setsockopt(-1, SO_NOSIGPIPE) returning EBADF
[PASS] upstream GCC 16.2.0 source contains the matching control flow
[PASS] local FreeBSD GCC port patches do not introduce or modify this path
[PASS] both C_Accept/C_Socket patch hunks apply without rejects
[PASS] patched g-socthi.adb compiles as a single GNAT runtime unit
[PASS] temporary libgnat.a with only g-socthi.o replaced links successfully
[PASS] patched reproducer reports EAGAIN instead of EBADF
[PASS] patched truss contains no setsockopt(-1, SO_NOSIGPIPE) call

パッチ適用後のランタイム検証

GCC の完全なブートストラップを行わずに、パッチ適用後のランタイムを検証しました。 インストール済みの g-socthi.ali には、そのランタイムユニットのビルドに使用された コンパイラオプションが記録されています。インストール済みの g-socthi.adb をコピーして 二つの変更を適用し、記録されたオプションとインストール済み GNAT 16.2.0 コンパイラを 使って、そのランタイム本体だけをコンパイルしました。

次に、インストール済みの静的ランタイムアーカイブを一時ディレクトリへコピーし、 g-socthi.o だけを置き換えました。

cp /usr/local/lib/gcc16/gcc/x86_64-portbld-freebsd15.1/16.2.0/adalib/libgnat.a \
   /tmp/libgnat.a
ar r /tmp/libgnat.a g-socthi.o
ranlib /tmp/libgnat.a

この環境では gnatlink16 がインストール済みの libgnat.a を絶対パスで gcc16 に 渡すため、-L ディレクトリを追加するだけでは置き換えられません。検証用バイナリでは バインダ出力を再生成してコンパイルし、最後の gcc16 リンクコマンドを、インストール済み アーカイブの代わりに一時的に作成したパッチ適用済み libgnat.a を指定して再実行しました。

未修正のランタイムでは、再現プログラムは次を報告します。

GNAT.SOCKETS.SOCKET_ERROR: [9] Bad file descriptor

関連するトレースは次のとおりです。

setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...)                              ERR#35 'Resource temporarily unavailable'
setsockopt(-1,SOL_SOCKET,SO_NOSIGPIPE,...) ERR#9 'Bad file descriptor'

その他のランタイムアーカイブは変更せず、パッチ済みの g-socthi.o をリンクすると、 同じ再現プログラムは次を報告します。

GNAT.SOCKETS.SOCKET_ERROR: [35] Resource temporarily unavailable

関連するトレースは次のようになります。

setsockopt(3,SOL_SOCKET,SO_NOSIGPIPE,...) = 0
accept(3,...)                              ERR#35 'Resource temporarily unavailable'

このトレースでは accept()EAGAIN を返し、その後の setsockopt(-1, SO_NOSIGPIPE, ...) 呼び出しも発生していません。

C_Socket の変更箇所はソース監査だけで裏付けられています。今回の調査では失敗する socket() 呼び出しを実行するランタイムテストは行っていません。