GCC 16.2 GNAT.Sockets が FreeBSD で accept() 失敗後に EAGAIN を上書きする問題
概要
FreeBSD 15.1 amd64 上の GCC/GNAT 16.2.0 では、ノンブロッキング accept() が
-1 を返し、errno に EAGAIN が設定された後、
GNAT.Sockets.Accept_Socket が BAD_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_Socket の Syscall_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.adb、C_Accept、C_Socket、Disable_SIGPIPE、
SO_NOSIGPIPE、socket.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() が errno を EBADF に変更していることが分かります。
システムコールトレース
再現プログラムを 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 ランタイムはディスクリプタ -1 で setsockopt() を呼び出します。この二回目の
システムコールが errno を EBADF に変更します。
トレースには accept() からの EAGAIN と、その後の setsockopt() 呼び出しだけから
生じた EBADF が記録されています。元の EAGAIN は、accept() から復帰した後に GNAT ランタイム内部で
上書きされています。
GCC 16.2.0 のソースコード上の経路
gcc/ada/libgnat/g-socthi.adb の C_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_Accept が Failure(-1)を返しても、ランタイムは次を
実行します。
Disable_SIGPIPE (-1);
Disable_SIGPIPE は gcc/ada/socket.c から __gnat_disable_sigpipe として
インポートされています。SO_NOSIGPIPE を定義するシステムでは、その実装が次を
呼び出します。
setsockopt (fd, SOL_SOCKET, SO_NOSIGPIPE, ...)
FreeBSD は SO_NOSIGPIPE を提供するため、失敗を示す戻り値 -1 が
setsockopt(-1, ...) に渡され、そこで errno が EBADF に設定されます。
上位実装の 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_Socket が Failure を返した場合、同じ論理によって 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_Accept と C_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() 呼び出しを実行するランタイムテストは行っていません。