ServerConfig has four nullable option groups, and only one of them arrives already switched on:
public IncrementalOptions? Incremental { get; init; } // null
public TcpOptions? Tcp { get; init; } = new(); // TcpOptions.Port = 8080
public UdpOptions? Udp { get; init; } = new(); // UdpOptions.Ports is empty
public QuicOptions? Quic { get; init; } // null
Quic and Incremental default to null. Udp does default to new(), but UdpOptions.Ports is empty by default and its own doc says so - "Empty (default) means no UDP sockets are opened" - so the instance is inert and exists only to carry tunables.
TcpOptions is the exception. Its Port defaults to 8080, so the default instance is not inert, it is a live listener. That makes "no TCP" an opt-out rather than an absence, and it is the only transport where forgetting a line binds a port.
Because the default is a property initializer it runs before any object initializer, so a config built as new ServerConfig { ... } keeps the live listener for every property it does not explicitly set. A QUIC-only server has to write Tcp = null and mean it. ServerConfig's own summary already documents the workaround, which is a fair sign the shape is inverted:
Defaults to an enabled listener on TcpOptions.Port; set it to null to open no TCP listener at all, which is what a QUIC-only server wants - otherwise the reactor binds 8080 regardless and inbound connections reach a Reactor.TcpHandle that was never set.
Not hypothetical: the GenHTTP ioxide engine hit exactly this. It builds a ServerConfig and then adds listeners conditionally from the endpoint bindings (WithTcp only when some endpoint asked for HTTP/1 or HTTP/2, WithQuic only when one asked for HTTP/3). An HTTP/3-only server therefore never overwrites Tcp, inherited the default, and bound 8080 for a protocol it does not serve. It needed a commit of its own to start from no listeners, and carries a permanent Tcp = null with a comment explaining why. Worth noting the failure there is worse than the doc warns: that engine sets TcpHandle unconditionally, so a connection to the stray 8080 does not find an unset handler, it reaches a live one that looks the listener port up in a per-port endpoint table and throws KeyNotFoundException.
Two ways out, depending on what the default is meant to buy:
- Default
Tcp to null, so config starts from no listeners and a listener is something you add - consistent with Quic.
- Keep
Tcp = new() but make TcpOptions inert by default the way UdpOptions already is: a ports collection that is empty unless filled. Same end state, and it makes the two listener-owning transports the same shape.
The cost of (1) is the zero-config case: new ServerConfig() no longer gives a working server on 8080, and every consumer relying on the implicit default needs Tcp = new(). That is the actual tradeoff to decide, and it is why this is filed rather than just changed.
ServerConfighas four nullable option groups, and only one of them arrives already switched on:QuicandIncrementaldefault to null.Udpdoes default tonew(), butUdpOptions.Portsis empty by default and its own doc says so - "Empty (default) means no UDP sockets are opened" - so the instance is inert and exists only to carry tunables.TcpOptionsis the exception. ItsPortdefaults to 8080, so the default instance is not inert, it is a live listener. That makes "no TCP" an opt-out rather than an absence, and it is the only transport where forgetting a line binds a port.Because the default is a property initializer it runs before any object initializer, so a config built as
new ServerConfig { ... }keeps the live listener for every property it does not explicitly set. A QUIC-only server has to writeTcp = nulland mean it.ServerConfig's own summary already documents the workaround, which is a fair sign the shape is inverted:Not hypothetical: the GenHTTP ioxide engine hit exactly this. It builds a
ServerConfigand then adds listeners conditionally from the endpoint bindings (WithTcponly when some endpoint asked for HTTP/1 or HTTP/2,WithQuiconly when one asked for HTTP/3). An HTTP/3-only server therefore never overwritesTcp, inherited the default, and bound 8080 for a protocol it does not serve. It needed a commit of its own to start from no listeners, and carries a permanentTcp = nullwith a comment explaining why. Worth noting the failure there is worse than the doc warns: that engine setsTcpHandleunconditionally, so a connection to the stray 8080 does not find an unset handler, it reaches a live one that looks the listener port up in a per-port endpoint table and throwsKeyNotFoundException.Two ways out, depending on what the default is meant to buy:
Tcpto null, so config starts from no listeners and a listener is something you add - consistent withQuic.Tcp = new()but makeTcpOptionsinert by default the wayUdpOptionsalready is: a ports collection that is empty unless filled. Same end state, and it makes the two listener-owning transports the same shape.The cost of (1) is the zero-config case:
new ServerConfig()no longer gives a working server on 8080, and every consumer relying on the implicit default needsTcp = new(). That is the actual tradeoff to decide, and it is why this is filed rather than just changed.