What
QuicOptions.Port is a single ushort, so a server binds exactly one QUIC listener. Everything else about a server is multi-port — TcpOptions has ExtraPorts, UdpOptions.Ports is already an array — but HTTP/3 is limited to one endpoint.
Why it matters
It surfaced building the GenHTTP ioxide engine, where protocols are configured per port. Every combination works on one host except a second HTTP/3 port:
8080 Http1 ok
8081 Http2 ok
8082 Http1AndHttp2 ok
8443 All ok <- the only endpoint that can carry HTTP/3
8444 Http1AndHttp3 needs a second host
8445 Http2AndHttp3 needs a third
8446 Http3 needs a fourth
A second endpoint asking for HTTP/3 has to be refused at startup, so the engine throws and the sample either drops those combinations or runs a host per combination. More practically, it means one process cannot serve HTTP/3 for two sites on different ports, or run a public and an mTLS-protected QUIC endpoint side by side, without a server each.
Why it looks tractable
Most of the machinery is already multi-port:
UdpOptions.Ports is an array, and Reactor.Udp binds every one of them per reactor via SO_REUSEPORT.
- The QUIC port is appended to that same list — "The QUIC port is a UDP socket like any other; only completion routing differs" (
Reactor.Udp.cs).
UdpDatagram already carries LocalPort, so a datagram knows which endpoint it arrived on.
- The connection factory is invoked at exactly one place, in
QuicDispatchDatagram:
QuicConnection? freshQuicConnection = _quicOptions?.ConnectionFactory?.Invoke(this, in datagram, in dcid);
So the shape is roughly: QuicOptions carries several ports each with its own factory, OpenUdpSockets binds them all, and dispatch selects the factory by datagram.LocalPort. Existing CID routing is unaffected — locally-minted CIDs are already unique per engine, and short-header packets keep resolving through _quicConns without needing the port at all.
Design questions
- One factory per port, or one for all of them? Per port is what makes this worth doing: endpoints differ by certificate, ALPN and client-CA, and each of those lives in a
QuicEngine. A single shared factory would only let you vary behaviour by inspecting the datagram, which is the wrong seam.
LocalCidLength. Currently one value on QuicOptions, and the demux slices exactly that many bytes from short-header packets before it knows which endpoint the packet belongs to. So either every endpoint keeps the same CID length (simplest, and they all default to 8), or the demux has to resolve the port first. The first is probably right, with validation rather than silent breakage if they disagree.
- Backwards compatibility.
Port + ConnectionFactory can stay as the single-endpoint spelling and populate the map, so nothing existing changes.
Not urgent
There is a workaround — one server per HTTP/3 endpoint, which costs a set of reactors each and works fine. This is about making the QUIC side as multi-port as the rest of the transport already is.
What
QuicOptions.Portis a singleushort, so a server binds exactly one QUIC listener. Everything else about a server is multi-port —TcpOptionshasExtraPorts,UdpOptions.Portsis already an array — but HTTP/3 is limited to one endpoint.Why it matters
It surfaced building the GenHTTP ioxide engine, where protocols are configured per port. Every combination works on one host except a second HTTP/3 port:
A second endpoint asking for HTTP/3 has to be refused at startup, so the engine throws and the sample either drops those combinations or runs a host per combination. More practically, it means one process cannot serve HTTP/3 for two sites on different ports, or run a public and an mTLS-protected QUIC endpoint side by side, without a server each.
Why it looks tractable
Most of the machinery is already multi-port:
UdpOptions.Portsis an array, andReactor.Udpbinds every one of them per reactor via SO_REUSEPORT.Reactor.Udp.cs).UdpDatagramalready carriesLocalPort, so a datagram knows which endpoint it arrived on.QuicDispatchDatagram:So the shape is roughly:
QuicOptionscarries several ports each with its own factory,OpenUdpSocketsbinds them all, and dispatch selects the factory bydatagram.LocalPort. Existing CID routing is unaffected — locally-minted CIDs are already unique per engine, and short-header packets keep resolving through_quicConnswithout needing the port at all.Design questions
QuicEngine. A single shared factory would only let you vary behaviour by inspecting the datagram, which is the wrong seam.LocalCidLength. Currently one value onQuicOptions, and the demux slices exactly that many bytes from short-header packets before it knows which endpoint the packet belongs to. So either every endpoint keeps the same CID length (simplest, and they all default to 8), or the demux has to resolve the port first. The first is probably right, with validation rather than silent breakage if they disagree.Port+ConnectionFactorycan stay as the single-endpoint spelling and populate the map, so nothing existing changes.Not urgent
There is a workaround — one server per HTTP/3 endpoint, which costs a set of reactors each and works fine. This is about making the QUIC side as multi-port as the rest of the transport already is.