Skip to content

Ioxide engine: ioxide 0.4.187, all endpoints served, native TLS termination - #887

Draft
MDA2AV wants to merge 36 commits into
mainfrom
ioxide-0.4.161
Draft

Ioxide engine: ioxide 0.4.187, all endpoints served, native TLS termination#887
MDA2AV wants to merge 36 commits into
mainfrom
ioxide-0.4.161

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator
  • ioxide 0.1.10.4.187 (the separate ioxide.tls package is folded into core)
  • Migrates the renamed APIs: TcpConnection, TcpHandle, TcpConnectionDualPipe, ServerConfig.Tcp
  • Serves every configured endpoint (primary port + ExtraPorts) instead of the first only
  • Endpoints bound with a certificate are TLS-terminated ring-natively, one context per port; the certificate is exported as PEM in memory. Client certificate validation and SNI-only providers throw as unsupported
  • Replaces the hand-rolled TlsDuplexPipe with ioxide's TlsConnectionDualPipe (close_notify on teardown in both TLS backends)
  • Releases the connection when the TLS handshake or a custom connection factory faults

Verified with a two-endpoint host (plaintext + certificate-bound): both serve, and a strict client observes close_notify before FIN.

MDA2AV added 3 commits August 8, 2026 18:15
…nation

- ioxide 0.1.1 -> 0.4.161; the separate ioxide.tls package is folded into core
- migrate renamed APIs (TcpConnection, TcpHandle, TcpConnectionDualPipe, ServerConfig.Tcp)
- serve every configured endpoint (primary port + ExtraPorts) instead of the first only
- endpoints bound with a certificate are TLS-terminated ring-natively (per-port contexts,
  certificate exported as PEM); client cert validation and SNI report as unsupported
- replace the hand-rolled TlsDuplexPipe with ioxide's TlsConnectionDualPipe
- release the connection when the handshake or connection factory faults
… one

The eager Provide(null) in the constructor threw for SNI-only certificate
providers (SecurityTests' PickyCertificateProvider), failing host startup for
the secure-upgrade redirect cases that never actually handshake.

Certificates are now resolved per reactor in OnStart. A secure port whose
provider yields no default certificate stays advertised (so redirects derive
the https port) but its handshakes are refused with a FIN, so a client sees a
fast connection failure instead of a plaintext response on an https port.
@MDA2AV MDA2AV changed the title Ioxide engine: ioxide 0.4.161, all endpoints served, native TLS termination Ioxide engine: ioxide 0.4.165, all endpoints served, native TLS termination Aug 9, 2026
MDA2AV added 3 commits August 9, 2026 21:29
ioxide.file 0.4.167 became io_uring reads only - it hands out a descriptor
and a length, bakes no HTTP responses and caches no bytes. So Asset.Response,
Asset.ResponseLength and AssetCache.IsFresh are all gone, and this module
could not merely be re-pinned; the bump from 0.1.1 to 0.4.169 crosses that
redesign. The engine goes 0.4.165 -> 0.4.169 with it.

The baked-response branch is gone: the body is always read off the ring
through the per-reactor AssetReader pool, which this class already used for
assets too large to bake.

The freshness check moves here rather than disappearing. The package dropped
per-request statx deliberately - it trusts a snapshot's descriptors and
expects Reload() on deploy - but this module's documented behaviour is that
an edited file is served, and TestChangedFileServesUpdatedContent asserts it.
Adopting the package's model silently would have changed GenHTTP's contract
under its users, so AssetFreshness reproduces the size comparison the package
used to do. It matters beyond freshness: the handler's length becomes
Content-Length, so the body writer must agree with it or the response is
malformed - which is exactly how the built-in Files module misbehaves when a
file changes under it, serving new content at the old length.

Acceptance suite: 2044 (net11) + 1442 (net10) pass, including all 16 Ioxide
tests. Playground gains /ring and /disk over one directory to price the two
against each other; that file also carries unrelated in-progress work, so it
is left uncommitted deliberately.
/ring mounts IoxideFiles and /disk GenHTTP's built-in Files module over the
SAME directory, on the same engine, so the module is the only variable.
GENHTTP_STATIC picks the directory and neither route mounts without it.

Measured here with wrk -t8 -c64, best of two interleaved passes:

           /ring      /disk
   4 KiB   835409   1041891
  64 KiB   365531    509255

The built-in module is ahead, but part of that is work it does not do: edit a
file while it runs and it serves the new content at the old Content-Length,
truncating the response, where IoxideFiles serves it whole. That check is
what AssetFreshness restored.

One tuning note for later: IoxideAssetContent flushes every 12 KiB to stay
under the 16 KiB write slab, and at 64 KiB that costs about 19% - raising the
chunk to 64 KiB measured 433924 against 365531, content verified identical.
Left alone because a bigger chunk grows every connection's slab, which is a
memory tradeoff worth deciding rather than slipping in.
Added on a wrong assumption that 0.4.169 was unpublished. It is, so the
local feed was both unnecessary and a hazard - it pinned an absolute path
that only exists on one machine, and it shadowed the published package with
a locally built one of the same version.

Restore now resolves from nuget.org (verified via .nupkg.metadata source),
and the acceptance suite passes against the published package: 2044 on
net11, 1442 on net10.
@MDA2AV
MDA2AV marked this pull request as draft August 10, 2026 09:39
@MDA2AV MDA2AV changed the title Ioxide engine: ioxide 0.4.165, all endpoints served, native TLS termination Ioxide engine: ioxide 0.4.169, all endpoints served, native TLS termination Aug 10, 2026
MDA2AV added 19 commits August 16, 2026 00:56
The engine served HTTP/1.1 only. It now serves HTTP/2 - by ALPN on a TLS port,
by the connection preface on a plaintext one - and HTTP/3 on the endpoint bound
with enableQuic, carried by ngtcp2 and nghttp3.

Streamed in both directions on both protocols. A handler starts once the request
headers have arrived and pulls the body as it is delivered, paced by flow control
so an upload cannot outrun it; the response goes out through the protocol's own
writer, where each flush parks until the peer's window allows more. Serving a
large file therefore costs the send-retention high-water rather than the size of
the file.

HTTP/2 and HTTP/3 differ only in transport, so the bridge between them and the
handler chain is written once in Protocol/Mux and the two drivers are thin. The
server splits along the same line: hosting, TLS termination and the QUIC listener
are three partial files rather than one growing class.

Certificates come from the caller. ngtcp2 loads PEM from disk rather than taking
a certificate object, so Http3CertificatePath and Http3KeyPath name the files
directly and nothing is written. Without them the endpoint's certificate is
exported to a temporary directory created owner-only before anything is written
to it, and removed on shutdown - which is worth avoiding, and the log says so.

Mutual TLS across all three protocols, enforced where the connection is
terminated: OpenSSL for HTTP/1.1 and HTTP/2, ngtcp2 for HTTP/3. An endpoint bound
with a certificateValidator asks for a client certificate; ClientCaPath is what
the offered one is validated against. Verified per protocol - a client signed by
the configured CA is served, one offering nothing is refused, and one signed by
another CA is refused.

Engine options move to an IoxideOptions record rather than growing Create's
parameter list, and HTTP/3 keys off the enableQuic flag GenHTTP's endpoint model
already carries instead of a second switch.

Acceptance suite 1442/1442.
Http2 was a flag in the engine options while HTTP/3 was enableQuic on the
endpoint, so the two protocols were configured in different places and neither
could be given a port of its own.

Protocols are a property of an endpoint, so they are set per endpoint now:

    Protocols = IoxideProtocols.Http1,          // what a port serves by default
    ProtocolsByPort =
    {
        [8081] = IoxideProtocols.Http2,         // h2c only, no HTTP/1.1 here
        [8443] = IoxideProtocols.All,           // h1 + h2 over TCP, h3 over UDP
    }

HTTP/1.1 and HTTP/2 share the TCP socket - ALPN decides on a secure endpoint, the
connection preface on a plaintext one - and HTTP/3 is a UDP socket on the same
port number, so one port can serve all three or each can have its own. A port
that serves neither HTTP/1.1 nor HTTP/2 would otherwise accept TCP connections
and answer nothing, so HTTP/1.1 is served there instead.

The set is now honoured rather than advisory: an HTTP/2-only port closes a
connection that is not HTTP/2, where before it quietly answered HTTP/1.1.

HTTP/3 in the DEFAULT set applies only to endpoints that can serve it, since QUIC
carries TLS 1.3 and a plaintext port cannot - so Protocols = All reads as
"everything each port supports" rather than failing over the plaintext one. Named
explicitly for a port it is taken literally. Asking two endpoints for HTTP/3 is
still refused, but the message now names the ports and what to do about it.

enableQuic on Bind keeps working and still means HTTP/3 for that endpoint.

Acceptance suite 1442/1442; mutual TLS still enforced on all three protocols.
The sample showed 8081 as HTTP/2 only without saying that was a choice, so it
read as a limitation. Both protocols share a port when the port is given
Http1AndHttp2 - ALPN decides on a secure endpoint, the connection preface on a
plaintext one.
Http1AndHttp3 and Http2AndHttp3 join the named combinations, and both describe
real deployments. HTTP/1.1 with HTTP/3 skips HTTP/2 entirely while still serving
every client - one that speaks neither gets HTTP/1.1, and a browser told about
the QUIC port by Alt-Svc moves itself there. HTTP/2 with HTTP/3 drops HTTP/1.1,
which suits somewhere the clients are known, gRPC being the obvious one.

An endpoint given only HTTP/3 now opens no TCP listener at all, where before it
was quietly given HTTP/1.1 on the grounds that the socket existed anyway. It does
not have to: the transport takes a null TCP configuration, so the endpoint binds
its UDP socket and nothing else, and a server made entirely of such endpoints
opens no TCP listener either. A port left with no protocols is a configuration
error rather than something to paper over.

Verified per combination, asserting the protocol actually negotiated rather than
that a request succeeded - a client asking for HTTP/2 against a port that does not
serve it falls back to HTTP/1.1 and answers 200, which reads as success:

    Http1           h1 only,  tcp
    Http2           h2 only,  tcp
    Http3           h3 only,  udp and no tcp listener
    Http1AndHttp2   h1 h2,    tcp
    Http1AndHttp3   h1 h3,    tcp + udp
    Http2AndHttp3   h2 h3,    tcp + udp
    All             h1 h2 h3, tcp + udp

Acceptance suite 1442/1442.
The sample bound three ports and described the rest in a comment. It now binds
one per combination that can coexist:

    8080  Http1          HTTP/1.1 only
    8081  Http2          HTTP/2 only - an HTTP/1.1 client is turned away
    8082  Http1AndHttp2  both on one plaintext socket, the preface decides
    8443  the HTTP/3 case

The four combinations carrying HTTP/3 cannot run together: the transport binds
one QUIC listener per server, so a second endpoint asking for HTTP/3 is refused
at startup. They take turns on 8443 instead, chosen by GENHTTP_H3 - All (the
default), Http1AndHttp3, Http2AndHttp3, or Http3 alone, which leaves that port
with a UDP socket and no TCP listener at all.

Each mode verified by asserting the protocol negotiated rather than that a
request succeeded, and by the listener counts: under GENHTTP_H3=Http3 port 8443
reports tcp=0 udp=32 while the others are TCP only.
The sample rotated the four HTTP/3 combinations through one port with an
environment variable, so six of the seven were only ever described. All seven run
at once now, each on its own port:

    8080  Http1          8443  All
    8081  Http2          8444  Http1AndHttp3
    8082  Http1AndHttp2  8445  Http2AndHttp3
                         8446  Http3

A server binds one QUIC listener, so the four carrying HTTP/3 need a host each -
which costs nothing worth avoiding, since a host is a handler and a few reactors.
The three plaintext combinations share one host, having no QUIC listener to
contend over. Reactors are held at two apiece rather than one per core: six hosts
on one machine, and a sample is not where throughput is measured.

Verified per port by the protocol actually negotiated, not by a request
succeeding - a client asking for HTTP/2 where it is not served falls back to
HTTP/1.1 and answers 200:

    8080 h1          8443 h1 h2 h3
    8081    h2       8444 h1    h3
    8082 h1 h2       8445    h2 h3
                     8446       h3

Port 8446 reports tcp=0 udp=2: an HTTP/3-only endpoint opens no TCP listener.

Acceptance suite 1442/1442.
Seven hosts to demonstrate seven combinations was more machinery than the point
deserved. One host binds all of them except a second HTTP/3 port, so the sample
is one host again:

    8080  Http1          HTTP/1.1 only
    8081  Http2          HTTP/2 only - an HTTP/1.1 client is turned away
    8082  Http1AndHttp2  both on one socket, the preface decides
    8443  All            HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP

Http1AndHttp3, Http2AndHttp3 and Http3-alone are named in the header rather than
bound, because only one endpoint per server can carry HTTP/3 - the transport
binds a single QUIC listener - and changing what 8443 serves is how to try them.

Verified by the protocol negotiated on each port rather than by a request
succeeding, and by the listener counts.
IoxideOptions was a flat list of nine properties from three unrelated concerns.
The two that belong together are grouped now:

    options.Http3.CertificatePath        options.MutualTls.ClientCaPath
    options.Http3.KeyPath                options.MutualTls.ClientCaPem
    options.Http3.QpackDynamicTableCapacity
    options.Http3.QpackBlockedStreams

Protocols and ProtocolsByPort stay at the top, being what the engine is mostly
configured through. QUIC's certificate and HTTP/3's QPACK share a group despite
belonging to different layers, because they configure the same endpoint.

The sample gains a port serving HTTP/1.1 behind mutual TLS, which also shows that
requiring a client certificate is decided per endpoint: 8444 is bound with a
certificateValidator and demands one, while 8443 alongside it stays open. The CA
they are validated against is shared by the server, since that is what the
transport takes.

An endpoint like that cannot be tried without a client certificate, so the sample
writes a CA, one certificate signed by it and one signed by nobody into ./certs
on startup, and the header carries the curl commands. Verified: the signed client
gets 200, no certificate is refused, the impostor is refused, and 8443 answers
both HTTP/1.1 and HTTP/3 without a certificate throughout.

Every certificate there shares one validity window. Reading the clock per
certificate put the leaf a second beyond its issuer, which is refused outright -
the sample crashed on startup until they were pinned.
The comment explained that ngtcp2 loads PEM from disk without saying whose PEM,
which reads as though HTTP/3 needed a certificate of its own. It serves the one
bound to its endpoint, the same as HTTP/1.1 and HTTP/2; the paths only change
whether that certificate reaches ngtcp2 from files that already exist or from one
written out for it, because ngtcp2 has no in-memory alternative and OpenSSL does.
Http3.CertificatePath exists to hand ngtcp2 a file, since it loads PEM from disk
and has no in-memory alternative - unlike OpenSSL, which terminates the TCP
protocols and takes the PEM text directly. Nothing stopped those paths naming a
DIFFERENT certificate, and then the same port answered as one host over TCP and
another over QUIC, silently. Confirmed on one endpoint:

    h1/h2:  subject=CN = localhost
    h3:     subject: CN=DIFFERENT-h3-identity

That breaks the reason the two share a port. A browser moving from HTTP/1.1 to
HTTP/3 by an Alt-Svc header expects the alternative to present a certificate
valid for the ORIGIN (RFC 7838 3.1), so it would refuse the upgrade - or not
notice.

Compared by leaf thumbprint, so a file carrying a fuller chain than the bound
certificate is not flagged. A warning rather than a refusal: someone may be doing
it deliberately, and this is not the place to decide they cannot.

The comparison reads the PEM text rather than calling CreateFromPemFile, which
wants a private key beside the certificate and throws on the certificate-only
file this usually is - the first version of this check threw every time and
logged it at Debug, so it looked like the warning simply never fired.
The sample wrote every generated key with File.WriteAllText, which takes the
umask - so client.key and impostor.key landed world-readable. Throwaways, but a
sample is read as an example of how to do it, and the engine's own export next to
them was already 0600.

It also left the HTTP/3 certificate paths unset, so the engine exported the bound
certificate to a temporary directory. That works and is owner-only, but the copy
outlives a SIGKILL - repeated restarts leave a private key per run under /tmp.
The sample now writes its certificate to ./certs and names it, which removes the
export entirely and demonstrates the option worth using in a deployment.

    /tmp/genhttp-ioxide-*   gone, 0 export log lines
    certs/*.key             -rw-------
    certs/*.crt             -rw-rw-r--   (public, unchanged)

All four still answer: h1 1.1, h2 2, h3 3, and mutual TLS on 8444 with the signed
client.
ngtcp2 loads PEM by path, which is the C layer's contract and fine. Working
around it was not: an endpoint serving HTTP/3 without configured paths had the
bound certificate exported to a temporary directory, so the engine chose a
location and a lifetime for someone else's private key. Owner-only, deleted on
shutdown - and still there after any shutdown that skips cleanup, one directory
per run.

Http3.CertificatePath and Http3.KeyPath are required to serve HTTP/3 now.
Without them the endpoint is a configuration error, named and explained, rather
than a key appearing under /tmp:

    Port 8443 serves HTTP/3, which needs a PEM certificate and key on disk -
    ngtcp2 loads them by path. Set IoxideOptions.Http3.CertificatePath and
    Http3.KeyPath to the same certificate bound to that endpoint.

That removes the export, the owner-only temp directory, the writer that made it
and the cleanup that chased it - about sixty lines. The check that the configured
PEM is actually the endpoint's certificate stays, since naming the wrong one is
still possible and still leaves a port answering as two hosts.

The sample writes its own throwaway certificate to ./certs and names it, which is
what a deployment does with the PEM it already has.
"Mux" was jargon for the one thing HTTP/2 and HTTP/3 have in common, and it read
as though the folder were a protocol of its own.

Splitting it into Http2 and Http3 folders was the obvious alternative and does
not work: 648 of those lines are used verbatim by both protocols against 179 in
the drivers, which themselves differ by 39 lines once the protocol names are
normalised. Splitting would either duplicate the 648 or leave a third shared
folder anyway - the same shape under another name.

So the folder is Multiplexed, which says why the code is shared, and the two
drivers move up beside ConnectionDriver, the HTTP/1.1 one. Each protocol now has
its driver in Protocol/ and the request and response bridge they share sits in
Protocol/Multiplexed/. Types renamed to match.

No behaviour change: h1 1.1, h2c 2, h2 2, h3 3, mutual TLS 200, acceptance 1442.
…op the duplicated StatusLine

ConnectionDriver had grown into two unrelated jobs: deciding what protocol a TCP
connection speaks, and then serving it when the answer was HTTP/1.1. The second
half moves to Http1Driver, alongside Http2Driver and Http3Driver - so each
protocol is one file, and ConnectionDriver is only the transport plus the
ALPN/preface decision that routes to them.

The engine also carried its own copy of StatusLine, byte-identical to the one in
GenHTTP.Engine.Shared.Types. Use the shared one; the Ioxide engine gets the same
InternalsVisibleTo the acceptance tests already have.

DateHeader stays duplicated on purpose - the engine's is [ThreadStatic] so each
reactor owns its buffer, which the shared static cannot be.
The comment blocks had grown to the point of hiding the code they explained -
590 of 2540 lines, with whole paragraphs restating what the next statement says.
Trimmed to what is not derivable from reading it: the traps, the RFC references,
and the reasons a line is the way it is. Public XML docs keep their summaries.

No code changed - the diff is comment-only, verified by comparing both revisions
with every comment line stripped.
Fourteen files sat flat in Protocol/, and nothing in the listing said which
belonged to which protocol. The dependency graph already answered it: the six
response-writing files are reachable only from Http1Driver, and the six
Multiplexed ones only from Http2Driver and Http3Driver.

    Protocol/
      ConnectionDriver.cs   the TCP entry point, and the only fork between them
      Http1/                Http1Driver + its response writing and sinks
      Multiplexed/          Http2Driver, Http3Driver + what the two share

Namespaces follow the folders, so the moved types are now under .Protocol.Http1
and .Protocol.Multiplexed. Both nest inside .Protocol, which is how the drivers
still reach ConnectionDriver without importing anything.
kernelTx/kernelRx were loose booleans on Host.Create, next to the delegates,
saying neither what they switch nor where they apply. They are now grouped like
Http3 and MutualTls already were:

    options: new IoxideOptions
    {
        Tcp = new IoxideTcpOptions { TxKernelTls = true, RxKernelTls = true },
    }

Tcp is the honest group for them. kTLS offloads the record layer OpenSSL owns,
which terminates HTTP/1.1 and HTTP/2 only - HTTP/3 carries TLS 1.3 inside ngtcp2
and can never use it. The old names said "kernel" without saying kernel WHAT, and
sat where nothing marked that boundary.

Host.Create drops both parameters; nothing outside the engine passed them.
The kernel TLS knobs moved into options and the sample had no example of the
group. Shipped off: the tls ULP is absent on most machines, and a sample that
needs modprobe to serve anything is not a sample.
… needs

Measured on a box without the tls ULP: both true leaves 8443 and 8444 answering
nothing at all, with no log line - the handshake fails per connection and the
driver swallows it as a failed handshake. 8080 and 8443's HTTP/3 keep serving,
since neither goes through the OpenSSL record layer.

So both ship off, and the comment says how to check for the module first.
MDA2AV added 3 commits August 16, 2026 20:22
Tuning the runtime meant reaching for a delegate over ioxide's own record:

    configure: c => c with { ReactorCount = 2 }

which asks the caller to know a type from another package to set one number,
and is undiscoverable next to the typed groups the rest of the options use.

    Reactor = new IoxideReactorOptions { ReactorCount = 2 }

ReactorCount, RingEntries, RecvBufferSize, RecvSlots and Incremental are all
nullable and pass through untouched when unset, so ioxide keeps owning its own
defaults - restating them here would pin a stale copy the day ioxide retunes one.
The exception is ReactorCount, which the engine has always overridden: ioxide
ships a fixed 12, and one per core is the better guess.

configure stays as the escape hatch for what the group does not model, and now
runs after it so it still has the last word.
…faults

Host.Create no longer takes Func<ServerConfig, ServerConfig>. Tuning the engine
meant reaching for a record from another package through a delegate, which is
neither discoverable nor typed to what the engine actually honours - it let you
set Port and DualStack too, which the endpoint bindings then overwrote.

Everything the hook could usefully reach is now on IoxideOptions. The six
TcpOptions knobs it alone could touch move to IoxideTcpOptions alongside the
kernel TLS pair: ListenBacklog, WriteSlabSize, WriteOverflow, PoolMax,
ZeroCopySend, RecvQueueEntries. UdpOptions is not exposed - the engine wires no
raw datagram handler, and QUIC binds its own port.

Both groups carry real default values rather than nulls meaning "ask ioxide", so
BuildServerConfig is a straight assignment with no probe instance and no
coalescing, and the defaults are visible where they are read.
An extension point nothing called, nothing tested and no sample showed. It let a
host replace transport establishment wholesale - and with it the secure-port
guard and the mutual TLS wiring - which is not something a caller reaches for by
accident, or apparently at all.

Removing it takes four hops with it: Host.Create -> IoxideServerHost ->
IoxideServer field -> ConnectionDriver parameter, read in one place. What is left
is the transport selection the engine actually performs, secure or plain.

IoxideTls loses its public surface with it. StartService and AcceptAsync existed
only to help write a factory, so the class is now internal and holds the one
method that terminates TLS for the endpoints bound with a certificate.
@MDA2AV MDA2AV changed the title Ioxide engine: ioxide 0.4.169, all endpoints served, native TLS termination Ioxide engine: ioxide 0.4.187, all endpoints served, native TLS termination Aug 16, 2026
MDA2AV added 7 commits August 16, 2026 21:14
Nghttp3Options is not a duplicate of IoxideHttp3Options - it is ngtcp2's own
record, and this is the one place the caller's settings cross into it. Holding it
is deliberate: QuicHandle runs per accepted connection and neither value ever
changes, so building it there would allocate per connection.

What was wrong is where it lived. It sat in the constructor and in the main
partial, built unconditionally, while _quicEngine and _quicEndPoint sat in the
QUIC half and were set in WithQuic. Now all three are together, built when a QUIC
listener actually starts and dropped with it - so a server serving no HTTP/3
never constructs it at all.
IoxideServer.Quic.cs owned the QUIC listener while the TCP one was inlined in
StartAsync, so the two transports read as different kinds of thing when they are
the same kind. The TCP half now sits in IoxideServer.Tcp.cs behind WithTcp, the
mirror of WithQuic, and StartAsync says what it does:

    var serverConfig = WithTcp(BuildServerConfig());

    if (_quicRequested is { } quicEndPoint)
    {
        serverConfig = WithQuic(serverConfig, quicEndPoint);
    }

BuildServerConfig keeps only what is not a listener - the reactors, and DualStack,
which applies to the TCP listener and the UDP socket alike.

IoxideServer.Tls.cs becomes IoxideServer.Tcp.Tls.cs: it configures OpenSSL, which
terminates the TCP protocols only, and QUIC's TLS is ngtcp2's business in the
other file. RequiresClientCertificate is the one thing both ask, and now says so.
… inherit one

WithTcp already returned Tcp = null when no endpoint served HTTP/1.1 or HTTP/2,
and an HTTP/3-only server does come up with no TCP listener - verified: zero TCP
sockets, UDP on the bound port, 8080 untouched.

But it only worked because WithTcp runs unconditionally. ioxide's ServerConfig
defaults Tcp to a live listener on 8080, so guarding the call the way WithQuic is
guarded - the obvious thing for someone tidying the asymmetry - would leave that
default in place and bind 8080 for a protocol the server does not speak. Nothing
said so at the call site.

BuildServerConfig now sets Tcp = null explicitly. No listeners is the baseline,
WithTcp and WithQuic only ever add, and skipping either is inert rather than
wrong. Udp needs no such treatment: its Ports default to empty, which opens
nothing.
_quicRequested said in the constructor whether a QUIC listener was wanted, while
the TCP side worked it out inside WithTcp at start time. Same question, two
different shapes, and only one of them visible at the call site.

_tcpRequested now sits beside it, resolved from the same _protocols map, and
StartAsync acts on both the same way:

    if (_tcpRequested.Length > 0) serverConfig = WithTcp(serverConfig);
    if (_quicRequested is { } ep)  serverConfig = WithQuic(serverConfig, ep);

Guarding WithTcp is only safe because Tcp = null is now the baseline; before that
it would have left ioxide's default listener on 8080 in place.

_extraPorts goes with it - assigned in the constructor, never read, and reaching
for what _tcpRequested actually computes.
…uctor

The constructor did the endpoint mapping, the dual-stack check, the protocol
resolution, both listener decisions and the QUIC arity refusal - so reading how
HTTP/3 is admitted meant reading the whole thing, in a file that is not about
QUIC.

Each piece moves to where the rest of its subject already lives:

    MapEndPoints          core     mapping, and the dual-stack agreement
    ResolveTcpPorts       Tcp      which ports share the TCP listener
    ResolveQuicEndPoint   Quic     which endpoint gets QUIC, and why only one

What is left is assignment in dependency order, and the ordering constraint is
now stated rather than implied: both resolvers read _protocols, and the TCP one
reads _primary.

MapEndPoints checks mapped[0] rather than _primary, so it no longer depends on a
field being assigned first - the check moved with the mapping it validates.
Hosting/ held the server, its host and the endpoint types in one flat folder,
under a name no other engine uses. It now mirrors Engine/Internal:

    Infrastructure/
      IoxideServer.cs  .Tcp.cs  .Tcp.Tls.cs  .Quic.cs
      IoxideServerHost.cs
      Endpoints/
        EndPoint.cs
        EndPointCollection.cs

IoxideEndPoint and IoxideEndPoints lose the prefix the namespace already carries,
and become EndPoint and EndPointCollection - the names the Internal engine gives
the same two things. Both are internal now, as they are there: neither was ever
reachable except through IEndPoint and IEndPointCollection.

Namespaces follow the folders, so the server files move to
GenHTTP.Engine.Ioxide.Infrastructure and the endpoint types sit one below.
Matches Engine/Internal/Host.cs and Engine/Kestrel/Host.cs, which hold the same
entry point under the same name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant