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_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() 호출이 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가 기록됩니다. 원래의 EAGAINaccept() 호출이 반환된 뒤 GNAT 런타임 내부에서 덮어써집니다.

GCC 16.2.0의 소스 경로

gcc/ada/libgnat/g-socthi.adb에서 C_AcceptSyscall_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가 읽는 값은 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_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() 호출을 실행하는 런타임 테스트를 수행하지 않았습니다.