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 포트 트리는 GCC 16.2.0 릴리스 tarball을 기반으로 합니다.
SHA256 (gcc-16.2.0.tar.xz) =
e6738e29597f733270731aa90600f37ffdc045079dfc27ec7e8192cc81085c3e
이 컴파일러를 빌드할 때 사용한 로컬 FreeBSD 포트 패치 세트도 소스 감사를
수행했습니다. 해당
패치 가운데 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가 읽는 값은 accept()에서 나온 EAGAIN이 아니라
EBADF입니다.
재현된 경로에서는 accept()가 반환한 Failure 값에 대해 후처리가 수행되고, 호출자가
errno를 읽기 전에 후속 setsockopt() 호출이 그 값을 덮어씁니다.
관련 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()
호출을 실행하는 런타임 테스트를 수행하지 않았습니다.