Proposal: `Socket.TryAccept` and `TcpDeferAccept`
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
## TLDR - show me the code
[Here is the vibe-coded implementation](DeagleGross/runtime/pull/2)
_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](https://github.com/DeagleGross/Baraholka/blob/main/Benchmarks/AcceptBenchmark.cs) `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
```csharp
namespace System.Net.Sockets;
public partial class Socket
{
///
/// Attempts to accept a pending connection without blocking.
///
///
/// 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.
///
///
/// On success, receives the peer's address, captured in the same syscall
/// as the accept (no separate getpeername call).
///
///
/// if a connection was accepted;
/// if no connection was pending (would block / EAGAIN).
///
///
/// The socket is not bound and listening.
///
///
/// An error other than would-block occurred during the accept.
///
///
///
/// This method is designed for high-performance accept loops driven by a
/// readiness poll mechanism (e.g., ).
/// It avoids the overhead of creating a full wrapper
/// for the accepted connection.
///
///
/// On Linux, uses accept4(SOCK_NONBLOCK | SOCK_CLOEXEC) under the hood.
/// On macOS/FreeBSD, falls back to accept() followed by
/// fcntl(O_NONBLOCK | FD_CLOEXEC).
///
///
[UnsupportedOSPlatform("windows")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
public bool TryAccept(
out SafeSocketHandle? acceptedHandle,
out EndPoint? remoteEndPoint);
}
```
```csharp
namespace System.Net.Sockets;
public enum SocketOptionName
{
///
/// Linux only. The kernel delays completing accept() until the
/// client sends its first data byte (e.g., TLS ClientHello). The value
/// is the timeout in seconds; 0 disables.
/// Maps to TCP_DEFER_ACCEPT (option 9 on SOL_TCP).
///
///
///
/// Timeout behavior: 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.
///
///
/// Shutdown / draining: 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.
///
///
/// This option has no effect on macOS, FreeBSD, or Windows. On macOS,
/// the analogous feature is SO_ACCEPTFILTER("dataready"), which
/// is a different API not covered by this enum value.
///
///
TcpDeferAccept = 9,
}
```
## API Usage
### Worker accept loop
```csharp
// 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
```csharp
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:
1. **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.
2. **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).
3. **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
1. **`EndPoint` allocation**: `TryAccept` creates an `EndPoint` from the sockaddr returned by `accept()`. For maximum performance, a future overload could accept a `Span` for the raw sockaddr to avoid the `EndPoint` allocation. This can be added later without breaking changes.
Contributor guide
Assessment
This issue has not been assessed yet.