Multi-port listeners

One reactor can listen on more than one port and tell your handler which one a connection arrived on. That is all you need to run several entry points - plaintext and TLS, or a public port and an admin port - from the same shared-nothing reactors, without a second fleet of threads.

Configuration

ServerConfig.Tcp.Port is the primary listener; Tcp.ExtraPorts adds more. Every reactor binds each one with SO_REUSEPORT and arms a multishot accept on it.

var config = new ServerConfig
{
    ReactorCount   = Environment.ProcessorCount,  // io_uring rings/threads - one per core
    RingEntries    = 8192,                         // SQ/CQ depth per ring
    DualStack      = false,                         // true = one IPv6 socket also accepts IPv4-mapped
    RecvBufferSize = 32 * 1024,                    // bytes per shared recv buffer
    RecvSlots      = 4096,                          // shared recv buffer-ring depth
    Incremental    = null,                          // per-connection recv rings (6.12+)
    Udp            = null,                          // no raw UDP sockets
    Quic           = null,                          // no QUIC transport
    Tcp = new TcpOptions
    {
        Port             = 8080,                    // plaintext
        ExtraPorts       = [8443],                  // TLS, same reactors
        ListenBacklog    = 1024,                     // accept-queue depth per SO_REUSEPORT listener
        WriteSlabSize    = 16 * 1024,                // per-connection write buffer before overflow
        PoolMax          = 1024,                      // pooled connection objects kept per reactor
        WriteOverflow    = WriteOverflowStrategy.Grow,  // Grow = realloc one slab; Segmented = chain + SENDMSG
        ZeroCopySend     = false,                      // SEND_ZC: kernel copies less, wins on large writes
        RecvQueueEntries = 64,                         // per-connection recv completion queue depth
    },
};

With no ExtraPorts, behaviour is exactly as before - one listener, one accept. Adding ports costs nothing on the recv/send hot path; the only extra work happens at accept time.

Routing by listener

Each accept completion carries the listener's fd in its user_data. The reactor looks the port up (a tiny table - Port plus ExtraPorts) and stamps it on the connection before your handler runs:

reactor.TcpHandle = async (r, conn) =>
{
    TlsSession? tls = null;
    if (conn.ListenerPort == 8443)
        tls = await r.GetService<TlsService>().AcceptAsync(conn);   // terminate TLS on this door

    // one loop serves both doors: with a session, reads decrypt through tls.Decrypt and
    // responses go out through tls.Write; without one the same calls are conn reads and writes
};

The engine never learns what a port means. ListenerPort is just a number you branch on - terminate TLS on one, serve metrics on another, accept the PROXY protocol on a third. The same connection pool, buffer rings, and inline-resume machinery serve every door.

Why not just run two servers?

You can - reactors are shared-nothing, so nothing stops you spawning twelve reactors on :8080 and twelve more on :8443. That is perfectly valid and needs no multi-port support at all. The difference shows only when both ports are hot at the same time: two fleets means twice the threads contending for your cores, breaking the one-reactor-per-core discipline. Multi-port keeps a single set of reactors owning every listener, so a connection on either port is just another connection on that core's ring.

If your ports are never busy simultaneously (a common case - benchmark profiles run one at a time), the two approaches perform identically and the two-fleet route is simpler. Reach for multi-port when one set of cores should serve several entry points under concurrent load.

Under the hood

At startup the reactor opens one SO_REUSEPORT socket per port and arms a multishot accept on each, tagged with that listener's fd. On an accept completion it resolves the port from the fd, sets conn.ListenerPort, and re-arms the same listener. Teardown closes every listener. The connection's port is reset on recycle, so a pooled connection reused for a new accept always reflects the door it actually came in on. See the core internals for the accept path in full.