GCC 16.2 GNAT.Sockets ghi đè EAGAIN sau khi accept() thất bại trên FreeBSD

Tóm tắt

Trên FreeBSD 15.1 amd64 với GCC/GNAT 16.2.0, có thể tái hiện hành vi trong đó GNAT.Sockets.Accept_Socket báo BAD_FILE_DESCRIPTOR sau khi lời gọi accept() ở chế độ không chặn trả về -1 với errno được đặt thành EAGAIN.

Hành vi này được tái hiện bằng một chương trình Ada độc lập chỉ phụ thuộc vào GNAT.Sockets và các gói thuộc thư viện chuẩn Ada. Dấu vết lời gọi hệ thống cho thấy nhân FreeBSD trả về EAGAIN. Sau đó runtime của GNAT gọi hàm trợ giúp vô hiệu hóa SIGPIPE với giá trị trả về -1 biểu thị thất bại. Trên FreeBSD, hàm trợ giúp này gọi setsockopt(-1, SOL_SOCKET, SO_NOSIGPIPE, ...). Lời gọi đó thất bại với EBADF, làm ghi đè giá trị EAGAIN từ accept() trước khi GNAT.Sockets chuyển lỗi thành một ngoại lệ Ada.

Trình tự lời gọi và lỗi quan sát được là:

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

Mẫu gọi Disable_SIGPIPE vô điều kiện tương tự cũng tồn tại sau Syscall_Socket trong C_Socket. Không có kiểm thử runtime nào trong lần điều tra này thực thi một lời gọi socket() bị lỗi, vì vậy trường hợp đó được ghi nhận là một phát hiện từ việc kiểm tra mã nguồn chứ không phải kết quả runtime đã được tái hiện.

Bản vá mã nguồn chỉ gọi Disable_SIGPIPE khi thao tác socket cấp thấp thành công (R /= Failure).

Môi trường kiểm thử

Hành vi này được tái hiện trong môi trường sau:

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

Cây nguồn của port GCC 16.2 có bật Ada dùng cho công việc này dựa trên tarball phát hành GCC 16.2.0:

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

Bộ bản vá cục bộ của port FreeBSD dùng để xây dựng trình biên dịch này cũng đã được rà soát ở mức mã nguồn. Không bản vá nào trong số đó thay đổi g-socthi.adb, C_Accept, C_Socket, Disable_SIGPIPE, SO_NOSIGPIPE hoặc socket.c. Mã nguồn upstream GCC 16.2.0 chứa cùng luồng điều khiển như luồng đã quan sát trong lỗi được tái hiện.

Tham chiếu tới mã nguồn upstream:

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

Chương trình tái hiện độc lập

Chương trình sau tạo một socket TCP lắng nghe, chuyển nó sang chế độ không chặn, rồi gọi Accept_Socket khi không có máy khách nào đang chờ kết nối.

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;

Biên dịch và chạy bằng trình biên dịch GNAT 16.2 đã cài đặt:

gnatmake16 -q accept_eagain.adb
./accept_eagain

Kết quả quan sát được:

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

Dấu vết bên dưới cho thấy accept() đã trả về EAGAIN trên socket lắng nghe hợp lệ trước khi một lời gọi setsockopt() tiếp theo đổi errno thành EBADF.

Dấu vết lời gọi hệ thống

Chạy chương trình tái hiện dưới truss cho thấy sự thay đổi của errno như sau:

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'

Lời gọi setsockopt() đầu tiên thao tác trên descriptor 3 của socket lắng nghe hợp lệ.

Hai lời gọi tiếp theo cho thấy sự thay đổi của errno. Khi không có kết nối nào sẵn sàng trên socket lắng nghe không chặn, accept() trả về -1 với EAGAIN. Ngay sau đó runtime của GNAT gọi setsockopt() với descriptor -1. Lời gọi hệ thống thứ hai này đổi errno thành EBADF.

Dấu vết ghi nhận EAGAIN từ accept() và chỉ ghi nhận EBADF ở lời gọi setsockopt() tiếp theo. Giá trị EAGAIN ban đầu bị ghi đè bên trong runtime của GNAT sau khi accept() trả về.

Luồng xử lý trong mã nguồn GCC 16.2.0

Trong gcc/ada/libgnat/g-socthi.adb, C_Accept lưu kết quả của Syscall_Accept vào R. Phần triển khai bảo vệ công việc quản lý socket không chặn bằng điều kiện R /= Failure, nhưng phần thiết lập SIGPIPE tiếp theo lại được thực hiện vô điều kiện:

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

Disable_SIGPIPE (R);
return R;

Do đó, khi Syscall_Accept trả về Failure (-1), runtime vẫn thực thi:

Disable_SIGPIPE (-1);

Disable_SIGPIPE được nhập từ gcc/ada/socket.c dưới tên __gnat_disable_sigpipe. Trên các hệ thống định nghĩa SO_NOSIGPIPE, phần triển khai của nó gọi:

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

FreeBSD cung cấp SO_NOSIGPIPE, vì vậy giá trị trả về -1 biểu thị thất bại được truyền tới setsockopt(-1, ...), khiến errno được đặt thành EBADF.

Phần triển khai cấp cao hơn trong g-socket.adb sau đó thực hiện gần tương đương như sau:

Res := C_Accept (...);

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

Tại thời điểm đó, Socket_Errno đọc được EBADF thay vì EAGAIN từ accept().

Trong đường đi đã tái hiện, hậu xử lý được thực hiện trên giá trị Failureaccept() trả về, và lời gọi setsockopt() tiếp theo ghi đè errno trước khi bên gọi đọc giá trị này.

Phát hiện liên quan trong C_Socket

Cùng tệp GCC 16.2.0 đó chứa mẫu sau trong C_Socket:

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

Nếu Syscall_Socket trả về Failure, cùng logic đó có thể dẫn tới Disable_SIGPIPE (-1) trên hệ thống đích định nghĩa SO_NOSIGPIPE.

Lỗi C_Accept đã được tái hiện. Không có kiểm thử runtime nào trong lần điều tra này thực thi một lời gọi Syscall_Socket bị lỗi, vì vậy trường hợp C_Socket được ghi nhận là một phát hiện từ việc kiểm tra mã nguồn chứ không phải kết quả runtime được tái hiện độc lập.

Bản vá

Bản vá chỉ thực hiện hậu xử lý descriptor khi kết quả lời gọi hệ thống không phải là Failure. Toàn bộ bản vá cho g-socthi.adb của GCC 16.2.0 được đưa bên dưới:

--- 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;

Bản vá áp dụng cùng một điều kiện bảo vệ cho cả C_AcceptC_Socket. Cả hai phần thay đổi đều áp dụng sạch vào mã nguồn bản phát hành GCC 16.2.0.

Trạng thái xác minh

Bản vá C_Accept đã được xác minh bằng các kiểm tra sau:

[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

Xác minh runtime sau bản vá

Runtime sau bản vá đã được xác minh mà không cần bootstrap đầy đủ GCC. g-socthi.ali đã cài đặt ghi lại các tùy chọn biên dịch dùng để xây dựng đơn vị runtime. g-socthi.adb đã cài đặt được sao chép, hai phần thay đổi được áp dụng, rồi riêng phần thân runtime đó được biên dịch bằng trình biên dịch GNAT 16.2.0 đã cài đặt với các tùy chọn được ghi lại.

Sau đó thư viện runtime tĩnh đã cài đặt được sao chép vào một thư mục tạm và chỉ g-socthi.o được thay thế:

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

Trên cài đặt này, gnatlink16 truyền libgnat.a đã cài đặt tới gcc16 bằng đường dẫn tuyệt đối, vì vậy chỉ thêm một thư mục -L là chưa đủ để thay thế nó. Đối với tệp thực thi dùng để xác minh, đầu ra của binder được tạo lại và biên dịch, sau đó lệnh liên kết gcc16 cuối cùng được chạy lại với libgnat.a tạm thời đã vá thay cho thư viện đã cài đặt.

Với runtime chưa sửa, chương trình tái hiện báo:

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

và dấu vết liên quan là:

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'

Khi liên kết g-socthi.o đã vá vào thư viện runtime mà các thành phần khác vẫn giữ nguyên, cùng chương trình tái hiện báo:

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

và dấu vết liên quan trở thành:

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

Trong dấu vết này, accept() trả về EAGAIN và không có lời gọi setsockopt(-1, SO_NOSIGPIPE, ...) nào xảy ra sau đó.

Phần thay đổi C_Socket chỉ được xác nhận qua việc kiểm tra mã nguồn; không có kiểm thử runtime nào trong lần điều tra này thực thi một lời gọi socket() bị lỗi.