TLDR - show me the code
Here is the vibe-coded implementation
Note: this is only the rough implementation, it will be redone carefully once API is approved
Background and motivation
High-performance servers that drive non-blocking I/O from a dedicated poll thread (epoll/kqueue) need a lightweight accept path. The current Socket.Accept() API has several costs that are avoidable for consumers who only need the raw file descriptor — not a full Socket object:
Problem 1: Exception-driven EAGAIN on the hot path
In a poll-driven accept loop, the worker drains all pending connections until there are none left. With Socket.Accept() on a non-blocking socket, the "no more connections" signal is a thrown SocketException(WouldBlock) — an exception allocation + stack trace capture + throw + catch on every single wakeup.
Problem 2: Full Socket object allocation per accept
Socket.Accept() calls CreateAcceptSocket which:
- Allocates a new
Socket object with managed state (address family, socket type, protocol, blocking flags, event tracking)
- Calls
UpdateAcceptSocket to copy listener state and configure the accepted socket
- Calls
InternalSetBlocking which issues an fcntl / ioctl syscall to set blocking mode
- Creates an
EndPoint object from the peer address
For a server that only needs the raw fd (to register with a poll handle and drive non-blocking I/O), this is ~320 bytes of unnecessary allocation per connection.
Problem 3: Accepted fd is always blocking
The runtime's native SystemNative_Accept uses accept4(SOCK_CLOEXEC) on Linux — without SOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking mode via fcntl. A consumer driving non-blocking I/O via poll must then make an additional fcntl(O_NONBLOCK) syscall per accepted connection.
Problem 4: TCP_DEFER_ACCEPT requires magic numbers
TCP_DEFER_ACCEPT is only available via Socket.SetRawSocketOption((int)SocketOptionLevel.Tcp, 9, …). For a TLS server this is high-value: the kernel only completes the accept once the client's first data byte (the TLS ClientHello) is in the buffer, which guarantees SSL_do_handshake makes immediate forward progress.
Benchmark evidence
I benchmarked Socket.Accept() against a raw accept() P/Invoke to quantify the overhead:
BenchmarkDotNet v0.15.6, Linux Ubuntu 24.04.3 LTS
AMD Ryzen 9 7950X3D, .NET 10.0.3
| Method | Mean | Allocated |
|------------------ |---------:|----------:|
| SocketAccept | 7.677 us | 320 B | ← full Socket object per accept
| RawAccept | 8.155 us | 64 B | ← SafeSocketHandle only (5x less alloc)
| | | |
| EagainException | 5.865 us | 569 B | ← Socket.Accept() throws on EAGAIN
| EagainReturnValue | 3.839 us | 9 B | ← raw accept returns -1 (1.5x faster, 63x less alloc)
- EAGAIN path: Returning
false instead of throwing is 1.5x faster with 63x less allocation (9 B vs 569 B). At 100k+ accepts/sec, the EAGAIN fires on every poll wakeup.
- Accept path: Per-call latency is similar (dominated by the kernel syscall), but allocation drops from 320 B to 64 B — a 5x reduction. At scale, this translates directly to GC pressure savings.
Goals
- A
Try-pattern accept that returns bool instead of throwing on EAGAIN.
- Returns
SafeSocketHandle instead of a full Socket object.
- Captures the peer address in the same
accept() syscall (no extra getpeername).
- The accepted fd should be non-blocking and CLOEXEC.
TCP_DEFER_ACCEPT as a first-class enum value.
API Proposal
namespace System.Net.Sockets;
public partial class Socket
{
/// <summary>
/// Attempts to accept a pending connection without blocking.
/// </summary>
/// <param name="acceptedHandle">
/// On success, receives the accepted connection's handle. The handle is
/// non-blocking and CLOEXEC. The caller owns the handle and is responsible
/// for disposing it.
/// </param>
/// <param name="remoteEndPoint">
/// On success, receives the peer's address, captured in the same syscall
/// as the accept (no separate <c>getpeername</c> call).
/// </param>
/// <returns>
/// <see langword="true"/> if a connection was accepted;
/// <see langword="false"/> if no connection was pending (would block / EAGAIN).
/// </returns>
/// <exception cref="InvalidOperationException">
/// The socket is not bound and listening.
/// </exception>
/// <exception cref="SocketException">
/// An error other than would-block occurred during the accept.
/// </exception>
/// <remarks>
/// <para>
/// This method is designed for high-performance accept loops driven by a
/// readiness poll mechanism (e.g., <see cref="Threading.SafePollHandle"/>).
/// It avoids the overhead of creating a full <see cref="Socket"/> wrapper
/// for the accepted connection.
/// </para>
/// <para>
/// On Linux, uses <c>accept4(SOCK_NONBLOCK | SOCK_CLOEXEC)</c> under the hood.
/// On macOS/FreeBSD, falls back to <c>accept()</c> followed by
/// <c>fcntl(O_NONBLOCK | FD_CLOEXEC)</c>.
/// </para>
/// </remarks>
[UnsupportedOSPlatform("windows")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
public bool TryAccept(
out SafeSocketHandle? acceptedHandle,
out EndPoint? remoteEndPoint);
}
namespace System.Net.Sockets;
public enum SocketOptionName
{
/// <summary>
/// Linux only. The kernel delays completing <c>accept()</c> until the
/// client sends its first data byte (e.g., TLS ClientHello). The value
/// is the timeout in seconds; 0 disables.
/// Maps to <c>TCP_DEFER_ACCEPT</c> (option 9 on <c>SOL_TCP</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Timeout behavior:</b> If the client connects but never sends data
/// within the specified timeout, the kernel silently drops the connection.
/// The client's TCP handshake completed successfully, but the server
/// application never sees the connection.
/// </para>
/// <para>
/// <b>Shutdown / draining:</b> When the listen socket is closed, any
/// connections still deferred in the kernel's accept queue are silently
/// dropped — no notification is sent to the client.
/// </para>
/// <para>
/// This option has no effect on macOS, FreeBSD, or Windows. On macOS,
/// the analogous feature is <c>SO_ACCEPTFILTER("dataready")</c>, which
/// is a different API not covered by this enum value.
/// </para>
/// </remarks>
TcpDeferAccept = 9,
}
API Usage
Worker accept loop
// listenSocket is bound, listening, Blocking == false.
void OnListenReadable()
{
// Drain all pending connections from this wakeup.
while (listenSocket.TryAccept(out var handle, out var remote))
{
// handle is non-blocking, CLOEXEC. No Socket object allocated.
var conn = connectionManager.CreateConnection(handle!, remote!);
// epoll invocation
poll.TryAdd(handle!, PollEvents.Read, PollRegistrationOptions.None,
state: conn.Index, out _);
}
// false return means EAGAIN — done for this wakeup. No exception thrown.
}
TCP_DEFER_ACCEPT on the listen socket
listenSocket.SetSocketOption(
SocketOptionLevel.Tcp,
SocketOptionName.TcpDeferAccept,
optionValue: 1); // 1-second timeout
// Now accept() only returns connections that have data ready.
// For TLS servers, this means the ClientHello is already in the buffer
// when the connection is accepted, so SSL_do_handshake() can make
// immediate progress without bouncing through WantRead.
Design details
Why not reuse Socket.Accept() with Blocking = false?
Socket.Accept() on a non-blocking socket does call the same native SystemNative_Accept → accept4 under the hood, and SocketPal.Accept correctly returns SocketError.WouldBlock on EAGAIN. However, three problems prevent reuse:
-
Throws on EAGAIN: Socket.Accept() turns WouldBlock into a thrown SocketException (via UpdateStatusAfterSocketErrorAndThrowException). In a drain loop, EAGAIN is the normal exit — not an exceptional condition. The benchmark shows this costs 569 B of allocation and 1.5x more time per occurrence.
-
Creates a full Socket object: CreateAcceptSocket allocates a Socket, copies listener state, calls InternalSetBlocking (an fcntl/ioctl syscall), and creates an EndPoint object. That's 320 B per accept. TryAccept returns just the SafeSocketHandle (64 B).
-
Returns a blocking fd: The native code uses accept4(SOCK_CLOEXEC) without SOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking via fcntl(fd, F_SETFL, ~O_NONBLOCK). A poll-driven consumer needs non-blocking fds.
These are not fixable without breaking the existing Socket.Accept() contract (blocking fd by default, returns Socket, throws on error).
Open questions
EndPoint allocation: TryAccept creates an EndPoint from the sockaddr returned by accept(). For maximum performance, a future overload could accept a Span<byte> for the raw sockaddr to avoid the EndPoint allocation. This can be added later without breaking changes.
TLDR - show me the code
Here is the vibe-coded implementation
Note: this is only the rough implementation, it will be redone carefully once API is approved
Background and motivation
High-performance servers that drive non-blocking I/O from a dedicated poll thread (epoll/kqueue) need a lightweight accept path. The current
Socket.Accept()API has several costs that are avoidable for consumers who only need the raw file descriptor — not a fullSocketobject:Problem 1: Exception-driven EAGAIN on the hot path
In a poll-driven accept loop, the worker drains all pending connections until there are none left. With
Socket.Accept()on a non-blocking socket, the "no more connections" signal is a thrownSocketException(WouldBlock)— an exception allocation + stack trace capture + throw + catch on every single wakeup.Problem 2: Full
Socketobject allocation per acceptSocket.Accept()callsCreateAcceptSocketwhich:Socketobject with managed state (address family, socket type, protocol, blocking flags, event tracking)UpdateAcceptSocketto copy listener state and configure the accepted socketInternalSetBlockingwhich issues anfcntl/ioctlsyscall to set blocking modeEndPointobject from the peer addressFor a server that only needs the raw fd (to register with a poll handle and drive non-blocking I/O), this is ~320 bytes of unnecessary allocation per connection.
Problem 3: Accepted fd is always blocking
The runtime's native
SystemNative_Acceptusesaccept4(SOCK_CLOEXEC)on Linux — withoutSOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking mode viafcntl. A consumer driving non-blocking I/O via poll must then make an additionalfcntl(O_NONBLOCK)syscall per accepted connection.Problem 4:
TCP_DEFER_ACCEPTrequires magic numbersTCP_DEFER_ACCEPTis only available viaSocket.SetRawSocketOption((int)SocketOptionLevel.Tcp, 9, …). For a TLS server this is high-value: the kernel only completes the accept once the client's first data byte (the TLS ClientHello) is in the buffer, which guaranteesSSL_do_handshakemakes immediate forward progress.Benchmark evidence
I benchmarked
Socket.Accept()against a rawaccept()P/Invoke to quantify the overhead:falseinstead of throwing is 1.5x faster with 63x less allocation (9 B vs 569 B). At 100k+ accepts/sec, the EAGAIN fires on every poll wakeup.Goals
Try-pattern accept that returnsboolinstead of throwing on EAGAIN.SafeSocketHandleinstead of a fullSocketobject.accept()syscall (no extragetpeername).TCP_DEFER_ACCEPTas a first-class enum value.API Proposal
API Usage
Worker accept loop
TCP_DEFER_ACCEPTon the listen socketDesign details
Why not reuse
Socket.Accept()withBlocking = false?Socket.Accept()on a non-blocking socket does call the same nativeSystemNative_Accept→accept4under the hood, andSocketPal.Acceptcorrectly returnsSocketError.WouldBlockon EAGAIN. However, three problems prevent reuse:Throws on EAGAIN:
Socket.Accept()turnsWouldBlockinto a thrownSocketException(viaUpdateStatusAfterSocketErrorAndThrowException). In a drain loop, EAGAIN is the normal exit — not an exceptional condition. The benchmark shows this costs 569 B of allocation and 1.5x more time per occurrence.Creates a full
Socketobject:CreateAcceptSocketallocates aSocket, copies listener state, callsInternalSetBlocking(anfcntl/ioctlsyscall), and creates anEndPointobject. That's 320 B per accept.TryAcceptreturns just theSafeSocketHandle(64 B).Returns a blocking fd: The native code uses
accept4(SOCK_CLOEXEC)withoutSOCK_NONBLOCK. On macOS, it explicitly resets the accepted fd to blocking viafcntl(fd, F_SETFL, ~O_NONBLOCK). A poll-driven consumer needs non-blocking fds.These are not fixable without breaking the existing
Socket.Accept()contract (blocking fd by default, returnsSocket, throws on error).Open questions
EndPointallocation:TryAcceptcreates anEndPointfrom the sockaddr returned byaccept(). For maximum performance, a future overload could accept aSpan<byte>for the raw sockaddr to avoid theEndPointallocation. This can be added later without breaking changes.