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.Port is the primary listener; ExtraPorts adds more.
Every reactor binds each one with SO_REUSEPORT and arms a multishot accept on it.
var config = new ServerConfig
{
Port = 8080, // plaintext
ExtraPorts = [8443], // TLS, same reactors
ReactorCount = Environment.ProcessorCount,
};
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.Handle = async (r, conn) =>
{
if (conn.ListenerPort == 8443)
await r.GetService<TlsService>().AcceptAsync(conn); // terminate TLS on this door
// from here both ports look identical - plaintext in, plaintext out
};
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 Reactor for the accept path in full.