Severity: medium (high if deployed directly internet-facing rather than behind an LB/proxy).
Status 2026-09-20 (main @ 47eba14, shipped 0.14.239): the idle/keep-alive half of this issue is DONE — see #236. TcpOptions.IdleTimeoutMs and SendTimeoutMs both default to 60s, swept on the reactor's existing ~250ms ticker, mirroring QuicSweep. It also grew a clock the original body did not ask for: a connection with a flush outstanding is not idle but SENDING, so the send clock governs it, and a peer that keeps sending while it has stopped reading refreshes the idle stamp forever and is only catchable that way. This issue is now retargeted to the half that remains.
Problem — no concurrent-connection cap in shared mode
Track grows the connection table without bound and multishot accept is always re-armed, so the only backstop in shared mode is RLIMIT_NOFILE:
// src/ioxide/Reactor/Reactor.cs:139
private void Track(int fd, TcpConnection conn)
{
if (fd >= _connections.Length)
{
int newLength = _connections.Length;
while (newLength <= fd) newLength *= 2;
Array.Resize(ref _connections, newLength);
}
_connections[fd] = conn;
}
Incremental mode has a cap and sheds at it (Reactor.Tcp.cs:231, since #92), because a gid is a hard-capped resource. Shared mode has nothing: ServerConfig.MaxConnections (ServerConfig.cs:69) lives on IncrementalOptions and is consumed only by the gid path, and TcpOptions.PoolMax caps the pool of connection OBJECTS, not live connections.
Reaching RLIMIT_NOFILE is not a graceful failure either: accept re-arms immediately on EMFILE, which pins the reactor at 100% CPU and logs millions of lines a second (#222).
The decision this needs
At the cap, either:
- shed — close on accept, which is what incremental does today and is a one-line mirror of it; or
- pause the accept re-arm until the count drops back below, which is real backpressure but needs the reactor to remember to re-arm, so it is a new piece of state rather than a branch.
Shedding is consistent with existing behaviour and cheap. Pausing is kinder to clients and to an LB's health checks. Worth deciding before writing either.
Notes
Severity: medium (high if deployed directly internet-facing rather than behind an LB/proxy).
Status 2026-09-20 (main @
47eba14, shipped 0.14.239): the idle/keep-alive half of this issue is DONE — see #236.TcpOptions.IdleTimeoutMsandSendTimeoutMsboth default to 60s, swept on the reactor's existing ~250ms ticker, mirroringQuicSweep. It also grew a clock the original body did not ask for: a connection with a flush outstanding is not idle but SENDING, so the send clock governs it, and a peer that keeps sending while it has stopped reading refreshes the idle stamp forever and is only catchable that way. This issue is now retargeted to the half that remains.Problem — no concurrent-connection cap in shared mode
Trackgrows the connection table without bound and multishot accept is always re-armed, so the only backstop in shared mode isRLIMIT_NOFILE:Incremental mode has a cap and sheds at it (
Reactor.Tcp.cs:231, since #92), because a gid is a hard-capped resource. Shared mode has nothing:ServerConfig.MaxConnections(ServerConfig.cs:69) lives onIncrementalOptionsand is consumed only by the gid path, andTcpOptions.PoolMaxcaps the pool of connection OBJECTS, not live connections.Reaching
RLIMIT_NOFILEis not a graceful failure either: accept re-arms immediately onEMFILE, which pins the reactor at 100% CPU and logs millions of lines a second (#222).The decision this needs
At the cap, either:
Shedding is consistent with existing behaviour and cheap. Pausing is kinder to clients and to an LB's health checks. Worth deciding before writing either.
Notes
RLIMIT_NOFILEis per process, not per reactor, so a per-reactor cap has to be sized against the fleet rather than the limit.