Skip to content

hardening: no concurrent-connection cap in shared mode (accept is unbounded) #97

Description

@MDA2AV

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions