diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs
new file mode 100644
index 000000000..196f41bae
--- /dev/null
+++ b/Engine/Ioxide/EngineOptions.cs
@@ -0,0 +1,152 @@
+using ioxide;
+
+namespace GenHTTP.Engine.Ioxide;
+
+///
+/// Protocol and TLS options for the ioxide engine. The port, its certificate and whether it asks
+/// for a client certificate stay on Bind; which protocols it then serves lives here.
+///
+public sealed record EngineOptions
+{
+ internal static readonly EngineOptions Default = new();
+
+ ///
+ /// The protocols every endpoint serves, unless says otherwise.
+ /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here.
+ ///
+ public Protocols Protocols { get; init; } = Protocols.Http1;
+
+ ///
+ /// Protocols for one port, overriding - bind the ports, then name the
+ /// ones that differ: { [8081] = Protocols.Http2, [8443] = Protocols.All }.
+ ///
+ public Dictionary ProtocolsByPort { get; init; } = [];
+
+ /// The reactors: how many, and the io_uring machinery each one owns.
+ public ReactorOptions Reactor { get; init; } = new();
+
+ /// The TCP endpoints: how TLS is terminated for HTTP/1.1 and HTTP/2.
+ public TcpTransportOptions Tcp { get; init; } = new();
+
+ /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK.
+ public Http3Options Http3 { get; init; } = new();
+}
+
+///
+/// The reactors. Each runs on its own thread, owns an io_uring ring and the connections accepted
+/// on it, and shares nothing with the others - so these are per reactor, not per server, and the
+/// memory they describe is multiplied by .
+///
+public sealed record ReactorOptions
+{
+ ///
+ /// How many reactors to run. One per core suits a server with the machine to itself; anything
+ /// sharing the box - a colocated load generator, a database, sibling containers - wants fewer,
+ /// or the reactors and everything else fight for the same cores.
+ ///
+ public int ReactorCount { get; init; } = Environment.ProcessorCount;
+
+ /// io_uring submission and completion queue depth, per reactor.
+ public uint RingEntries { get; init; } = 8192;
+
+ ///
+ /// Bytes per buffer in the shared recv ring. Larger reads more per completion and wastes more
+ /// per idle connection. Unused when is set.
+ ///
+ public int RecvBufferSize { get; init; } = 32 * 1024;
+
+ ///
+ /// Buffers in the shared recv ring. Running out costs a retry, not a lost byte. Unused when
+ /// is set.
+ ///
+ public int RecvSlots { get; init; } = 4096;
+
+ ///
+ /// Give each connection its own small buffer ring (IOU_PBUF_RING_INC, kernel 6.12+) instead of
+ /// drawing from the shared one. Setting this IS enabling the mode, and the two shared-ring
+ /// knobs above then go unused. Reserves MaxConnections x RecvSlots x RecvBufferSize per
+ /// reactor up front, so it trades memory for not sharing a ring between connections.
+ ///
+ public IncrementalOptions? Incremental { get; init; }
+}
+
+///
+/// The TCP endpoints, where OpenSSL terminates TLS for HTTP/1.1 and HTTP/2. HTTP/3 is not
+/// configured here: QUIC carries its own TLS 1.3 inside ngtcp2, so none of this reaches it.
+///
+public sealed record TcpTransportOptions
+{
+ ///
+ /// Produce TLS records in the kernel (kTLS) on the send path instead of in OpenSSL, which
+ /// still drives the handshake. Requires the Linux tls module and TLS 1.3.
+ ///
+ public bool TxKernelTls { get; init; }
+
+ ///
+ /// Decrypt TLS records in the kernel on the receive path. Experimental, and requires
+ /// - RX shares the ULP handoff TX installs, so ioxide refuses RX
+ /// alone. The peer must send no post-handshake control records.
+ ///
+ public bool RxKernelTls { get; init; }
+
+ ///
+ /// listen() backlog per reactor - the accept queue that absorbs a burst of connections. Every
+ /// reactor binds its own SO_REUSEPORT listener, so the server absorbs this many per reactor.
+ ///
+ public int ListenBacklog { get; init; } = 1024;
+
+ ///
+ /// Bytes of write buffer per connection. A response that fits leaves in one send; a larger one
+ /// is handled by .
+ ///
+ public int WriteSlabSize { get; init; } = 16 * 1024;
+
+ ///
+ /// What a response larger than does: grow the slab and keep one
+ /// send, or chain pooled slabs and flush them with one vectored sendmsg instead of reallocating.
+ ///
+ public WriteOverflowStrategy WriteOverflow { get; init; } = WriteOverflowStrategy.Grow;
+
+ /// Connections kept pooled per reactor for reuse rather than freed.
+ public int PoolMax { get; init; } = 1024;
+
+ ///
+ /// Send responses with zero-copy (IORING_OP_SEND_ZC) instead of a normal send. Trades the
+ /// in-kernel payload copy for page pinning and a second completion per send, so it only pays
+ /// for large responses. kTLS connections always fall back to a plain send.
+ ///
+ public bool ZeroCopySend { get; init; }
+
+ ///
+ /// Depth of the per-connection recv queue, a power of two. Overflow closes the connection.
+ ///
+ public int RecvQueueEntries { get; init; } = 64;
+}
+
+///
+/// The HTTP/3 endpoint. Only consulted when a port serves .
+///
+public sealed record Http3Options
+{
+ ///
+ /// PEM certificate chain for the HTTP/3 listener, as a path. Required to serve HTTP/3, and
+ /// should be the certificate bound to the endpoint rather than a second one - it is named
+ /// separately only because ngtcp2 loads PEM by path, and the engine writes none for you.
+ ///
+ public string? CertificatePath { get; init; }
+
+ /// PEM private key for the HTTP/3 listener. Pairs with .
+ public string? KeyPath { get; init; }
+
+ ///
+ /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. 0 keeps every header literal
+ /// against the static table, which costs bytes but can never stall a stream on a table update.
+ ///
+ public long QpackDynamicTableCapacity { get; init; }
+
+ ///
+ /// How many HTTP/3 streams may wait on a QPACK table insertion. Only meaningful alongside a
+ /// nonzero , and the price paid for one.
+ ///
+ public long QpackBlockedStreams { get; init; }
+}
diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
index 596800623..958b17f7d 100644
--- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
+++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
@@ -2,7 +2,7 @@
- net11.0
+ net11.0;net10.0
true
@@ -10,10 +10,14 @@
-
-
+
+
+
+
+
-
+
diff --git a/Engine/Ioxide/Host.cs b/Engine/Ioxide/Host.cs
new file mode 100644
index 000000000..3f845d9e5
--- /dev/null
+++ b/Engine/Ioxide/Host.cs
@@ -0,0 +1,25 @@
+using GenHTTP.Api.Infrastructure;
+
+using GenHTTP.Engine.Ioxide.Infrastructure;
+using ioxide;
+
+namespace GenHTTP.Engine.Ioxide;
+
+///
+/// Entry point to host an application using the ioxide io_uring engine.
+///
+public static class Host
+{
+
+ ///
+ /// Registers ring-native services on each reactor's own thread before it serves, e.g.
+ /// r => PgPool.Start(r, pgOptions). Handlers resolve them via IoxideReactor.Current.
+ ///
+ ///
+ /// Everything the engine is tuned by: the reactors, the TCP transport, protocols per port,
+ /// the HTTP/3 certificate, mutual TLS and QPACK. Ports and certificates stay on Bind.
+ ///
+ public static IServerHost Create(Action? onReactorStart = null, EngineOptions? options = null)
+ => new ServerHost(onReactorStart, options);
+
+}
diff --git a/Engine/Ioxide/Hosting/IoxideEndPoint.cs b/Engine/Ioxide/Hosting/IoxideEndPoint.cs
deleted file mode 100644
index 41bc717c9..000000000
--- a/Engine/Ioxide/Hosting/IoxideEndPoint.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System.Collections;
-using System.Net;
-
-using GenHTTP.Api.Infrastructure;
-
-namespace GenHTTP.Engine.Ioxide.Hosting;
-
-public sealed class IoxideEndPoint(IPAddress? address, ushort port, bool dualStack, bool secure) : IEndPoint
-{
- public IPAddress? Address => address;
-
- public ushort Port => port;
-
- public bool DualStack => dualStack;
-
- public bool Secure => secure;
-
- public void Dispose() { }
-}
-
-internal sealed class IoxideEndPoints(IReadOnlyList eps) : IEndPointCollection
-{
- public IEndPoint this[int i] => eps[i];
-
- public int Count => eps.Count;
-
- public IEnumerator GetEnumerator() => eps.GetEnumerator();
-
- IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
-}
diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs
deleted file mode 100644
index 1ca861cd9..000000000
--- a/Engine/Ioxide/Hosting/IoxideServer.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-using System.Diagnostics;
-using System.IO.Pipelines;
-
-using GenHTTP.Api.Content;
-using GenHTTP.Api.Infrastructure;
-
-using GenHTTP.Engine.Ioxide.Protocol;
-using GenHTTP.Engine.Shared.Infrastructure;
-using GenHTTP.Engine.Shared.Types;
-
-using ioxide;
-using Microsoft.Extensions.Logging;
-
-namespace GenHTTP.Engine.Ioxide.Hosting;
-
-public sealed class IoxideServer : IServer
-{
- private readonly ServerConfiguration _config;
-
- private readonly IoxideEndPoint _endPoint;
-
- private readonly Func? _configure;
-
- private readonly Action? _onReactorStart;
-
- private readonly Func>? _connectionFactory;
-
- private readonly ILogger _logger;
-
- private Thread[]? _threads;
-
- private Reactor[]? _reactors;
-
- public string Version { get; } = typeof(IoxideServer).Assembly.GetName().Version?.ToString() ?? "0.1";
-
- public bool Running { get; private set; }
-
- public bool Development => _config.DevelopmentMode;
-
- public IPropertyBag Properties { get; } = new PropertyBag();
-
- public ILoggerFactory Logging => _config.Logging;
-
- public IEndPointCollection EndPoints { get; }
-
- public IHandler Handler { get; }
-
- internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null)
- {
- _config = config;
- Handler = handler;
- _configure = configure;
- _onReactorStart = onReactorStart;
- _connectionFactory = connectionFactory;
-
- _logger = config.Logging.CreateLogger();
-
- var ep = config.EndPoints.First(); // spike: still only SERVE the first endpoint
-
- _endPoint = new IoxideEndPoint(ep.Address, ep.Port, ep.DualStack, ep.Security != null);
-
- // Advertise every configured endpoint (including secure ones) in IServer.EndPoints so concerns
- // that inspect it — e.g. the secure-upgrade redirect, which derives the HTTPS port from a secure
- // endpoint — behave correctly, even though the reactor currently only binds the first endpoint.
- EndPoints = new IoxideEndPoints(
- config.EndPoints.Select(e => (IEndPoint)new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)).ToList()
- );
-
- var endPointCount = config.EndPoints.Count();
-
- if (endPointCount > 1)
- {
- _logger.LogWarning("Configured with {Count} endpoints, but the ioxide engine only serves the first one ({Address}:{Port})", endPointCount, _endPoint.Address, _endPoint.Port);
- }
- }
-
- public async ValueTask StartAsync()
- {
- await PrepareHandlerAsync();
-
- Running = true;
-
- var cfg = new ServerConfig { ReactorCount = Environment.ProcessorCount };
-
- if (_configure is not null)
- {
- cfg = _configure(cfg);
- }
-
- // The endpoint binding (.Port()/.Bind()) determines the listen port and dual-stack mode, so
- // they always win over whatever the configuration hook may have set.
- cfg = cfg with
- {
- Port = _endPoint.Port,
- DualStack = _endPoint.DualStack
- };
-
- _threads = new Thread[cfg.ReactorCount];
- _reactors = new Reactor[cfg.ReactorCount];
-
- // Reactors bind their listeners on their own threads (inside Reactor.Run), so StartAsync must
- // not return until they're actually accepting - otherwise a client that connects immediately
- // (as the test host does) races the bind and gets "connection refused". OnStart fires right
- // after the listener is bound, so each reactor signals once it's up.
- var listening = new CountdownEvent(cfg.ReactorCount);
-
- for (var i = 0; i < _threads.Length; i++)
- {
- var reactor = new Reactor(i, cfg)
- {
- // Runs once on the reactor's own thread before it serves: bind the reactor into the
- // [ThreadStatic] seam so handler code can resolve per-reactor services, then let the
- // host register those services (e.g. PgPool.Start(r, ...)) on this reactor's ring.
- OnStart = r =>
- {
- IoxideReactor.Bind(r);
- _onReactorStart?.Invoke(r);
- listening.Signal();
- },
- Handle = (_, c) => ConnectionDriver.HandleAsync(this, _endPoint, c, _connectionFactory),
- };
-
- _reactors[i] = reactor;
-
- _threads[i] = new Thread(reactor.Run)
- {
- Name = $"ioxide-genhttp-{i}",
- IsBackground = true,
- };
-
- _threads[i].Start();
- }
-
- // Block (off the caller) until every reactor reports listening, so the server is accepting
- // before StartAsync returns. The timeout is a safety net for a reactor that fails to bind -
- // log and continue rather than hang the host forever.
- if (await Task.Run(() => listening.Wait(TimeSpan.FromSeconds(10))))
- {
- listening.Dispose();
- }
- else
- {
- _logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet.");
- }
-
- _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoint.Address, _endPoint.Port, DescribeSettings());
- }
-
- private async ValueTask PrepareHandlerAsync()
- {
- try
- {
- var start = Stopwatch.GetTimestamp();
-
- await Handler.PrepareAsync(this);
-
- var elapsed = Stopwatch.GetElapsedTime(start);
-
- _logger.LogInformation("Prepared handlers in {ElapsedMs:0.##} ms", elapsed.TotalMilliseconds);
- }
- catch (Exception e)
- {
- _logger.LogCritical(e, "Failed to prepare the handler chain");
- }
- }
-
- private string DescribeSettings() => $"ioxide, {(_endPoint.Secure ? "HTTPS" : "HTTP")}, DualStack: {_endPoint.DualStack}, Reactors: {_reactors?.Length ?? 0}";
-
- public async ValueTask DisposeAsync()
- {
- Running = false;
-
- var reactors = _reactors;
- var threads = _threads;
-
- _reactors = null;
- _threads = null;
-
- if (reactors is null || threads is null)
- {
- return;
- }
-
- _logger.LogInformation("Stopping {Count} ioxide reactors ...", reactors.Length);
-
- // Each reactor owns an io_uring ring on its own thread. Signal every reactor to stop, then join
- // the threads: each loop exits and Run() disposes its ring on the reactor thread (mandatory for a
- // single-issuer / DEFER_TASKRUN ring). Without this the rings leak for the lifetime of the process,
- // so a long-lived host - or a test run that spins up hundreds of hosts - eventually exhausts
- // io_uring_setup and crashes. Joining runs off the caller so DisposeAsync stays non-blocking.
- await Task.Run(() =>
- {
- foreach (var reactor in reactors)
- {
- reactor.Stop();
- }
-
- foreach (var thread in threads)
- {
- thread.Join(TimeSpan.FromSeconds(5));
- }
- });
-
- _logger.LogInformation("Stopped ioxide reactors");
- }
-
-}
diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs
deleted file mode 100644
index 62335033c..000000000
--- a/Engine/Ioxide/Hosting/IoxideServerHost.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.IO.Pipelines;
-
-using GenHTTP.Api.Content;
-using GenHTTP.Api.Infrastructure;
-using GenHTTP.Engine.Shared.Hosting;
-using GenHTTP.Engine.Shared.Infrastructure;
-
-using ioxide;
-
-namespace GenHTTP.Engine.Ioxide.Hosting;
-
-public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost
-{
-
- protected override IServer Build(ServerConfiguration config, IHandler handler)
- => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory);
-
-}
diff --git a/Engine/Ioxide/IMutualTlsValidator.cs b/Engine/Ioxide/IMutualTlsValidator.cs
new file mode 100644
index 000000000..d28886d27
--- /dev/null
+++ b/Engine/Ioxide/IMutualTlsValidator.cs
@@ -0,0 +1,27 @@
+using GenHTTP.Api.Infrastructure;
+
+namespace GenHTTP.Engine.Ioxide;
+
+///
+/// A client-certificate validator that also names what the offered chain is validated against.
+/// Pass one to Bind and that endpoint does mutual TLS against these anchors.
+///
+///
+/// is called with a chain that has already been built, which
+/// suits an engine validating in managed code. ioxide validates in OpenSSL, and in ngtcp2 for
+/// HTTP/3, both of which need the trust anchors before the handshake begins - early enough that a
+/// bad chain is refused before any request exists, and Validate is never reached. So the
+/// anchors travel with the validator, on the binding that wanted them.
+///
+public interface IMutualTlsValidator : ICertificateValidator
+{
+ ///
+ /// PEM bundle of trust anchors, as a path. Its subject names are also sent in the
+ /// CertificateRequest, so a client holding several certificates can pick the right one;
+ /// sends no such hint.
+ ///
+ string? ClientCaPath => null;
+
+ /// The trust anchors as PEM text - the in-memory alternative to .
+ string? ClientCaPem => null;
+}
diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs
new file mode 100644
index 000000000..db9c30789
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs
@@ -0,0 +1,29 @@
+using System.Net;
+
+using GenHTTP.Api.Infrastructure;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+///
+/// One bound endpoint, as the engine sees it: where it listens and what it serves. Whether it is
+/// secured is the subclass - or .
+///
+///
+/// Nothing to dispose: the listener belongs to the reactors, which bind it themselves and tear it
+/// down with their rings.
+///
+internal abstract class EndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols) : IEndPoint
+{
+ public IPAddress? Address => address;
+
+ public ushort Port => port;
+
+ public bool DualStack => dualStack;
+
+ /// What this endpoint serves, resolved once from the options and its own binding.
+ public Protocols Protocols => protocols;
+
+ public abstract bool Secure { get; }
+
+ public void Dispose() { }
+}
diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs
new file mode 100644
index 000000000..526602aac
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs
@@ -0,0 +1,19 @@
+using System.Collections;
+
+using GenHTTP.Api.Infrastructure;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+///
+/// The endpoints a server is listening on, as reports them.
+///
+internal sealed class EndPointCollection(IReadOnlyList endPoints) : IEndPointCollection
+{
+ public IEndPoint this[int index] => endPoints[index];
+
+ public int Count => endPoints.Count;
+
+ public IEnumerator GetEnumerator() => endPoints.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs
new file mode 100644
index 000000000..f1dc28fe3
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs
@@ -0,0 +1,13 @@
+using System.Net;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+///
+/// An endpoint bound without a certificate. Cleartext HTTP/1.1 or HTTP/2 only - HTTP/3 is refused
+/// on one of these, since QUIC carries TLS 1.3 and has no cleartext mode.
+///
+internal sealed class InsecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols)
+ : EndPoint(address, port, dualStack, protocols)
+{
+ public override bool Secure => false;
+}
diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs
new file mode 100644
index 000000000..b326ba722
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs
@@ -0,0 +1,95 @@
+using System.Net;
+
+using GenHTTP.Engine.Shared.Infrastructure;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+///
+/// An endpoint bound with a certificate: what TLS termination here takes, in one place. The
+/// handshake itself belongs to the transport - OpenSSL for HTTP/1.1 and HTTP/2, ngtcp2 for HTTP/3 -
+/// so this carries the settings rather than performing it.
+///
+///
+/// Everything here comes off the binding: the certificate from Bind, and the mutual-TLS
+/// settings from the validator passed alongside it. A validator asks for a client certificate; an
+/// also names what the chain is validated against. Read once
+/// here rather than at each use, so the transports ask the endpoint and nothing else.
+///
+///
+/// What each transport accepts, and in what form. The two do not match, because OpenSSL is handed
+/// the certificate as data while ngtcp2 loads it by path:
+///
+///
+///
+/// TCP (HTTP/1.1, HTTP/2) HTTP/3 (QUIC)
+/// server certificate X509Certificate2, from Bind PEM file, Http3.CertificatePath
+/// server key exported from that certificate PEM file, Http3.KeyPath
+/// issuer chain built here, root omitted whatever that PEM file holds
+/// client trust anchors ClientCaPath or ClientCaPem ClientCaPath or ClientCaPem
+/// demand a client cert ICertificateValidator.RequireCertificate, on both
+///
+///
+///
+/// Three consequences of that table. The HTTP/3 certificate is named a SECOND time, on
+/// EngineOptions.Http3, because ngtcp2 takes paths and the engine will not write a private
+/// key out on anyone's behalf - it should be the same certificate the endpoint is bound with, and
+/// the QUIC half warns when the thumbprints disagree.
+///
+///
+///
+/// The issuer chain is assembled for TCP only. ICertificateProvider yields a single
+/// certificate, which cannot carry intermediates, so they are recovered from the machine store and
+/// sent leaf-first with the root left off; HTTP/3 sends whatever the configured PEM file contains.
+///
+///
+///
+/// Client anchors are the one setting that reads the same on both, in either form. That took
+/// ioxide 0.5.192: before it, ngtcp2 took only a path, so reached OpenSSL
+/// and was dropped on the way to QUIC - an endpoint serving both transports validated clients over
+/// TCP and let them through unvalidated over HTTP/3, saying nothing about it.
+///
+///
+internal sealed class SecureEndPoint : EndPoint
+{
+ internal SecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols,
+ SecurityConfiguration security)
+ : base(address, port, dualStack, protocols)
+ {
+ Security = security;
+
+ RequireClientCertificate = security.CertificateValidator?.RequireCertificate == true;
+
+ if (security.CertificateValidator is IMutualTlsValidator mutualTls)
+ {
+ ClientCaPath = mutualTls.ClientCaPath;
+ ClientCaPem = mutualTls.ClientCaPem;
+ }
+ }
+
+ public override bool Secure => true;
+
+ /// How this endpoint is secured: its certificate provider, protocols and validator.
+ public SecurityConfiguration Security { get; }
+
+ ///
+ /// PEM bundle of trust anchors that client certificates are validated against, as a path. Its
+ /// subject names are also sent in the CertificateRequest, so a client holding several
+ /// certificates can pick the right one; sends no such hint.
+ ///
+ public string? ClientCaPath { get; }
+
+ /// The trust anchors as PEM text - the in-memory alternative to .
+ public string? ClientCaPem { get; }
+
+ ///
+ /// Whether a client offering no certificate is refused here. False still asks for one and
+ /// validates what arrives, since the CertificateRequest goes out either way.
+ ///
+ public bool RequireClientCertificate { get; }
+
+ ///
+ /// Whether this endpoint asks for a client certificate at all - which is exactly whether the
+ /// binding named a validator, since everything mutual TLS needs now arrives on one.
+ ///
+ public bool MutualTls => Security.CertificateValidator is not null;
+}
diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs
new file mode 100644
index 000000000..fcbcb6f58
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs
@@ -0,0 +1,168 @@
+using System.Security.Cryptography.X509Certificates;
+
+using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+using ioxide;
+using ioxide.nghttp3;
+using ioxide.ngtcp2;
+
+using Microsoft.Extensions.Logging;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure;
+
+///
+/// The QUIC listener that carries HTTP/3, alongside the TCP one.
+///
+public sealed partial class Server
+{
+ private QuicEngine? _quicEngine;
+
+ /// The endpoint serving HTTP/3, or null. Resolved in the constructor.
+ private readonly EndPoint? _quicEndPoint;
+
+ private Nghttp3Options? _h3Options;
+
+ ///
+ /// The endpoint that wants a QUIC listener, or none. At most one: the transport binds a single
+ /// UDP port for the whole server, so several endpoints asking for HTTP/3 would each want their
+ /// own and only the first could have it - refused here rather than silently honouring one.
+ ///
+ private EndPoint? ResolveQuicEndPoint()
+ {
+ var quicEndPoints = _endPoints.Where(e => e.Protocols.HasFlag(Protocols.Http3)).ToList();
+
+ if (quicEndPoints.Count > 1)
+ {
+ throw new NotSupportedException(
+ $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quicEndPoints.Select(e => e.Port))}. "
+ + "Name the protocols per port (ProtocolsByPort) so only one of them serves HTTP/3.");
+ }
+
+ return quicEndPoints.Count == 1 ? quicEndPoints[0] : null;
+ }
+
+ ///
+ /// Adds the QUIC listener for the endpoint serving HTTP/3. Needs a secure endpoint - QUIC
+ /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser
+ /// assumes when an Alt-Svc advertisement names none of its own.
+ ///
+ private ServerConfig WithQuic(ServerConfig serverConfig)
+ {
+ var endPoint = _quicEndPoint!;
+
+ if (endPoint is not SecureEndPoint quicEndPoint)
+ {
+ _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", endPoint.Port);
+ return serverConfig;
+ }
+
+ if (!TryResolveQuicCertificate(quicEndPoint.Security, quicEndPoint.Port, out var certPath, out var keyPath))
+ {
+ return serverConfig;
+ }
+
+ _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"],
+ clientCaPemPath: quicEndPoint.ClientCaPath,
+ requireClientCertificate: quicEndPoint.RequireClientCertificate,
+ clientCaPem: quicEndPoint.ClientCaPem);
+
+ // Built once here, not in the QuicHandle below - that runs per accepted connection, and
+ // these two never change. Nghttp3Options is ngtcp2's own record; Http3Options is
+ // what the caller sets, and this is where the two meet.
+ _h3Options = new Nghttp3Options
+ {
+ QpackDynamicTableCapacity = _engineOptions.Http3.QpackDynamicTableCapacity,
+ QpackBlockedStreams = _engineOptions.Http3.QpackBlockedStreams,
+ };
+
+ return serverConfig with
+ {
+ Udp = serverConfig.Udp ?? new UdpOptions(),
+ Quic = new QuicOptions
+ {
+ Port = quicEndPoint.Port,
+ ConnectionFactory = _quicEngine.CreateFactory(),
+ },
+ };
+ }
+
+ ///
+ /// The PEM files ngtcp2 loads. Configured, or HTTP/3 does not start.
+ ///
+ ///
+ /// ngtcp2 takes paths rather than a certificate object, so this honours the C layer's contract
+ /// rather than working around it: the engine will not write a private key out on your behalf,
+ /// since one written to a temporary directory outlives any shutdown that skips cleanup.
+ ///
+ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfiguration security, ushort port,
+ out string certPath, out string keyPath)
+ {
+ certPath = keyPath = string.Empty;
+
+ if (_engineOptions.Http3.CertificatePath is not { } configuredCert || _engineOptions.Http3.KeyPath is not { } configuredKey)
+ {
+ throw new InvalidOperationException(
+ $"Port {port} serves HTTP/3, which needs a PEM certificate and key on disk - ngtcp2 loads them by path. "
+ + "Set EngineOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint.");
+ }
+
+ if (!File.Exists(configuredCert) || !File.Exists(configuredKey))
+ {
+ _logger.LogError("The configured HTTP/3 certificate or key does not exist ({Certificate}, {Key}); no listener was started.", configuredCert, configuredKey);
+ return false;
+ }
+
+ WarnIfNotTheBoundCertificate(configuredCert, security, port);
+
+ certPath = configuredCert;
+ keyPath = configuredKey;
+ return true;
+ }
+
+ ///
+ /// Warns when the configured PEM is not the certificate bound to this endpoint, which would
+ /// answer as one host over TCP and another over QUIC. A browser following an Alt-Svc header
+ /// expects the alternative to be valid for the ORIGIN (RFC 7838 3.1) and would refuse it.
+ ///
+ ///
+ /// Compared by leaf thumbprint, so a file carrying a fuller chain is not flagged. A warning
+ /// rather than a refusal: someone may be serving a different certificate deliberately.
+ ///
+ private void WarnIfNotTheBoundCertificate(string configuredCert, Shared.Infrastructure.SecurityConfiguration security, ushort port)
+ {
+ if (security.CertificateProvider.Provide(null) is not { } bound)
+ {
+ return;
+ }
+
+ try
+ {
+ // From the PEM text, not CreateFromPemFile - that one wants a private key alongside
+ // the certificate and throws on a certificate-only file, which is what this usually is.
+ using var configured = X509Certificate2.CreateFromPem(File.ReadAllText(configuredCert));
+
+ if (!string.Equals(configured.Thumbprint, bound.Thumbprint, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogWarning(
+ "The HTTP/3 certificate configured for port {Port} ({ConfiguredSubject}) is not the one bound to that endpoint ({BoundSubject}). "
+ + "The port will answer as one host over TCP and another over QUIC, and a browser following an Alt-Svc advertisement expects them to match.",
+ port, configured.Subject, bound.Subject);
+ }
+ }
+ catch (Exception e)
+ {
+ // Only the comparison failed; ngtcp2 will report a certificate it cannot load itself.
+ _logger.LogDebug(e, "Could not compare the configured HTTP/3 certificate at {Path} with the bound one", configuredCert);
+ }
+ }
+
+ ///
+ /// Drops the QUIC engine. Nothing was written for it, so nothing is cleaned up.
+ ///
+ private void DisposeQuic()
+ {
+ _quicEngine?.Dispose();
+ _quicEngine = null;
+ _h3Options = null;
+ }
+}
diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs
new file mode 100644
index 000000000..8ad69aae7
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs
@@ -0,0 +1,148 @@
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+using ioxide.tls;
+
+using Microsoft.Extensions.Logging;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure;
+
+///
+/// TLS termination for the TCP endpoints - HTTP/1.1 and HTTP/2 both ride this.
+///
+public sealed partial class Server
+{
+ ///
+ /// The TLS options for every secure port whose provider yields a default (no-SNI) certificate.
+ /// A provider selecting by SNI (unsupported here) returns none, leaving the port advertised as
+ /// secure - so secure-upgrade redirects still work - but refusing its handshakes.
+ ///
+ private IEnumerable> ResolveTls()
+ {
+ foreach (var endPoint in SecureEndPoints)
+ {
+ var port = endPoint.Port;
+
+ if (endPoint.Security.CertificateProvider.Provide(null) is not { } certificate)
+ {
+ _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port);
+ continue;
+ }
+
+ yield return new(port, new TlsOptions
+ {
+ CertificatePem = ExportChainPem(certificate, port),
+ KeyPem = ExportKeyPem(certificate),
+
+ // Server preference, most preferred first. A client offering neither continues
+ // without an ALPN extension at all.
+ Alpn = endPoint.Protocols.HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"],
+
+ ClientCaPath = endPoint.ClientCaPath,
+ ClientCaPem = endPoint.ClientCaPem,
+ RequireClientCertificate = endPoint.RequireClientCertificate,
+
+ KernelTx = _engineOptions.Tcp.TxKernelTls,
+ KernelRx = _engineOptions.Tcp.RxKernelTls
+ });
+ }
+ }
+
+ ///
+ /// Whether any endpoint asks for a client certificate at all.
+ ///
+ private bool MutualTlsConfigured => SecureEndPoints.Any(e => e.MutualTls);
+
+ /// The endpoints bound with a certificate - the ones TLS applies to.
+ private IEnumerable SecureEndPoints => _endPoints.OfType();
+
+ ///
+ /// The certificate and the intermediates a client needs to reach a root it trusts, leaf first.
+ ///
+ ///
+ /// ICertificateProvider hands over one certificate, but anything issued by a real
+ /// CA is signed by an intermediate, and a client that does not already hold that intermediate
+ /// cannot build a path to its root - so a server sends them (RFC 8446 4.4.2). The Internal
+ /// engine gets this for free from SslStream, which assembles the chain itself; this
+ /// engine terminates TLS on its own, so it assembles it here.
+ ///
+ /// The root is left out deliberately: a client that does not already trust it will not start
+ /// because we sent it, and it is bytes on every handshake.
+ ///
+ /// Certificate downloads are off. Fetching a missing intermediate over AIA would put a network
+ /// call on the startup path, where a slow or unreachable host is a hung server rather than a
+ /// slow one - the intermediate is expected in the machine store beside the certificate.
+ ///
+ private string ExportChainPem(X509Certificate2 certificate, ushort port)
+ {
+ using var chain = new X509Chain();
+
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
+ chain.ChainPolicy.DisableCertificateDownloads = true;
+
+ // Built for its elements, not its verdict: a privately issued or self-signed certificate
+ // does not validate against this machine's roots and still carries the chain to send.
+ chain.Build(certificate);
+
+ var links = chain.ChainElements;
+
+ if (links.Count <= 1)
+ {
+ // Self-signed is the ordinary case here - leaf and root at once, nothing to add. Any
+ // other certificate arriving alone means its issuer was not found, and the handshake
+ // will fail for clients that cannot supply the gap themselves.
+ if (!IsSelfIssued(certificate))
+ {
+ _logger.LogWarning(
+ "No issuer chain found for the certificate on port {Port} ({Subject}), so only the leaf will be sent. "
+ + "Clients without its intermediates cached will refuse the handshake; install them in the machine store.",
+ port, certificate.Subject);
+ }
+
+ return certificate.ExportCertificatePem();
+ }
+
+ var pem = new StringBuilder();
+
+ for (var i = 0; i < links.Count; i++)
+ {
+ var link = links[i].Certificate;
+
+ if (i == links.Count - 1 && IsSelfIssued(link))
+ {
+ break;
+ }
+
+ pem.AppendLine(link.ExportCertificatePem());
+ }
+
+ return pem.ToString();
+ }
+
+ /// Whether a certificate is its own issuer, which is what makes it a root.
+ private static bool IsSelfIssued(X509Certificate2 certificate)
+ => certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData);
+
+ private static string ExportKeyPem(X509Certificate2 certificate)
+ => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem()
+ ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem()
+ ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key.");
+}
+
+///
+/// The TLS service each secure port owns on this reactor, keyed by the port a connection arrived
+/// on. One per port, since ALPN and the client CA differ per endpoint; filled from
+/// when the reactor starts, and read per connection by the
+/// connection driver.
+///
+internal sealed class TlsRegistry
+{
+ private readonly Dictionary _byPort = [];
+
+ public void Add(ushort port, TlsService service) => _byPort[port] = service;
+
+ public bool TryFor(ushort port, out TlsService service) => _byPort.TryGetValue(port, out service!);
+}
diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs
new file mode 100644
index 000000000..f988e6e48
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs
@@ -0,0 +1,52 @@
+using ioxide;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure;
+
+///
+/// The TCP listener that carries HTTP/1.1 and HTTP/2, alongside the QUIC one.
+///
+public sealed partial class Server
+{
+ private readonly ushort[] _tcpPorts;
+
+ ///
+ /// The ports that want a TCP listener: those serving HTTP/1.1 or HTTP/2, primary first - it
+ /// becomes ioxide's TcpOptions.Port and the rest its ExtraPorts. Empty means this
+ /// server serves HTTP/3 only and opens no TCP listener at all.
+ ///
+ ///
+ /// One listener bound to several ports rather than one per port: a connection carries the port
+ /// it arrived on, which is what lets a single handler serve endpoints speaking different
+ /// protocols.
+ ///
+ private ushort[] ResolveTcpPorts()
+ => _endPoints.Where(e => (e.Protocols & Protocols.Http1AndHttp2) != 0)
+ .Select(e => e.Port)
+ .OrderBy(p => p == _endPoints[0].Port ? 0 : 1)
+ .ToArray();
+
+ ///
+ /// Adds the TCP listener for the ports resolved into _tcpPorts. Only called when
+ /// there are any - an HTTP/3-only server gets a UDP socket and no TCP listener at all.
+ ///
+ ///
+ /// The tuning comes from the options; the ports come from the bindings and are not the
+ /// caller's to set.
+ ///
+ private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with
+ {
+ // ioxide's, not ours - the engine's own TcpTransportOptions is what feeds it below.
+ Tcp = new TcpOptions
+ {
+ Port = _tcpPorts[0],
+ ExtraPorts = _tcpPorts[1..],
+
+ ListenBacklog = _engineOptions.Tcp.ListenBacklog,
+ WriteSlabSize = _engineOptions.Tcp.WriteSlabSize,
+ WriteOverflow = _engineOptions.Tcp.WriteOverflow,
+ PoolMax = _engineOptions.Tcp.PoolMax,
+ ZeroCopySend = _engineOptions.Tcp.ZeroCopySend,
+ RecvQueueEntries = _engineOptions.Tcp.RecvQueueEntries,
+ },
+ };
+}
diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs
new file mode 100644
index 000000000..831b8d7d5
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/Server.cs
@@ -0,0 +1,398 @@
+using System.Diagnostics;
+using System.IO.Pipelines;
+
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Infrastructure;
+
+using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints;
+
+using GenHTTP.Engine.Ioxide.Protocol;
+using GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+using GenHTTP.Engine.Shared.Infrastructure;
+using GenHTTP.Engine.Shared.Types;
+
+using ioxide;
+using ioxide.tls;
+
+using Microsoft.Extensions.Logging;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure;
+
+///
+/// Hosts an application on ioxide's io_uring reactors: one per core, each owning a ring and its
+/// connections on its own thread. Protocols are per port; TLS termination and the QUIC listener
+/// live in the other halves of this class.
+///
+public sealed partial class Server : IServer
+{
+ private readonly ServerConfiguration _serverConfiguration;
+
+ /// Every endpoint, in the order it was bound. The first one names the server.
+ private readonly EndPoint[] _endPoints;
+
+ ///
+ /// One mode for the whole server, since that is all ioxide takes - see ,
+ /// which refuses endpoints that disagree.
+ ///
+ private readonly bool _dualStack;
+
+ private readonly Action? _onReactorStart;
+
+ private readonly EngineOptions _engineOptions;
+
+ private readonly ILogger _logger;
+
+ private Thread[]? _threads;
+
+ private Reactor[]? _reactors;
+
+#region Get-/Setters
+
+ public string Version { get; } = typeof(Server).Assembly.GetName().Version?.ToString() ?? "0.1";
+
+ public bool Running { get; private set; }
+
+ public bool Development => _serverConfiguration.DevelopmentMode;
+
+ public IPropertyBag Properties { get; } = new PropertyBag();
+
+ public ILoggerFactory Logging => _serverConfiguration.Logging;
+
+ public IEndPointCollection EndPoints { get; }
+
+ public IHandler Handler { get; }
+
+#endregion
+
+#region Constructors
+
+ internal Server(
+ ServerConfiguration serverConfiguration,
+ IHandler handler,
+ Action? onReactorStart = null,
+ EngineOptions? options = null)
+ {
+ _serverConfiguration = serverConfiguration;
+ Handler = handler;
+ _onReactorStart = onReactorStart;
+ _engineOptions = options ?? EngineOptions.Default;
+
+ _logger = serverConfiguration.Logging.CreateLogger();
+
+ _endPoints = MapEndPoints(serverConfiguration, _engineOptions);
+ _dualStack = _endPoints[0].DualStack;
+
+ // Which endpoints want which listener, settled here so StartAsync only has to act on it.
+ // Both read _endPoints, which each endpoint's own protocols now come with.
+ _tcpPorts = ResolveTcpPorts();
+ _quicEndPoint = ResolveQuicEndPoint();
+
+ EndPoints = new EndPointCollection(_endPoints);
+ }
+
+#endregion
+
+ public async ValueTask StartAsync()
+ {
+ await PrepareHandlerAsync();
+
+ Running = true;
+
+ var serverConfig = BuildServerConfig();
+
+ if (_tcpPorts.Length > 0)
+ {
+ serverConfig = WithTcp(serverConfig);
+ }
+
+ if (_quicEndPoint is not null)
+ {
+ serverConfig = WithQuic(serverConfig);
+ }
+
+ _threads = new Thread[serverConfig.ReactorCount];
+ _reactors = new Reactor[serverConfig.ReactorCount];
+
+ // Reactors bind their listeners on their own threads, so StartAsync must not return before
+ // they accept - a client connecting immediately (as the test host does) would otherwise
+ // race the bind and get "connection refused".
+ var listening = new CountdownEvent(serverConfig.ReactorCount);
+
+ for (var i = 0; i < _threads.Length; i++)
+ {
+ var reactor = new Reactor(i, serverConfig)
+ {
+ OnStart = r =>
+ {
+ IoxideReactor.Bind(r);
+
+ if (SecureEndPoints.Any())
+ {
+ var registry = new TlsRegistry();
+
+ foreach (var (port, options) in ResolveTls())
+ {
+ registry.Add(port, TlsService.Start(r, options, register: false));
+ }
+
+ r.AddService(registry);
+ }
+
+ _onReactorStart?.Invoke(r);
+
+ listening.Signal();
+ },
+ TcpHandle = (_, tcpConnection) =>
+ {
+ var endPoint = EndPointFor(tcpConnection.ListenerPort);
+
+ return ConnectionDriver.HandleAsync(this, endPoint, tcpConnection, endPoint.Protocols);
+ },
+
+ QuicHandle = _quicEngine is not null
+ ? (_, quicConnection) => Http3Driver.RunAsync(this, _quicEndPoint!, quicConnection, _h3Options!)
+ : null
+ };
+
+ _reactors[i] = reactor;
+
+ _threads[i] = new Thread(reactor.Run)
+ {
+ Name = $"ioxide-genhttp-{i}",
+ IsBackground = true,
+ };
+
+ _threads[i].Start();
+ }
+
+ // Off the caller. The timeout is a safety net for a reactor that fails to bind: log and
+ // continue rather than hang the host forever.
+ if (await Task.Run(() => listening.Wait(TimeSpan.FromSeconds(10))))
+ {
+ listening.Dispose();
+ }
+ else
+ {
+ _logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet.");
+ }
+
+ if (_logger.IsEnabled(LogLevel.Information))
+ {
+ _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoints[0].Address, _endPoints[0].Port, DescribeSettings());
+ }
+ }
+
+ ///
+ /// GenHTTP's endpoints as the engine's own, resolving what each one serves, and where the two
+ /// things the engine cannot express are refused: a port bound twice, and endpoints asking for
+ /// different dual-stack modes.
+ ///
+ ///
+ /// GenHTTP takes dual-stack per endpoint, on Bind; ioxide takes one flag for the whole
+ /// server, deciding whether listeners are IPv6 on :: with V6ONLY off or plain IPv4 on 0.0.0.0.
+ /// The engine can only honour one, and honours the first endpoint's - so endpoints that
+ /// disagree are refused here rather than silently served the first one's mode.
+ ///
+ private static EndPoint[] MapEndPoints(ServerConfiguration config, EngineOptions options)
+ {
+ var mapped = config.EndPoints.Select(e => Map(e, options)).ToArray();
+
+ // A connection carries only the port it arrived on, so that port has to name one endpoint.
+ // Checked explicitly because nothing else keys them by port any more.
+ if (mapped.GroupBy(e => e.Port).FirstOrDefault(g => g.Count() > 1) is { } duplicate)
+ {
+ throw new NotSupportedException(
+ $"Port {duplicate.Key} was bound {duplicate.Count()} times. A connection is matched to its endpoint "
+ + "by the port it arrived on, so each port carries one endpoint.");
+ }
+
+ // ioxide validates a client chain in OpenSSL and ngtcp2, which need the anchors before the
+ // handshake. ICertificateValidator.RequireCertificate defaults to TRUE, so a validator that
+ // names none is the easy mistake to make - and it would otherwise surface as an exception
+ // out of TlsService, on a reactor thread, halfway through starting the server.
+ foreach (var endPoint in mapped.OfType())
+ {
+ if (endPoint.RequireClientCertificate && endPoint.ClientCaPath is null && endPoint.ClientCaPem is null)
+ {
+ throw new NotSupportedException(
+ $"Port {endPoint.Port} requires a client certificate but names nothing to validate one against. "
+ + $"Pass an {nameof(IMutualTlsValidator)} to Bind with ClientCaPath or ClientCaPem set, or leave "
+ + "RequireCertificate false to let the connection in and decide in the handler.");
+ }
+ }
+
+ var dualStack = mapped[0].DualStack;
+
+ // No disagreement between DualStack capability among endpoints is supported as of today
+ // To support that user can simply have two different IServerHost instances running.
+ if (mapped.Any(e => e.DualStack != dualStack))
+ {
+ var disagreeing = mapped.Where(e => e.DualStack != dualStack).Select(e => e.Port);
+
+ throw new NotSupportedException(
+ $"The ioxide engine binds every endpoint with one dual-stack mode, taken from the first one bound "
+ + $"(port {mapped[0].Port}, DualStack = {dualStack}). These ask for the other: {string.Join(", ", disagreeing)}.");
+ }
+
+ return mapped;
+ }
+
+ ///
+ /// The engine-wide configuration, straight from the options. The listeners are added on top by
+ /// WithTcp and WithQuic, which take their ports from the endpoint bindings.
+ ///
+ private ServerConfig BuildServerConfig() => new()
+ {
+ ReactorCount = _engineOptions.Reactor.ReactorCount,
+ RingEntries = _engineOptions.Reactor.RingEntries,
+ RecvBufferSize = _engineOptions.Reactor.RecvBufferSize,
+ RecvSlots = _engineOptions.Reactor.RecvSlots,
+ Incremental = _engineOptions.Reactor.Incremental,
+
+ // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket
+ // alike, which is why the engine binds every endpoint with one mode.
+ DualStack = _dualStack,
+
+ // No listeners yet - WithTcp and WithQuic add the ones the bindings ask for. Explicitly
+ // null because ioxide's own default is a live listener on 8080, which an HTTP/3-only server
+ // would otherwise inherit and bind for a protocol it does not serve.
+ Tcp = null,
+ };
+
+ private async ValueTask PrepareHandlerAsync()
+ {
+ try
+ {
+ var start = Stopwatch.GetTimestamp();
+
+ await Handler.PrepareAsync(this);
+
+ var elapsed = Stopwatch.GetElapsedTime(start);
+
+ _logger.LogInformation("Prepared handlers in {ElapsedMs:0.##} ms", elapsed.TotalMilliseconds);
+ }
+ catch (Exception e)
+ {
+ _logger.LogCritical(e, "Failed to prepare the handler chain");
+ }
+ }
+
+ ///
+ /// One binding as the engine's own endpoint. A certificate makes it a ,
+ /// which is what carries the TLS settings; without one it is cleartext and has none to carry.
+ ///
+ private static EndPoint Map(EndPointConfiguration endPoint, EngineOptions options)
+ {
+ var protocols = ResolveProtocols(options, endPoint);
+
+ return endPoint.Security is { } security
+ ? new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security)
+ : new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols);
+ }
+
+ ///
+ /// What one endpoint serves: the default, its port's override, and its own enableQuic flag.
+ ///
+ private static Protocols ResolveProtocols(EngineOptions options, EndPointConfiguration endPoint)
+ {
+ var named = options.ProtocolsByPort.TryGetValue(endPoint.Port, out var configured);
+
+ var protocols = named ? configured : options.Protocols;
+
+ // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything
+ // each port supports" rather than an error about the plaintext one. Named per port it is
+ // taken literally, and refused where the port has no certificate.
+ if (!named && protocols.HasFlag(Protocols.Http3) && endPoint.Security is null)
+ {
+ protocols &= ~Protocols.Http3;
+ }
+
+ if (endPoint.EnableQuic)
+ {
+ protocols |= Protocols.Http3;
+ }
+
+ if (protocols == 0)
+ {
+ throw new NotSupportedException($"Port {endPoint.Port} was given no protocols to serve.");
+ }
+
+ return protocols;
+ }
+
+ ///
+ /// The endpoint a connection arrived on, matched by the port its listener bound. A scan rather
+ /// than a table: a server binds a handful of endpoints, so the array is the whole truth and
+ /// there is no second copy of it to keep in step.
+ ///
+ private EndPoint EndPointFor(ushort port)
+ {
+ foreach (var endPoint in _endPoints)
+ {
+ if (endPoint.Port == port)
+ {
+ return endPoint;
+ }
+ }
+
+ throw new InvalidOperationException($"A connection arrived on port {port}, which no endpoint is bound to.");
+ }
+
+ private string DescribeSettings()
+ {
+ var protocols = string.Join(" ", _endPoints.OrderBy(e => e.Port).Select(e => $"{e.Port}:{Describe(e.Protocols)}"));
+
+ return $"ioxide, {protocols}, TLS on {SecureEndPoints.Count()}"
+ + (MutualTlsConfigured ? ", mTLS" : string.Empty)
+ + $", DualStack: {_dualStack}, Reactors: {_reactors?.Length ?? 0}";
+ }
+
+ private static string Describe(Protocols protocols)
+ {
+ var names = new List(3);
+
+ if (protocols.HasFlag(Protocols.Http1)) names.Add("h1");
+ if (protocols.HasFlag(Protocols.Http2)) names.Add("h2");
+ if (protocols.HasFlag(Protocols.Http3)) names.Add("h3");
+
+ return string.Join("+", names);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Running = false;
+
+ var reactors = _reactors;
+ var threads = _threads;
+
+ _reactors = null;
+ _threads = null;
+
+ if (reactors is null || threads is null)
+ {
+ return;
+ }
+
+ _logger.LogInformation("Stopping {Count} ioxide reactors ...", reactors.Length);
+
+ // Stop, then join: each loop exits and Run() disposes its ring on the reactor thread, which
+ // a single-issuer / DEFER_TASKRUN ring requires. Skipping it leaks a ring per host until
+ // io_uring_setup runs out. Off the caller, so DisposeAsync stays non-blocking.
+ await Task.Run(() =>
+ {
+ foreach (var reactor in reactors)
+ {
+ reactor.Stop();
+ }
+
+ foreach (var thread in threads)
+ {
+ thread.Join(TimeSpan.FromSeconds(5));
+ }
+ });
+
+ DisposeQuic();
+
+ _logger.LogInformation("Stopped ioxide reactors");
+ }
+}
diff --git a/Engine/Ioxide/Infrastructure/ServerHost.cs b/Engine/Ioxide/Infrastructure/ServerHost.cs
new file mode 100644
index 000000000..987c156c7
--- /dev/null
+++ b/Engine/Ioxide/Infrastructure/ServerHost.cs
@@ -0,0 +1,25 @@
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Engine.Shared.Infrastructure;
+
+using ioxide;
+
+namespace GenHTTP.Engine.Ioxide.Infrastructure;
+
+///
+/// Builds the engine's once the host has collected the bindings, the handler
+/// and the concerns.
+///
+///
+/// The base class is GenHTTP's, named the same and qualified here because this one shadows it
+/// inside this namespace.
+///
+public sealed class ServerHost(
+ Action? onReactorStart = null,
+ EngineOptions? options = null) : Shared.Hosting.ServerHost
+{
+
+ protected override IServer Build(ServerConfiguration config, IHandler handler)
+ => new Server(config, handler, onReactorStart, options);
+
+}
diff --git a/Engine/Ioxide/IoxideReactor.cs b/Engine/Ioxide/IoxideReactor.cs
index ce961677a..16caed1a5 100644
--- a/Engine/Ioxide/IoxideReactor.cs
+++ b/Engine/Ioxide/IoxideReactor.cs
@@ -3,18 +3,10 @@
namespace GenHTTP.Engine.Ioxide;
///
-/// Per-reactor access seam. Each ioxide reactor runs on its own thread; the engine binds the
-/// current into a [ThreadStatic] slot when that thread starts, so
-/// handler code (which runs on the reactor thread) can resolve per-reactor, ring-native services
-/// that were registered through the onReactorStart host hook — for example
-/// IoxideReactor.Current.GetService<PgPool>().
+/// Per-reactor access seam: resolves the ring-native services registered through the
+/// onReactorStart host hook, e.g. IoxideReactor.Current.GetService<PgPool>().
+/// Only valid on a reactor thread, since it relies on continuations resuming inline on one.
///
-///
-/// Only valid on a reactor thread (i.e. inside request handling). It relies on awaited
-/// continuations resuming inline on the same reactor thread — the affinity the
-/// ConnectionDriver thread-hop diagnostic verifies. Under a work-stealing scheduler the
-/// slot could point at the wrong reactor.
-///
public static class IoxideReactor
{
[ThreadStatic]
diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs
index cc6aa8fd3..efad5248a 100644
--- a/Engine/Ioxide/Protocol/ConnectionDriver.cs
+++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs
@@ -2,47 +2,32 @@
using System.IO.Pipelines;
using System.Net;
using System.Runtime.InteropServices;
-using GenHTTP.Api.Infrastructure;
-using GenHTTP.Api.Protocol;
-using GenHTTP.Engine.Shared.Types;
+using GenHTTP.Api.Infrastructure;
-using Glyph11.Parser;
-using Glyph11.Parser.UltraHardened;
-using Glyph11.Pico;
-using Glyph11.Protocol;
+using GenHTTP.Engine.Ioxide.Infrastructure;
+using GenHTTP.Engine.Ioxide.Protocol.Http1;
+using GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
-using Microsoft.Extensions.Logging;
+using ioxide.tls;
-using Connection = GenHTTP.Api.Protocol.Connection;
-using IoConnection = ioxide.Connection;
+using IoConnection = ioxide.TcpConnection;
namespace GenHTTP.Engine.Ioxide.Protocol;
///
-/// Drives GenHTTP's parse -> handle -> respond loop over an ioxide connection. One instance of this
-/// runs per accepted connection on the reactor thread; awaited continuations resume inline on that
-/// same thread.
+/// Takes an accepted TCP connection, establishes its transport, and hands it to the protocol it
+/// turns out to be speaking - or , decided by
+/// ALPN on a secure endpoint and by the HTTP/2 connection preface on a plaintext one. HTTP/3 never
+/// reaches this: QUIC is a UDP listener and goes straight to .
///
internal static partial class ConnectionDriver
{
- private static readonly ParserLimits Limits = ParserLimits.Default;
-
- // Benchmark switch: set GENHTTP_IOXIDE_PARSER=pico to parse request headers with
- // Glyph11.Pico (picohttpparser, native) instead of the hardened managed Glyph11 parser.
- // Both fill the same BinaryRequest, so the rest of the pipeline is identical — only the
- // header-parsing implementation differs. NOTE: the Pico path does picohttpparser-level
- // validation only (no path/token/smuggling hardening); it's for benchmarking, not for
- // hardening untrusted traffic.
- private static readonly bool UsePico =
- string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase);
-
-
///
- /// Half-close (SHUT_WR = 1) the socket's write side to send FIN. ioxide's refcounted teardown does not
- /// FIN a server-initiated close by itself (the reactor's active recv keeps a reference), so an
- /// EOF-delimited response (connection-close / upgrade) would otherwise hang the client. The read side
- /// stays open so the client's own close is still observed and the reactor reclaims the connection.
+ /// Half-closes the write side to send FIN. ioxide's refcounted teardown does not FIN a
+ /// server-initiated close by itself (the reactor's active recv keeps a reference), so an
+ /// EOF-delimited response would hang the client. The read side stays open, so the client's own
+ /// close is still observed and the reactor reclaims the connection.
///
private const int ShutWrite = 1;
@@ -52,231 +37,170 @@ internal static partial class ConnectionDriver
[LibraryImport("libc", EntryPoint = "getpeername")]
private static partial int GetPeerName(int sockfd, [Out] byte[] addr, ref int addrlen);
- private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray();
-
- // The connected client's remote address, read once per connection straight from the socket fd
- // (ioxide exposes the fd but not the peer address). Mirrors the Internal engine's
- // Socket.RemoteEndPoint.Address - returned as-is (IPv4-mapped IPv6 on a dual-stack listener), which
- // IPAddress.IsLoopback and the rest of the pipeline already handle.
- private static IPAddress? GetPeerAddress(int fd)
- {
- var addr = new byte[128]; // sockaddr_storage
- var len = addr.Length;
-
- if (GetPeerName(fd, addr, ref len) != 0)
- {
- return null;
- }
-
- var family = addr[0] | (addr[1] << 8);
-
- return family switch
- {
- 2 => new IPAddress(addr[4..8]), // AF_INET -> sin_addr
- 10 => new IPAddress(addr[8..24]), // AF_INET6 -> sin6_addr
- _ => null,
- };
- }
-
- // Per-reactor pool of Request objects. Each reactor runs on its own thread and services its
- // connections cooperatively, so the stack needs no locking. Reuses the per-connection Request
- // allocation, which matters under connection churn (e.g. limited-conn). Mirrors the Internal
- // engine's ClientContext pool, adapted for thread-per-core.
- [ThreadStatic]
- private static Stack? _requestPool;
-
- private const int MaxPooledRequests = 1024;
+ private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray();
- internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory)
+ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn,
+ Protocols protocols = Protocols.Http1)
{
- // Default transport is a plain duplex pipe over the connection. A connectionFactory (e.g. the
- // TLS-terminating one supplied by the host for the :8081 listener) can swap in a transport that
- // decrypts inbound bytes and writes plaintext for kTLS TX.
- var pipe = connectionFactory is null ? new ioxide.ConnectionDualPipe(conn) : await connectionFactory(conn);
+ IDuplexPipe pipe;
- var reader = pipe.Input;
- var writer = pipe.Output;
-
- // The peer address is constant for the connection; resolve it once from the socket fd.
- var remoteAddress = GetPeerAddress(conn.ClientFd);
-
- var request = RentRequest();
- var into = request.Source;
-
- // Diagnostic: the [ThreadStatic] Request pool and lock-free pooling assume every continuation
- // in this method resumes on the reactor thread that entered it. Capture that thread now — before
- // the first await — so we can warn (once) if ioxide ever resumes us on a different thread
- // (e.g. under a work-stealing scheduler), which silently degrades the pool.
- var reactorThreadId = Environment.CurrentManagedThreadId;
+ // Null on a plaintext port, and on a TLS port whose client offered nothing we serve.
+ string? negotiated = null;
try
{
- var dataRemaining = false;
- ReadResult readResult = default;
-
- while (server.Running)
+ if (endPoint.Secure)
{
- if (!dataRemaining)
- {
- readResult = await reader.ReadAsync();
- WarnIfThreadHopped(server, reactorThreadId, "after-read");
- }
-
- dataRemaining = false;
-
- var buffer = readResult.Buffer;
-
- if (!TryParseRequest(ref buffer, into))
+ // A secure port with no certificate is advertised for redirects but cannot
+ // handshake - FIN, so the client fails fast rather than a plaintext response
+ // landing on an https port.
+ if (!IoxideReactor.Current.GetService().TryFor(conn.ListenerPort, out var service))
{
- reader.AdvanceTo(readResult.Buffer.Start, readResult.Buffer.End);
- if (readResult.IsCompleted)
- {
- break;
- }
- continue;
+ _ = Shutdown(conn.ClientFd, ShutWrite);
+ conn.DecRef();
+ return;
}
- // Client cert stays null until TLS termination lands; the remote address is read from the socket.
- request.Apply(server, endPoint, reader, buffer.Start, remoteAddress, null);
-
- var keepAlive = await HandleRequestAsync(server, writer, request);
- WarnIfThreadHopped(server, reactorThreadId, "after-handle");
-
- if (!keepAlive)
- {
- await writer.FlushAsync();
- break;
- }
-
- await request.DrainAsync();
- request.Reset();
-
- if (readResult.IsCompleted)
- {
- break;
- }
-
- if (reader.TryRead(out var next)) // pipeline mode (more data available)
- {
- readResult = next;
- dataRemaining = true;
- }
- else
- {
- await writer.FlushAsync();
- }
+ (pipe, negotiated) = await AcceptTlsAsync(conn, service);
+ }
+ else
+ {
+ pipe = new ioxide.TcpConnectionDualPipe(conn);
}
}
catch
{
- // spike: swallow client/protocol faults; teardown happens in finally
+ // failed handshake - release the connection instead of leaking it
+ conn.DecRef();
+ return;
}
- finally
- {
- await reader.CompleteAsync();
- // Signal end-of-writes so the client sees EOF. Length-delimited responses don't strictly
- // need this, but connection-close and upgrade (101) responses are delimited by FIN — without
- // completing the writer the client blocks waiting for bytes that never come. Mirrors the
- // reader completion above.
- await writer.CompleteAsync();
+ var remoteAddress = GetPeerAddress(conn.ClientFd);
- WarnIfThreadHopped(server, reactorThreadId, "before-return");
- if (pipe is IAsyncDisposable disposable)
- {
- await disposable.DisposeAsync(); // tears down a TLS transport (stops the decrypt pump, close_notify)
- }
- // Send FIN for server-initiated closes so EOF-delimited responses terminate for the client
- // (see ShutWrite above). Harmless for client-initiated closes — the fd is already closing.
- Shutdown(conn.ClientFd, ShutWrite);
- conn.DecRef();
- ReturnRequest(request);
- }
- }
+ var http2 = protocols.HasFlag(Protocols.Http2);
+ var http1 = protocols.HasFlag(Protocols.Http1);
- private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into)
- => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into);
+ // Only worth peeking when both share the port: elsewhere the answer is already known.
+ var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(pipe.Input)));
- // Hardened managed parser (default): full RFC + smuggling validation.
- private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into)
- {
- if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead))
+ if (http2 && !http1)
{
- return false;
+ isHttp2 = true;
}
- buffer = buffer.Slice(bytesRead + 1);
- return true;
- }
+ if (isHttp2)
+ {
+ try
+ {
+ await Http2Driver.RunAsync(server, endPoint, pipe, remoteAddress, endPoint.Secure);
+ }
+ catch
+ {
+ // client or protocol fault - teardown happens below
+ }
+ finally
+ {
+ await CloseAsync(pipe, conn);
+ }
- // picohttpparser (native) — single-segment is parsed in place, multi-segment is linearized.
- // `consumed` follows the same -1 convention as the managed parser, so the slice is identical.
- private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into)
- {
- if (!PicoParser.TryParse(buffer, into, out var consumed))
+ return;
+ }
+
+ // Not HTTP/2 on a port that does not serve HTTP/1.1 either: close, rather than answer with
+ // a protocol the endpoint was configured not to speak.
+ if (!http1)
{
- return false;
+ await CloseAsync(pipe, conn);
+ return;
}
- buffer = buffer.Slice(consumed + 1);
- return true;
+ // HTTP/1.1 tears the connection down itself, so that it can return its pooled request first.
+ await Http1Driver.RunAsync(server, endPoint, pipe, conn, remoteAddress);
}
- private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request)
+ ///
+ /// Terminates TLS and reports what ALPN settled on, which is how a port serving several
+ /// protocols knows which one this connection speaks. Null means the client offered nothing the
+ /// port lists, and HTTP/1.1 is assumed.
+ ///
+ private static async ValueTask<(IDuplexPipe Pipe, string? Protocol)> AcceptTlsAsync(IoConnection conn, TlsService service)
{
- var header = request.Header;
+ var session = await service.AcceptAsync(conn);
- var headRequest = header.Method == RequestMethod.Head;
+ return (new TlsConnectionDualPipe(conn, session), session.NegotiatedAlpn);
+ }
- var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection);
+ ///
+ /// Peeks for the HTTP/2 connection preface without consuming it, so a plaintext client using
+ /// prior knowledge (h2c) is recognised and the same bytes are handed to the HTTP/2 layer.
+ ///
+ private static async ValueTask StartsWithPrefaceAsync(PipeReader reader)
+ {
+ while (true)
+ {
+ var result = await reader.ReadAsync();
+ var buffer = result.Buffer;
- var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11);
+ if (buffer.Length >= Preface.Length)
+ {
+ Span head = stackalloc byte[Preface.Length];
+ buffer.Slice(0, Preface.Length).CopyTo(head);
- var response = await server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response");
+ // Nothing consumed AND nothing examined: marking these examined would tell the pipe
+ // we want more, and whichever protocol reads next would block on data already here.
+ reader.AdvanceTo(buffer.Start, buffer.Start);
- var closeRequested = response.Mode is Connection.Close or Connection.Upgrade;
+ return head.SequenceEqual(Preface.Span);
+ }
- await ResponseWriter.WriteAsync(writer, request, response, keepAliveRequested && !closeRequested, headRequest);
+ // Too short to decide yet - examined to the end, so the next read waits for more.
+ reader.AdvanceTo(buffer.Start, buffer.End);
- return keepAliveRequested && !closeRequested;
+ if (result.IsCompleted)
+ {
+ return false;
+ }
+ }
}
- private static Request RentRequest()
- => _requestPool is { } pool && pool.TryPop(out var request) ? request : new Request();
-
- private static void ReturnRequest(Request request)
+ ///
+ /// Ends a connection: complete both halves, tear down the transport, FIN, release. Completing
+ /// the writer matters beyond tidiness - connection-close and upgrade responses are delimited by
+ /// FIN, and without it the client waits for bytes that never come.
+ ///
+ internal static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn)
{
- request.Reset();
+ await pipe.Input.CompleteAsync();
+ await pipe.Output.CompleteAsync();
- var pool = _requestPool ??= new Stack();
-
- if (pool.Count < MaxPooledRequests)
+ if (pipe is IAsyncDisposable disposable)
{
- pool.Push(request);
+ await disposable.DisposeAsync(); // tears down a TLS transport (stops the decrypt pump, close_notify)
}
- }
- // Set to 1 the first time a continuation is seen resuming off the reactor thread.
- private static int _hopWarned;
+ Shutdown(conn.ClientFd, ShutWrite);
+ conn.DecRef();
+ }
- // Warns at most once per process if this phase's continuation resumed on a different thread than the
- // reactor thread that entered HandleAsync. On the fast (affine) path it's a single int compare, so it's
- // safe to leave enabled during benchmarks — the one-shot guard keeps it from perturbing throughput.
- private static void WarnIfThreadHopped(IServer server, int reactorThreadId, string phase)
+ // Straight from the socket fd, since ioxide exposes the fd but not the peer address. Returned
+ // as-is (IPv4-mapped IPv6 on a dual-stack listener), which the pipeline already handles.
+ private static IPAddress? GetPeerAddress(int fd)
{
- var now = Environment.CurrentManagedThreadId;
+ var addr = new byte[128]; // sockaddr_storage
+ var len = addr.Length;
- if (now == reactorThreadId || _hopWarned != 0)
+ if (GetPeerName(fd, addr, ref len) != 0)
{
- return;
+ return null;
}
- if (Interlocked.Exchange(ref _hopWarned, 1) == 0)
+ var family = addr[0] | (addr[1] << 8);
+
+ return family switch
{
- server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.ConnectionDriver")
- .LogWarning("Thread hop detected: reactor={ReactorThreadId} now={CurrentThreadId} phase={Phase}. " +
- "The [ThreadStatic] Request pool assumes reactor affinity; pooling degrades under work-stealing. (warns once)", reactorThreadId, now, phase);
- }
+ 2 => new IPAddress(addr[4..8]), // AF_INET -> sin_addr
+ 10 => new IPAddress(addr[8..24]), // AF_INET6 -> sin6_addr
+ _ => null,
+ };
}
-
}
diff --git a/Engine/Ioxide/Protocol/ChunkedSink.cs b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs
similarity index 62%
rename from Engine/Ioxide/Protocol/ChunkedSink.cs
rename to Engine/Ioxide/Protocol/Http1/ChunkedSink.cs
index 627623a28..8099ed785 100644
--- a/Engine/Ioxide/Protocol/ChunkedSink.cs
+++ b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs
@@ -3,13 +3,12 @@
using GenHTTP.Api.Protocol;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
///
-/// Response sink for unknown-length content: both the and the
-/// route through a , so everything the content
-/// writes is HTTP/1.1 chunk-framed. Call after the content is written to
-/// emit the terminating chunk.
+/// Response sink for unknown-length content: both channels route through a
+/// , so everything the content writes is chunk-framed.
+/// emits the terminating chunk.
///
internal sealed class ChunkedSink : IResponseSink
{
diff --git a/Engine/Ioxide/Protocol/ChunkedWriter.cs b/Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs
similarity index 82%
rename from Engine/Ioxide/Protocol/ChunkedWriter.cs
rename to Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs
index 391495c1b..f0c715658 100644
--- a/Engine/Ioxide/Protocol/ChunkedWriter.cs
+++ b/Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs
@@ -1,13 +1,12 @@
using System.Buffers;
using System.IO.Pipelines;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
///
-/// Wraps a and frames every as an HTTP/1.1
-/// transfer-encoding chunk (size in hex + CRLF + data + CRLF). Ported from GenHTTP's Internal
-/// engine (ChunkedWriter): a fixed 8-hex-digit size header is reserved ahead of the payload so
-/// the chunk can be framed in place without a second copy.
+/// Frames every as a transfer-encoding chunk (hex size, CRLF, data, CRLF).
+/// A fixed 8-digit size header is reserved ahead of the payload, so the chunk is framed in place
+/// without a second copy. Ported from the Internal engine.
///
internal sealed class ChunkedWriter(PipeWriter writer) : IBufferWriter
{
diff --git a/Engine/Ioxide/Protocol/DateHeader.cs b/Engine/Ioxide/Protocol/Http1/DateHeader.cs
similarity index 71%
rename from Engine/Ioxide/Protocol/DateHeader.cs
rename to Engine/Ioxide/Protocol/Http1/DateHeader.cs
index dbd1ba2c3..f24666698 100644
--- a/Engine/Ioxide/Protocol/DateHeader.cs
+++ b/Engine/Ioxide/Protocol/Http1/DateHeader.cs
@@ -1,12 +1,10 @@
using System.Runtime.CompilerServices;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
///
-/// Per-reactor cached "Date: ...\r\n" header, refreshed at most once a second. Mirrors GenHTTP's
-/// Internal-engine DateHeader but is [ThreadStatic]: each
-/// reactor runs on its own thread, so it owns its buffer — no cross-thread races (a shared static
-/// would tear under N reactors) and no per-response formatting or allocation.
+/// Per-reactor cached "Date: ...\r\n" header, refreshed at most once a second. [ThreadStatic]
+/// rather than a shared static, which would tear under N reactors writing the same buffer.
///
internal static class DateHeader
{
diff --git a/Engine/Ioxide/Protocol/Http1/Http1Driver.cs b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs
new file mode 100644
index 000000000..37b5ee611
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs
@@ -0,0 +1,218 @@
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Net;
+
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+
+using GenHTTP.Engine.Shared.Types;
+
+using Glyph11.Parser;
+using Glyph11.Parser.UltraHardened;
+using Glyph11.Pico;
+using Glyph11.Protocol;
+
+using Microsoft.Extensions.Logging;
+
+using Connection = GenHTTP.Api.Protocol.Connection;
+using IoConnection = ioxide.TcpConnection;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
+
+///
+/// Serves an HTTP/1.1 connection: parse, handle, respond, repeat until it closes. Reached from
+/// once the transport is up and the protocol settled.
+///
+///
+/// One of these per connection, on the reactor thread, with awaited continuations resuming inline
+/// on that same thread - which is what lets the request pool below go without locking. Unlike
+/// HTTP/2 and HTTP/3 there is one request in flight at a time, so a connection owns a single
+/// for its lifetime.
+///
+internal static class Http1Driver
+{
+ private static readonly ParserLimits Limits = ParserLimits.Default;
+
+ // Benchmark switch: GENHTTP_IOXIDE_PARSER=pico parses headers with Glyph11.Pico
+ // (picohttpparser, native) instead of the hardened managed parser. Both fill the same
+ // BinaryRequest. The Pico path does picohttpparser-level validation only (no path/token/
+ // smuggling hardening), so it is for benchmarking, not for untrusted traffic.
+ private static readonly bool UsePico =
+ string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase);
+
+ private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray();
+
+ // Per-reactor, so the stack needs no locking. Reuses the per-connection Request allocation,
+ // which matters under connection churn.
+ [ThreadStatic]
+ private static Stack? _requestPool;
+
+ private const int MaxPooledRequests = 1024;
+
+ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexPipe pipe, IoConnection conn, IPAddress? remoteAddress)
+ {
+ var reader = pipe.Input;
+ var writer = pipe.Output;
+
+ var request = RentRequest();
+ var into = request.Source;
+
+ // Captured before the first await, so the diagnostic below can tell whether continuations
+ // really did resume on the reactor thread the pool assumes.
+ var reactorThreadId = Environment.CurrentManagedThreadId;
+
+ try
+ {
+ var dataRemaining = false;
+ ReadResult readResult = default;
+
+ while (server.Running)
+ {
+ if (!dataRemaining)
+ {
+ readResult = await reader.ReadAsync();
+ WarnIfThreadHopped(server, reactorThreadId, "after-read");
+ }
+
+ dataRemaining = false;
+
+ var buffer = readResult.Buffer;
+
+ if (!TryParseRequest(ref buffer, into))
+ {
+ reader.AdvanceTo(readResult.Buffer.Start, readResult.Buffer.End);
+ if (readResult.IsCompleted)
+ {
+ break;
+ }
+ continue;
+ }
+
+ // Client cert stays null until TLS termination exposes one.
+ request.Apply(server, endPoint, reader, buffer.Start, remoteAddress, null);
+
+ var keepAlive = await HandleRequestAsync(server, writer, request);
+ WarnIfThreadHopped(server, reactorThreadId, "after-handle");
+
+ if (!keepAlive)
+ {
+ await writer.FlushAsync();
+ break;
+ }
+
+ await request.DrainAsync();
+ request.Reset();
+
+ if (readResult.IsCompleted)
+ {
+ break;
+ }
+
+ if (reader.TryRead(out var next)) // pipeline mode (more data available)
+ {
+ readResult = next;
+ dataRemaining = true;
+ }
+ else
+ {
+ await writer.FlushAsync();
+ }
+ }
+ }
+ catch
+ {
+ // spike: swallow client/protocol faults; teardown happens in finally
+ }
+ finally
+ {
+ WarnIfThreadHopped(server, reactorThreadId, "before-return");
+
+ await ConnectionDriver.CloseAsync(pipe, conn);
+
+ ReturnRequest(request);
+ }
+ }
+
+ private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into)
+ => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into);
+
+ // Default: full RFC + smuggling validation.
+ private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into)
+ {
+ if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead))
+ {
+ return false;
+ }
+
+ buffer = buffer.Slice(bytesRead + 1);
+ return true;
+ }
+
+ // picohttpparser: single-segment is parsed in place, multi-segment is linearized. `consumed`
+ // follows the managed parser's -1 convention, so the slice is identical.
+ private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into)
+ {
+ if (!PicoParser.TryParse(buffer, into, out var consumed))
+ {
+ return false;
+ }
+
+ buffer = buffer.Slice(consumed + 1);
+ return true;
+ }
+
+ private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request)
+ {
+ var header = request.Header;
+
+ var headRequest = header.Method == RequestMethod.Head;
+
+ var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection);
+
+ var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11);
+
+ var response = await server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response");
+
+ var closeRequested = response.Mode is Connection.Close or Connection.Upgrade;
+
+ await ResponseWriter.WriteAsync(writer, request, response, keepAliveRequested && !closeRequested, headRequest);
+
+ return keepAliveRequested && !closeRequested;
+ }
+
+ private static Request RentRequest()
+ => _requestPool is { } pool && pool.TryPop(out var request) ? request : new Request();
+
+ private static void ReturnRequest(Request request)
+ {
+ request.Reset();
+
+ var pool = _requestPool ??= new Stack();
+
+ if (pool.Count < MaxPooledRequests)
+ {
+ pool.Push(request);
+ }
+ }
+
+ private static int _hopWarned;
+
+ // Warns once per process if a continuation resumed off the reactor thread. On the affine path
+ // it is a single int compare, so it can stay enabled during benchmarks.
+ private static void WarnIfThreadHopped(IServer server, int reactorThreadId, string phase)
+ {
+ var now = Environment.CurrentManagedThreadId;
+
+ if (now == reactorThreadId || _hopWarned != 0)
+ {
+ return;
+ }
+
+ if (Interlocked.Exchange(ref _hopWarned, 1) == 0)
+ {
+ server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http1Driver")
+ .LogWarning("Thread hop detected: reactor={ReactorThreadId} now={CurrentThreadId} phase={Phase}. " +
+ "The [ThreadStatic] Request pool assumes reactor affinity; pooling degrades under work-stealing. (warns once)", reactorThreadId, now, phase);
+ }
+ }
+}
diff --git a/Engine/Ioxide/Protocol/IoxideSink.cs b/Engine/Ioxide/Protocol/Http1/IoxideSink.cs
similarity index 86%
rename from Engine/Ioxide/Protocol/IoxideSink.cs
rename to Engine/Ioxide/Protocol/Http1/IoxideSink.cs
index b5d09aa7b..9e7c3daa1 100644
--- a/Engine/Ioxide/Protocol/IoxideSink.cs
+++ b/Engine/Ioxide/Protocol/Http1/IoxideSink.cs
@@ -3,7 +3,7 @@
using GenHTTP.Api.Protocol;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
internal sealed class IoxideSink(PipeWriter writer) : IResponseSink
{
diff --git a/Engine/Ioxide/Protocol/PipeWriterStream.cs b/Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs
similarity index 70%
rename from Engine/Ioxide/Protocol/PipeWriterStream.cs
rename to Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs
index cd867d917..779ed390e 100644
--- a/Engine/Ioxide/Protocol/PipeWriterStream.cs
+++ b/Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs
@@ -2,14 +2,13 @@
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
///
-/// Minimal write-only over an so a response
-/// sink's Stream writes through the SAME buffer writer as everything else, preserving ordering.
-/// receives the bytes (the raw for fixed-length
-/// responses, or a for chunked ones); is the
-/// underlying pipe that actually drains to the socket. Mirrors GenHTTP's WritingStream.
+/// Write-only over an , so a sink's Stream
+/// writes through the same buffer writer as everything else and ordering is preserved.
+/// takes the bytes (the raw pipe, or a );
+/// is the pipe that drains to the socket.
///
internal sealed class PipeWriterStream(IBufferWriter sink, PipeWriter flush) : Stream
{
@@ -60,10 +59,8 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo
return ValueTask.CompletedTask;
}
- // Deliberately a no-op: a synchronous flush would block the reactor thread on the pipe's
- // IValueTaskSource, but that flush is completed by the very same reactor -> deadlock. The bytes
- // written above are already buffered in `sink` and get drained by the end-of-response FlushAsync
- // (and async callers can await FlushAsync below), so dropping the sync flush loses nothing.
+ // No-op on purpose: a sync flush would block the reactor thread on a pipe only that reactor
+ // completes. The bytes are buffered in `sink` and drain at the end-of-response FlushAsync.
public override void Flush() { }
public override Task FlushAsync(CancellationToken cancellationToken) => flush.FlushAsync(cancellationToken).AsTask();
diff --git a/Engine/Ioxide/Protocol/ResponseWriter.cs b/Engine/Ioxide/Protocol/Http1/ResponseWriter.cs
similarity index 77%
rename from Engine/Ioxide/Protocol/ResponseWriter.cs
rename to Engine/Ioxide/Protocol/Http1/ResponseWriter.cs
index c67300095..6731bb646 100644
--- a/Engine/Ioxide/Protocol/ResponseWriter.cs
+++ b/Engine/Ioxide/Protocol/Http1/ResponseWriter.cs
@@ -5,13 +5,12 @@
using GenHTTP.Engine.Shared.Types;
-namespace GenHTTP.Engine.Ioxide.Protocol;
+namespace GenHTTP.Engine.Ioxide.Protocol.Http1;
///
-/// Writes an to a . Status line and body writing
-/// stay engine-specific (Ioxide sinks are allocated fresh per response rather than pooled on a
-/// per-connection context), but header serialization is shared with the Internal engine via
-/// ; only the Server/Date header values differ.
+/// Writes an to a . Header serialization is
+/// shared with the Internal engine through ; body writing stays
+/// engine-specific, since the sinks are allocated per response rather than pooled per connection.
///
internal static class ResponseWriter
{
@@ -42,7 +41,7 @@ private static async ValueTask WriteBodyAsync(PipeWriter writer, IResponse respo
if (content.Length is null && response.Mode != Connection.Upgrade)
{
- // Unknown length: chunk-frame everything the content writes, then terminate.
+ // Unknown length: chunk-frame everything, then terminate.
var sink = new ChunkedSink(writer);
await content.WriteAsync(sink);
sink.Finish();
diff --git a/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs
new file mode 100644
index 000000000..5b4ea4e5f
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs
@@ -0,0 +1,88 @@
+using System.IO.Pipelines;
+using System.Net;
+
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+
+using ioxide.http2;
+
+using Microsoft.Extensions.Logging;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// Serves an HTTP/2 connection: ioxide.http2 owns framing, HPACK and flow control, this maps each
+/// request onto GenHTTP's handler chain.
+///
+///
+/// Streamed both ways: the handler starts at end-of-headers, pulls the body as flow control
+/// delivers it, and writes into a writer that frames each flush as a DATA frame - so neither
+/// direction is assembled in memory and both are paced by the peer's window.
+///
+internal static class Http2Driver
+{
+ private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray();
+
+ private static readonly Http2Options Options = new() { StreamRequestBodies = true };
+
+ ///
+ /// Serves the connection over an established transport: a TLS pipe that negotiated "h2", or a
+ /// plaintext pipe carrying h2c with prior knowledge.
+ ///
+ internal static Task RunAsync(IServer server, IEndPoint endPoint, IDuplexPipe pipe, IPAddress? remoteAddress, bool secure)
+ => new Http2Connection(pipe, Options)
+ .RunAsync((request, writer) => DispatchAsync(server, endPoint, request, writer, remoteAddress, secure));
+
+ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, Http2Request request,
+ Http2ResponseWriter writer, IPAddress? remoteAddress, bool secure)
+ {
+ try
+ {
+ var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(request.Headers.Count);
+
+ for (var i = 0; i < request.Headers.Count; i++)
+ {
+ var header = request.Headers[i];
+ headers.Add((header.Key, header.Value));
+ }
+
+ var headRequest = request.Method.Span.SequenceEqual(Head.Span);
+
+ var reader = request.BodyReader;
+
+ await using var mapped = new MultiplexedRequest(server, endPoint, request.Method, request.Path, request.Authority,
+ headers, reader is null ? null : reader.ReadAsync, remoteAddress, HttpProtocol.Http2, secure);
+
+ var response = await server.Handler.HandleAsync(mapped)
+ ?? throw new InvalidOperationException("The root request handler did not return a response");
+
+ var data = MultiplexedResponder.BuildHeaders(response);
+
+ var head = new Http2Response { Status = data.Status };
+
+ foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in data.Headers)
+ {
+ head.Headers.Add(name, value);
+ }
+
+ writer.WriteHeaders(head);
+
+ await MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest);
+ }
+ catch (Exception e)
+ {
+ server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http2Driver")
+ .LogError(e, "Failed to handle HTTP/2 request");
+
+ if (!writer.IsCompleted)
+ {
+ writer.WriteHeaders(new Http2Response { Status = 500 });
+ }
+ }
+ finally
+ {
+ // Ends the stream. Without it the peer waits for a body that is never coming.
+ await writer.CompleteAsync();
+ }
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs
new file mode 100644
index 000000000..ac13a9ad5
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs
@@ -0,0 +1,84 @@
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+
+using ioxide;
+using ioxide.nghttp3;
+
+using Microsoft.Extensions.Logging;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// Serves an HTTP/3 connection: ngtcp2 carries QUIC, nghttp3 carries HTTP/3 and QPACK, this maps
+/// each request onto GenHTTP's handler chain.
+///
+///
+/// Streamed both ways, as with HTTP/2: each flush parks until the peer's window and the send
+/// retention high-water allow more, so a large file costs about that high-water in memory rather
+/// than its own size.
+///
+internal static class Http3Driver
+{
+ private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray();
+
+ /// Serves one accepted QUIC connection until it closes.
+ internal static Task RunAsync(IServer server, IEndPoint endPoint, QuicConnection connection, Nghttp3Options options)
+ => new Nghttp3Connection(connection, options)
+ .RunStreamedResponseAsync((request, writer) => DispatchAsync(server, endPoint, request, writer));
+
+ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, Nghttp3Request request,
+ Nghttp3ResponseWriter writer)
+ {
+ try
+ {
+ var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(request.Headers.Count);
+
+ for (var i = 0; i < request.Headers.Count; i++)
+ {
+ var header = request.Headers[i];
+ headers.Add((header.Key, header.Value));
+ }
+
+ var headRequest = request.Method.Span.SequenceEqual(Head.Span);
+
+ var reader = request.BodyReader;
+
+ // Always secure: QUIC carries TLS 1.3 and has no cleartext mode. The client address
+ // stays null - ioxide's QuicConnection exposes no way to read the peer address, and a
+ // QUIC peer may migrate mid-connection anyway.
+ await using var mapped = new MultiplexedRequest(server, endPoint, request.Method, request.Path, request.Authority,
+ headers, reader is null ? null : reader.ReadAsync, remoteAddress: null, HttpProtocol.Http3, secure: true);
+
+ var response = await server.Handler.HandleAsync(mapped)
+ ?? throw new InvalidOperationException("The root request handler did not return a response");
+
+ var data = MultiplexedResponder.BuildHeaders(response);
+
+ var head = new Nghttp3Response { Status = data.Status };
+
+ foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in data.Headers)
+ {
+ head.Headers.Add(name, value);
+ }
+
+ writer.WriteHeaders(head);
+
+ await MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest);
+ }
+ catch (Exception e)
+ {
+ server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http3Driver")
+ .LogError(e, "Failed to handle HTTP/3 request");
+
+ if (!writer.IsCompleted)
+ {
+ writer.WriteHeaders(new Nghttp3Response { Status = 500 });
+ }
+ }
+ finally
+ {
+ // Ends the stream. Without it the peer waits for a body that is never coming.
+ await writer.CompleteAsync();
+ }
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs
new file mode 100644
index 000000000..ea32d39a1
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs
@@ -0,0 +1,27 @@
+using GenHTTP.Api.Protocol;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// Header and query lists over fields a multiplexed protocol has already decoded. The shared list
+/// wraps Glyph11's parse output and so assumes HTTP/1.1; HPACK and QPACK hand over name/value
+/// pairs instead, with no request line and no raw header block to point back at.
+///
+internal sealed class MultiplexedKeyValueList : IRequestHeaders, IRequestQuery
+{
+ private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries;
+
+ internal MultiplexedKeyValueList(List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> entries)
+ {
+ _entries = entries;
+ }
+
+ public int Count => _entries.Count;
+
+ public KeyValuePair, ReadOnlyMemory> GetMemoryEntry(int index)
+ {
+ (ReadOnlyMemory name, ReadOnlyMemory value) = _entries[index];
+
+ return new KeyValuePair, ReadOnlyMemory>(name, value);
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs
new file mode 100644
index 000000000..945937741
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs
@@ -0,0 +1,121 @@
+using System.IO.Pipelines;
+using System.Net;
+
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+using GenHTTP.Engine.Shared.Types;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// An over a request decoded by HPACK or QPACK - not the shared
+/// , whose Source assumes an HTTP/1.1 parse off a pipe.
+///
+///
+/// Nothing here is pooled: several are live on one connection at once, so a pool would need the
+/// locking the reactor model exists to avoid.
+///
+internal sealed class MultiplexedRequest : IRequest
+{
+ private readonly MultiplexedRequestBody? _body;
+
+ private readonly ClientConnection _client = new();
+
+ private readonly PropertyBag _properties = new();
+
+ private readonly ResponseBuilder _response = new();
+
+ private Func? _bodyWrapper;
+
+ private bool _bodyFetched;
+
+ internal MultiplexedRequest(IServer server, IEndPoint endPoint, ReadOnlyMemory method, ReadOnlyMemory path,
+ ReadOnlyMemory authority, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers,
+ Func>>? read, IPAddress? remoteAddress, HttpProtocol protocol, bool secure)
+ {
+ Server = server;
+ EndPoint = endPoint;
+
+ Header = new MultiplexedRequestHeader(method, path, authority, headers, ParseQuery(path), protocol);
+
+ _body = read is null ? null : new MultiplexedRequestBody(read);
+
+ _client.Apply(remoteAddress, secure ? ClientProtocol.Https : ClientProtocol.Http, null);
+ }
+
+ public IServer Server { get; }
+
+ public IEndPoint EndPoint { get; }
+
+ public IClientConnection Client => _client;
+
+ public IPropertyBag Properties => _properties;
+
+ public IRequestHeader Header { get; }
+
+ public IRequestBody? GetBody(HeaderAccess headerAccess = HeaderAccess.Retain)
+ {
+ if (_bodyFetched)
+ {
+ throw new InvalidOperationException("Request body can only be fetched once.");
+ }
+
+ _bodyFetched = true;
+
+ if (_body is null)
+ {
+ return null;
+ }
+
+ return _bodyWrapper is not null ? _bodyWrapper(_body) : _body;
+ }
+
+ public void WrapBody(Func wrapper) => _bodyWrapper = wrapper;
+
+ public IResponseBuilder Respond() => _response.Status(ResponseStatus.Ok);
+
+ ///
+ /// Not supported: upgrading to a raw byte stream is an HTTP/1.1 mechanism.
+ ///
+ public PipeReader Upgrade()
+ => throw new NotSupportedException("Connection upgrades are not available over HTTP/2 or HTTP/3.");
+
+ public ValueTask DisposeAsync() => new();
+
+ // Both protocols carry the query inside :path, as HTTP/1.1 carries it in the request target.
+ private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> ParseQuery(ReadOnlyMemory path)
+ {
+ var parameters = new List<(ReadOnlyMemory, ReadOnlyMemory)>();
+
+ var mark = path.Span.IndexOf((byte)'?');
+
+ if (mark < 0)
+ {
+ return parameters;
+ }
+
+ var query = path[(mark + 1)..];
+
+ while (!query.IsEmpty)
+ {
+ var end = query.Span.IndexOf((byte)'&');
+
+ var pair = end < 0 ? query : query[..end];
+
+ query = end < 0 ? default : query[(end + 1)..];
+
+ if (pair.IsEmpty)
+ {
+ continue;
+ }
+
+ var equals = pair.Span.IndexOf((byte)'=');
+
+ parameters.Add(equals < 0
+ ? (pair, ReadOnlyMemory.Empty)
+ : (pair[..equals], pair[(equals + 1)..]));
+ }
+
+ return parameters;
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs
new file mode 100644
index 000000000..77d1869bf
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs
@@ -0,0 +1,117 @@
+using GenHTTP.Api.Protocol;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// A request body pulled from the protocol layer as it arrives - dispatch happens at
+/// end-of-headers, so it is still in flight when the handler starts. Flow control paces it: the
+/// window only reopens as chunks are consumed, so an upload cannot outrun the handler.
+///
+///
+/// The read delegate abstracts the two body readers, which have the same shape but come from
+/// different packages. It returns empty once the request stream has ended.
+///
+internal sealed class MultiplexedRequestBody : IRequestBody
+{
+ private readonly Func>> _read;
+
+ internal MultiplexedRequestBody(Func>> read)
+ {
+ _read = read;
+ }
+
+ public Stream AsStream() => new PullStream(_read);
+
+ public async ValueTask> AsMemoryAsync()
+ {
+ // Assembling defeats the point of streaming, but a handler asking for the whole body has
+ // to keep working.
+ var assembled = new MemoryStream();
+
+ while (true)
+ {
+ var chunk = await _read();
+
+ if (chunk.IsEmpty)
+ {
+ break;
+ }
+
+ assembled.Write(chunk.Span);
+ }
+
+ return assembled.ToArray();
+ }
+
+ /// Presents the pull-based reader as a forward-only stream.
+ private sealed class PullStream : Stream
+ {
+ private readonly Func>> _read;
+
+ private ReadOnlyMemory _current;
+
+ private bool _ended;
+
+ private long _position;
+
+ internal PullStream(Func>> read)
+ {
+ _read = read;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => _position;
+ set => throw new NotSupportedException();
+ }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ if (_current.IsEmpty && !_ended)
+ {
+ _current = await _read();
+
+ if (_current.IsEmpty)
+ {
+ _ended = true;
+ }
+ }
+
+ if (_current.IsEmpty)
+ {
+ return 0;
+ }
+
+ var take = Math.Min(buffer.Length, _current.Length);
+
+ _current[..take].CopyTo(buffer);
+ _current = _current[take..];
+ _position += take;
+
+ return take;
+ }
+
+ public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
+
+ // A sync read would block the reactor thread on a chunk only that thread can deliver.
+ public override int Read(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException("The request body must be read asynchronously.");
+
+ public override void Flush() { }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs
new file mode 100644
index 000000000..d1cf16f11
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs
@@ -0,0 +1,113 @@
+using GenHTTP.Api.Protocol;
+using GenHTTP.Engine.Shared.Types;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// An over the pseudo-headers a multiplexed protocol carries.
+///
+internal sealed class MultiplexedRequestHeader : IRequestHeader
+{
+ private static readonly ReadOnlyMemory HostName = "host"u8.ToArray();
+
+ private readonly MultiplexedKeyValueList _headers;
+
+ private readonly MultiplexedKeyValueList _query;
+
+ private readonly RequestTarget _target;
+
+ internal MultiplexedRequestHeader(ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority,
+ List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers,
+ List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query, HttpProtocol protocol)
+ {
+ _headers = new MultiplexedKeyValueList(WithHost(headers, authority));
+ _query = new MultiplexedKeyValueList(query);
+
+ _target = new RequestTarget();
+
+ // Routing must see the path alone, or every request carrying a query 404s.
+ Path = new ByteString(WithoutQuery(path));
+ Method = new RequestMethod(method);
+
+ Protocol = protocol;
+ Version = protocol == HttpProtocol.Http3 ? Http3Version : Http2Version;
+
+ _target.Apply(Path);
+ }
+
+ public RequestMethod Method { get; }
+
+ public ByteString Path { get; }
+
+ public IRequestTarget Target => _target;
+
+ // Settled by ALPN before a byte arrived, so there is no version token on the wire to read.
+ public HttpProtocol Protocol { get; }
+
+ public ReadOnlyMemory Version { get; }
+
+ public IRequestHeaders Headers => _headers;
+
+ public IRequestQuery Query => _query;
+
+ ///
+ /// Constructs Host from :authority, which clients send instead - as RFC 9113 8.3.1 and RFC 9114
+ /// 4.3.1 have an intermediary do. Routing, virtual hosting and redirects all expect a Host.
+ ///
+ private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> WithHost(
+ List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, ReadOnlyMemory authority)
+ {
+ if (authority.IsEmpty)
+ {
+ return headers;
+ }
+
+ foreach ((ReadOnlyMemory name, ReadOnlyMemory _) in headers)
+ {
+ if (name.Length == 4 && Matches(name.Span, "host"u8))
+ {
+ return headers;
+ }
+ }
+
+ var result = new List<(ReadOnlyMemory, ReadOnlyMemory)>(headers.Count + 1)
+ {
+ (HostName, authority),
+ };
+
+ result.AddRange(headers);
+
+ return result;
+ }
+
+ private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase)
+ {
+ for (var i = 0; i < name.Length; i++)
+ {
+ var c = name[i];
+
+ if (c is >= (byte)'A' and <= (byte)'Z')
+ {
+ c += 32;
+ }
+
+ if (c != lowercase[i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static ReadOnlyMemory WithoutQuery(ReadOnlyMemory path)
+ {
+ var mark = path.Span.IndexOf((byte)'?');
+
+ return mark < 0 ? path : path[..mark];
+ }
+
+ private static readonly ReadOnlyMemory Http2Version = "HTTP/2.0"u8.ToArray();
+
+ private static readonly ReadOnlyMemory Http3Version = "HTTP/3.0"u8.ToArray();
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs
new file mode 100644
index 000000000..c95e93bd9
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs
@@ -0,0 +1,147 @@
+using System.Buffers;
+using System.Buffers.Text;
+
+using GenHTTP.Api.Protocol;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// The status and fields of a response. Protocol-neutral on purpose: the two drivers want the same
+/// thing, but their response types come from different packages, so each builds its own from this.
+///
+internal readonly struct MultiplexedResponseData
+{
+ internal MultiplexedResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers)
+ {
+ Status = status;
+ Headers = headers;
+ }
+
+ internal int Status { get; }
+
+ internal List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> Headers { get; }
+}
+
+///
+/// Maps a GenHTTP onto what a multiplexed protocol submits.
+///
+internal static class MultiplexedResponder
+{
+ private static readonly ReadOnlyMemory ContentTypeName = "content-type"u8.ToArray();
+
+ private static readonly ReadOnlyMemory ContentEncodingName = "content-encoding"u8.ToArray();
+
+ private static readonly ReadOnlyMemory ContentLengthName = "content-length"u8.ToArray();
+
+ private static readonly ReadOnlyMemory ServerName = "server"u8.ToArray();
+
+ private static readonly ReadOnlyMemory ServerValue = "ioxide-genhttp"u8.ToArray();
+
+ /// Builds the field section. The content is streamed afterwards.
+ internal static MultiplexedResponseData BuildHeaders(IResponse response)
+ {
+ var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(response.Headers.Count + 4);
+
+ for (var i = 0; i < response.Headers.Count; i++)
+ {
+ var header = response.Headers.GetMemoryEntry(i);
+
+ // Connection-specific fields are malformed here (RFC 9113 8.2.2, RFC 9114 4.2) and a
+ // peer may treat one as a protocol error. Casing is left alone: both layers lowercase.
+ if (!IsConnectionSpecific(header.Key.Span))
+ {
+ headers.Add((header.Key, header.Value));
+ }
+ }
+
+ headers.Add((ServerName, ServerValue));
+
+ if (response.Content is { } content)
+ {
+ if (content.Type is { } type)
+ {
+ headers.Add((ContentTypeName, type.Bytes));
+ }
+
+ if (content.Encoding is { } encoding)
+ {
+ headers.Add((ContentEncodingName, encoding));
+ }
+
+ // A streamed response has no length by the time its headers go out, so neither layer
+ // fills this in. Send it whenever the content itself knows.
+ if (content.Length is { } length)
+ {
+ headers.Add((ContentLengthName, Digits(length)));
+ }
+ }
+
+ return new MultiplexedResponseData((int)response.Status, headers);
+ }
+
+ /// Streams the content into the protocol's response writer.
+ internal static async ValueTask WriteBodyAsync(IResponse response, IBufferWriter writer, Func flush, bool headRequest)
+ {
+ var content = response.Content;
+
+ if (content is null)
+ {
+ return;
+ }
+
+ try
+ {
+ // HEAD keeps the headers its GET would have produced and sends no body.
+ if (!headRequest)
+ {
+ await content.WriteAsync(new MultiplexedSink(writer, flush));
+ }
+ }
+ finally
+ {
+ if (content is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ }
+
+ private static ReadOnlyMemory Digits(ulong value)
+ {
+ var buffer = new byte[20];
+
+ Utf8Formatter.TryFormat(value, buffer, out var written);
+
+ return buffer.AsMemory(0, written);
+ }
+
+ private static bool IsConnectionSpecific(ReadOnlySpan name)
+ => Matches(name, "connection"u8) || Matches(name, "keep-alive"u8) || Matches(name, "transfer-encoding"u8)
+ || Matches(name, "upgrade"u8) || Matches(name, "proxy-connection"u8) || Matches(name, "server"u8)
+ || Matches(name, "content-length"u8);
+
+ private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase)
+ {
+ if (name.Length != lowercase.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < name.Length; i++)
+ {
+ var c = name[i];
+
+ if (c is >= (byte)'A' and <= (byte)'Z')
+ {
+ c += 32;
+ }
+
+ if (c != lowercase[i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs
new file mode 100644
index 000000000..2becd5430
--- /dev/null
+++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs
@@ -0,0 +1,95 @@
+using System.Buffers;
+
+using GenHTTP.Api.Protocol;
+
+namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed;
+
+///
+/// Writes response content straight into a protocol response writer, which is itself an
+/// - so the buffer channel reaches the wire with nothing between.
+///
+///
+/// The stream channel flushes on every write, and that await is the backpressure that bounds a
+/// large download. The buffer channel flushes once at the end - fine for a page, which is why file
+/// content should use the stream.
+///
+internal sealed class MultiplexedSink : IResponseSink
+{
+ private readonly IBufferWriter _writer;
+
+ private readonly Func _flush;
+
+ private Stream? _stream;
+
+ internal MultiplexedSink(IBufferWriter writer, Func flush)
+ {
+ _writer = writer;
+ _flush = flush;
+ }
+
+ public IBufferWriter Writer => _writer;
+
+ public Stream Stream => _stream ??= new FlushingStream(_writer, _flush);
+
+ ///
+ /// Adapts the protocol writer to the stream channel, flushing each write so the peer paces it.
+ ///
+ private sealed class FlushingStream : Stream
+ {
+ private readonly IBufferWriter _target;
+
+ private readonly Func _flush;
+
+ private long _written;
+
+ internal FlushingStream(IBufferWriter target, Func flush)
+ {
+ _target = target;
+ _flush = flush;
+ }
+
+ public override bool CanRead => false;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => true;
+
+ public override long Length => _written;
+
+ public override long Position
+ {
+ get => _written;
+ set => throw new NotSupportedException();
+ }
+
+ public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
+
+ public override void Write(ReadOnlySpan buffer)
+ {
+ // Staged, but not paced: there is no flush on the sync path. Large bodies want async.
+ _target.Write(buffer);
+ _written += buffer.Length;
+ }
+
+ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ _target.Write(buffer.Span);
+ _written += buffer.Length;
+
+ await _flush();
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
+
+ public override void Flush() { }
+
+ public override async Task FlushAsync(CancellationToken cancellationToken) => await _flush();
+
+ public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+ }
+}
diff --git a/Engine/Ioxide/Protocol/StatusLine.cs b/Engine/Ioxide/Protocol/StatusLine.cs
deleted file mode 100644
index d7cca219a..000000000
--- a/Engine/Ioxide/Protocol/StatusLine.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using GenHTTP.Api.Protocol;
-
-namespace GenHTTP.Engine.Ioxide.Protocol;
-
-internal static class StatusLine
-{
- private static readonly byte[][] Lines = Init();
-
- public static byte[] Get(ResponseStatus status) => Lines[(int)status];
-
- private static byte[][] Init()
- {
- var arr = new byte[600][];
-
- // 1xx
- arr[100] = "HTTP/1.1 100 Continue\r\n"u8.ToArray();
- arr[101] = "HTTP/1.1 101 Switching Protocols\r\n"u8.ToArray();
- arr[102] = "HTTP/1.1 102 Processing\r\n"u8.ToArray(); // WebDAV
-
- // 2xx
- arr[200] = "HTTP/1.1 200 OK\r\n"u8.ToArray();
- arr[201] = "HTTP/1.1 201 Created\r\n"u8.ToArray();
- arr[202] = "HTTP/1.1 202 Accepted\r\n"u8.ToArray();
- arr[203] = "HTTP/1.1 203 Non-Authoritative Information\r\n"u8.ToArray();
- arr[204] = "HTTP/1.1 204 No Content\r\n"u8.ToArray();
- arr[205] = "HTTP/1.1 205 Reset Content\r\n"u8.ToArray();
- arr[206] = "HTTP/1.1 206 Partial Content\r\n"u8.ToArray();
- arr[207] = "HTTP/1.1 207 Multi-Status\r\n"u8.ToArray(); // WebDAV
- arr[208] = "HTTP/1.1 208 Already Reported\r\n"u8.ToArray(); // WebDAV
- arr[226] = "HTTP/1.1 226 IM Used\r\n"u8.ToArray(); // Delta encoding
-
- // 3xx
- arr[300] = "HTTP/1.1 300 Multiple Choices\r\n"u8.ToArray();
- arr[301] = "HTTP/1.1 301 Moved Permanently\r\n"u8.ToArray();
- arr[302] = "HTTP/1.1 302 Found\r\n"u8.ToArray();
- arr[303] = "HTTP/1.1 303 See Other\r\n"u8.ToArray();
- arr[304] = "HTTP/1.1 304 Not Modified\r\n"u8.ToArray();
- arr[305] = "HTTP/1.1 305 Use Proxy\r\n"u8.ToArray();
- arr[307] = "HTTP/1.1 307 Temporary Redirect\r\n"u8.ToArray();
- arr[308] = "HTTP/1.1 308 Permanent Redirect\r\n"u8.ToArray();
-
- // 4xx
- arr[400] = "HTTP/1.1 400 Bad Request\r\n"u8.ToArray();
- arr[401] = "HTTP/1.1 401 Unauthorized\r\n"u8.ToArray();
- arr[402] = "HTTP/1.1 402 Payment Required\r\n"u8.ToArray();
- arr[403] = "HTTP/1.1 403 Forbidden\r\n"u8.ToArray();
- arr[404] = "HTTP/1.1 404 Not Found\r\n"u8.ToArray();
- arr[405] = "HTTP/1.1 405 Method Not Allowed\r\n"u8.ToArray();
- arr[406] = "HTTP/1.1 406 Not Acceptable\r\n"u8.ToArray();
- arr[407] = "HTTP/1.1 407 Proxy Authentication Required\r\n"u8.ToArray();
- arr[408] = "HTTP/1.1 408 Request Timeout\r\n"u8.ToArray();
- arr[409] = "HTTP/1.1 409 Conflict\r\n"u8.ToArray();
- arr[410] = "HTTP/1.1 410 Gone\r\n"u8.ToArray();
- arr[411] = "HTTP/1.1 411 Length Required\r\n"u8.ToArray();
- arr[412] = "HTTP/1.1 412 Precondition Failed\r\n"u8.ToArray();
- arr[413] = "HTTP/1.1 413 Payload Too Large\r\n"u8.ToArray();
- arr[414] = "HTTP/1.1 414 URI Too Long\r\n"u8.ToArray();
- arr[415] = "HTTP/1.1 415 Unsupported Media Type\r\n"u8.ToArray();
- arr[416] = "HTTP/1.1 416 Range Not Satisfiable\r\n"u8.ToArray();
- arr[417] = "HTTP/1.1 417 Expectation Failed\r\n"u8.ToArray();
- arr[418] = "HTTP/1.1 418 I'm a Teapot\r\n"u8.ToArray();
- arr[421] = "HTTP/1.1 421 Misdirected Request\r\n"u8.ToArray();
- arr[422] = "HTTP/1.1 422 Unprocessable Entity\r\n"u8.ToArray();
- arr[423] = "HTTP/1.1 423 Locked\r\n"u8.ToArray();
- arr[424] = "HTTP/1.1 424 Failed Dependency\r\n"u8.ToArray();
- arr[425] = "HTTP/1.1 425 Too Early\r\n"u8.ToArray();
- arr[426] = "HTTP/1.1 426 Upgrade Required\r\n"u8.ToArray();
- arr[428] = "HTTP/1.1 428 Precondition Required\r\n"u8.ToArray();
- arr[429] = "HTTP/1.1 429 Too Many Requests\r\n"u8.ToArray();
- arr[431] = "HTTP/1.1 431 Request Header Fields Too Large\r\n"u8.ToArray();
- arr[451] = "HTTP/1.1 451 Unavailable For Legal Reasons\r\n"u8.ToArray();
-
- // 5xx
- arr[500] = "HTTP/1.1 500 Internal Server Error\r\n"u8.ToArray();
- arr[501] = "HTTP/1.1 501 Not Implemented\r\n"u8.ToArray();
- arr[502] = "HTTP/1.1 502 Bad Gateway\r\n"u8.ToArray();
- arr[503] = "HTTP/1.1 503 Service Unavailable\r\n"u8.ToArray();
- arr[504] = "HTTP/1.1 504 Gateway Timeout\r\n"u8.ToArray();
- arr[505] = "HTTP/1.1 505 HTTP Version Not Supported\r\n"u8.ToArray();
- arr[506] = "HTTP/1.1 506 Variant Also Negotiates\r\n"u8.ToArray();
- arr[507] = "HTTP/1.1 507 Insufficient Storage\r\n"u8.ToArray();
- arr[508] = "HTTP/1.1 508 Loop Detected\r\n"u8.ToArray();
- arr[510] = "HTTP/1.1 510 Not Extended\r\n"u8.ToArray();
- arr[511] = "HTTP/1.1 511 Network Authentication Required\r\n"u8.ToArray();
-
- return arr;
- }
-
-}
diff --git a/Engine/Ioxide/Protocols.cs b/Engine/Ioxide/Protocols.cs
new file mode 100644
index 000000000..735bfd464
--- /dev/null
+++ b/Engine/Ioxide/Protocols.cs
@@ -0,0 +1,43 @@
+namespace GenHTTP.Engine.Ioxide;
+
+///
+/// The protocols an endpoint serves. and share the TCP
+/// socket, so enabling both turns no client away; is a UDP socket on the same
+/// port number, which is what lets one endpoint serve all three.
+///
+[Flags]
+public enum Protocols
+{
+ /// HTTP/1.1 over TCP.
+ Http1 = 1,
+
+ ///
+ /// HTTP/2 over TCP: by ALPN on a secure endpoint, or by the connection preface (h2c with prior
+ /// knowledge) on a plaintext one. The Upgrade: dance is not implemented.
+ ///
+ Http2 = 2,
+
+ ///
+ /// HTTP/3 over QUIC, on this endpoint's port number over UDP. Requires a certificate: QUIC
+ /// carries TLS 1.3 and has no cleartext mode.
+ ///
+ Http3 = 4,
+
+ /// HTTP/1.1 and HTTP/2 on the TCP socket, chosen per connection.
+ Http1AndHttp2 = Http1 | Http2,
+
+ ///
+ /// HTTP/1.1 over TCP and HTTP/3 over UDP, skipping HTTP/2. Every client is still served, and
+ /// HTTP/2's per-connection flow control and HPACK state are not paid for.
+ ///
+ Http1AndHttp3 = Http1 | Http3,
+
+ ///
+ /// HTTP/2 over TCP and HTTP/3 over UDP, with no HTTP/1.1 at all. For somewhere the clients are
+ /// known - a private API, or gRPC - since anything else is turned away.
+ ///
+ Http2AndHttp3 = Http2 | Http3,
+
+ /// Everything: HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP, one port number.
+ All = Http1 | Http2 | Http3,
+}
diff --git a/Engine/Ioxide/README.md b/Engine/Ioxide/README.md
index 22a029ddb..64a17e62d 100644
--- a/Engine/Ioxide/README.md
+++ b/Engine/Ioxide/README.md
@@ -13,13 +13,14 @@ no ASP.NET Core.
```
ioxide reactor (one per core, io_uring, SO_REUSEPORT)
- └─ accept → Connection
- └─ new ConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS)
+ └─ accept → TcpConnection
+ └─ TcpConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS)
+ (or TlsConnectionDualPipe for endpoints bound with a certificate)
└─ ConnectionDriver loop:
Glyph11 parser → GenHTTP Request (reused, public) → Handler.HandleAsync → ResponseWriter → PipeWriter
```
-The integration seam is ioxide's `ConnectionDualPipe`: GenHTTP's parse/handle/respond
+The integration seam is ioxide's `TcpConnectionDualPipe`: GenHTTP's parse/handle/respond
loop is already pure `PipeReader`/`PipeWriter`, so ioxide's native pipe bridge drops
straight in. Reused from GenHTTP unchanged: the public `Request` model, the Glyph11
parser, the `IResponseSink` content contract. Forked (thin): the per-connection loop
@@ -48,10 +49,10 @@ binding (`.Port()`/`.Bind()`), so any port set in the hook is overridden.
```csharp
await Host.Create(c => c with
{
- ReactorCount = 16, // one io_uring reactor per core
- RingEntries = 16384,
- RecvBufferSize = 64 * 1024,
- BufferRingEntries = 8192,
+ ReactorCount = 16, // one io_uring reactor per core
+ RingEntries = 16384,
+ RecvBufferSize = 64 * 1024,
+ RecvSlots = 8192,
})
.Handler(app)
.RunAsync();
@@ -60,9 +61,9 @@ await Host.Create(c => c with
## Dependency on ioxide
References the published [`ioxide`](https://www.nuget.org/packages/ioxide) NuGet
-package (`0.0.5`). The BCL pipe bridges the engine builds on
-(`ConnectionDualPipe`/`ConnectionPipeReader`/`ConnectionPipeWriter`/`ConnectionStream`)
-ship in that package.
+package (`0.4.161`). The BCL pipe bridges the engine builds on
+(`TcpConnectionDualPipe`/`TcpConnectionPipeReader`/`TcpConnectionPipeWriter`) and the
+ring-native TLS termination (`TlsService`/`TlsConnectionDualPipe`) ship in that package.
**Build note:** requires a .NET SDK with Roslyn 5.3+ (SDK 10.0.301+) because
GenHTTP's `MemoryView` source generator references `Microsoft.CodeAnalysis 5.3`.
@@ -77,10 +78,14 @@ Response handling mirrors the Internal engine: cached status lines, a once-a-sec
`Date` header (per-reactor, thread-static), and a `ChunkedWriter` for unknown-length
content. `Handler.PrepareAsync` runs at startup so handlers initialise before serving.
+Also validated: **HTTPS** - endpoints bound with a certificate (`.Bind(address, port, cert)`)
+are TLS-terminated ring-natively (OpenSSL both ways; kernel TLS stays opt-in through the
+`connectionFactory` seam) - and **multiple endpoints**, served by one reactor set via
+`ExtraPorts`, mixed plaintext and TLS.
+
Not yet implemented:
-- TLS / HTTPS (ioxide is plaintext `AF_INET` only here; `ioxide.tls`/kTLS would wire HTTPS endpoints).
-- IPv6 bind and multiple endpoints (first endpoint only).
+- Client certificates, SNI-selected certificates, and per-endpoint dual-stack modes.
- Graceful shutdown / connection drain (reactors are background threads; `DisposeAsync` only flips `Running`).
- `IServerCompanion` callbacks, the `Host`-header check, and the error-response path.
diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs
deleted file mode 100644
index aafbe1ccd..000000000
--- a/Engine/Ioxide/Server.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System.IO.Pipelines;
-
-using GenHTTP.Api.Infrastructure;
-
-using GenHTTP.Engine.Ioxide.Hosting;
-using ioxide;
-
-namespace GenHTTP.Engine.Ioxide;
-
-///
-/// Entry point to host an application using the ioxide io_uring engine.
-///
-public static class Host
-{
-
- ///
- /// Optional hook to tune the ioxide runtime (reactor count, ring sizes, recv/write
- /// buffers, ...). Receives a config pre-seeded with sensible defaults; return a
- /// modified copy, e.g. c => c with { ReactorCount = 16 }. The listen port is
- /// always taken from the GenHTTP endpoint binding (.Port()/.Bind()).
- ///
- ///
- /// Optional hook invoked once per reactor, on that reactor's own thread, before it serves.
- /// Use it to register per-reactor (ring-native) services on the supplied
- /// — e.g. r => PgPool.Start(r, pgOptions) — which handler code can later resolve via
- /// 's reactor seam (IoxideReactor.Current).
- ///
- ///
- /// Optional hook to turn an accepted into the duplex pipe the engine
- /// serves it over. Defaults to a plain ConnectionDualPipe. Supply a custom factory to
- /// wrap the transport — e.g. terminate TLS on a second listener port by decrypting inbound bytes
- /// and writing plaintext for kTLS TX. A returned pipe implementing
- /// is disposed when the connection ends.
- ///
- public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null)
- => new IoxideServerHost(configure, onReactorStart, connectionFactory);
-
-}
diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs
deleted file mode 100644
index ef25bc0bb..000000000
--- a/Engine/Ioxide/Tls/IoxideTls.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.IO.Pipelines;
-
-using ioxide;
-using ioxide.tls;
-
-namespace GenHTTP.Engine.Ioxide;
-
-///
-/// TLS-termination helpers for the ioxide engine. The engine owns the transport plumbing (the
-/// decrypt pump + kTLS-TX pipe adapter, ); the host supplies the
-/// certificate/key and decides which connections to terminate (typically by listener port).
-///
-///
-///
-/// Host.Create(
-/// configure: c => c with { ExtraPorts = [8081] },
-/// onReactorStart: r => IoxideTls.StartService(r, new TlsOptions { CertificatePath = cert, KeyPath = key }),
-/// connectionFactory: conn => conn.ListenerPort == 8081
-/// ? IoxideTls.AcceptAsync(conn)
-/// : new ValueTask<IDuplexPipe>(new ConnectionDualPipe(conn)));
-///
-///
-public static class IoxideTls
-{
- ///
- /// onReactorStart hook: start the ring-native TLS service (OpenSSL context) on this reactor.
- ///
- public static void StartService(Reactor reactor, TlsOptions options) => TlsService.Start(reactor, options);
-
- ///
- /// connectionFactory helper: TLS-terminate on the current reactor and
- /// return the duplex pipe the engine serves over. Requires to have run.
- ///
- public static async ValueTask AcceptAsync(Connection conn)
- {
- var session = await IoxideReactor.Current.GetService().AcceptAsync(conn);
- return new TlsDuplexPipe(conn, session);
- }
-}
diff --git a/Engine/Ioxide/Tls/TlsDuplexPipe.cs b/Engine/Ioxide/Tls/TlsDuplexPipe.cs
deleted file mode 100644
index 0bf52cdd2..000000000
--- a/Engine/Ioxide/Tls/TlsDuplexPipe.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System.Buffers;
-using System.IO.Pipelines;
-
-using ioxide;
-using ioxide.tls;
-
-namespace GenHTTP.Engine.Ioxide;
-
-///
-/// Adapts a TLS connection to the duplex pipe GenHTTP serves over. Inbound: a pump reads raw recv
-/// slices, decrypts each via the , and writes the plaintext into a Pipe the
-/// engine reads. Outbound: the engine writes plaintext to the connection's writer and kTLS TX (enabled
-/// during the handshake) has the kernel produce the records — so no explicit encrypt step.
-///
-internal sealed class TlsDuplexPipe : IDuplexPipe, IAsyncDisposable
-{
- private readonly Connection _conn;
-
- private readonly TlsSession _tls;
-
- private readonly Pipe _inbound;
-
- private readonly ConnectionDualPipe _outer; // only its writer is used (plaintext + kTLS TX)
-
- private readonly CancellationTokenSource _cts;
-
- private readonly Task _pump;
-
- public TlsDuplexPipe(Connection conn, TlsSession session)
- {
- _conn = conn;
- _tls = session;
- _inbound = new Pipe();
- _outer = new ConnectionDualPipe(conn);
- _cts = new CancellationTokenSource();
- _pump = PumpAsync(_cts.Token);
- }
-
- public PipeReader Input => _inbound.Reader;
-
- public PipeWriter Output => _outer.Output;
-
- private async Task PumpAsync(CancellationToken ct)
- {
- var writer = _inbound.Writer;
-
- try
- {
- // The client's first request can ride in bundled with its Finished flight.
- var initial = _tls.DrainPlaintext();
- if (!initial.IsEmpty)
- {
- writer.Write(initial);
- await writer.FlushAsync(ct);
- }
-
- while (!ct.IsCancellationRequested)
- {
- var snapshot = await _conn.ReadAsync();
-
- var produced = false;
-
- unsafe
- {
- while (_conn.TryGetItem(snapshot, out var item))
- {
- if (item.HasBuffer)
- {
- var plain = _tls.Decrypt(item.Ptr, item.Len);
- if (!plain.IsEmpty)
- {
- writer.Write(plain);
- produced = true;
- }
- }
-
- _conn.ReturnBuffer(in item);
- }
- }
-
- _conn.ResetRead();
-
- if (produced)
- {
- var flush = await writer.FlushAsync(ct);
- if (flush.IsCompleted)
- {
- break;
- }
- }
-
- if (snapshot.IsClosed)
- {
- break;
- }
- }
- }
- catch
- {
- // connection fault / cancellation — the reader is completed in finally
- }
- finally
- {
- await writer.CompleteAsync();
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- _cts.Cancel();
-
- try
- {
- await _pump;
- }
- catch
- {
- // ignore teardown faults
- }
-
- _tls.Dispose();
- _cts.Dispose();
- }
-}
diff --git a/Engine/Shared/GenHTTP.Engine.Shared.csproj b/Engine/Shared/GenHTTP.Engine.Shared.csproj
index fc8377e53..53a545973 100644
--- a/Engine/Shared/GenHTTP.Engine.Shared.csproj
+++ b/Engine/Shared/GenHTTP.Engine.Shared.csproj
@@ -26,6 +26,7 @@
+
diff --git a/Modules/IoxideFiles/AssetFreshness.cs b/Modules/IoxideFiles/AssetFreshness.cs
new file mode 100644
index 000000000..22ba712a6
--- /dev/null
+++ b/Modules/IoxideFiles/AssetFreshness.cs
@@ -0,0 +1,46 @@
+using ioxide.file;
+
+namespace GenHTTP.Modules.IoxideFiles;
+
+///
+/// Whether a snapshot's asset still matches the file on disk, by size - the same check
+/// ioxide.file performed until 0.4.167, when it moved to trusting descriptors for the lifetime of
+/// a snapshot. Reproduced here so this module keeps serving edited files without a reload.
+///
+internal static class AssetFreshness
+{
+
+ ///
+ /// True when the descriptor can be trusted. and
+ /// describe the file as it is now, so a caller that gets
+ /// false can still serve the changed file rather than 404 it.
+ ///
+ internal static bool IsFresh(in AssetCache.Asset asset, out bool exists, out long currentLength)
+ {
+ try
+ {
+ var info = new FileInfo(asset.Path);
+
+ exists = info.Exists;
+ currentLength = exists ? info.Length : 0;
+
+ return exists && currentLength == asset.Length;
+ }
+ catch (IOException)
+ {
+ // Racing with a rename or delete: treat as gone rather than serve a stale descriptor.
+ exists = false;
+ currentLength = 0;
+
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ exists = false;
+ currentLength = 0;
+
+ return false;
+ }
+ }
+
+}
diff --git a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
index 5173c7381..7a4bc1f18 100644
--- a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
+++ b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
@@ -19,7 +19,7 @@
-
+
diff --git a/Modules/IoxideFiles/IoxideAssetContent.cs b/Modules/IoxideFiles/IoxideAssetContent.cs
index 3f57ff8a4..448277b3b 100644
--- a/Modules/IoxideFiles/IoxideAssetContent.cs
+++ b/Modules/IoxideFiles/IoxideAssetContent.cs
@@ -1,6 +1,7 @@
using System.Buffers;
using Microsoft.Win32.SafeHandles;
+
using GenHTTP.Api.Protocol;
using GenHTTP.Engine.Ioxide;
@@ -14,6 +15,10 @@ namespace GenHTTP.Modules.IoxideFiles;
/// Writes one asset's body to the response sink, flush-disciplined so it never stages more than
/// bytes into the ioxide write slab at once. Re-resolves the asset under its own
/// lease, so nothing is held across an await.
+///
+/// The body is read positionally off the ring through a per-reactor pool.
+/// It cannot use the connection's write slab directly - TcpConnection.ReadFileAsync reads
+/// into that slab, and this writes into GenHTTP's response sink instead - so the copy stays.
///
public sealed class IoxideAssetContent(StaticAssets assets, string path, long length, ContentType contentType, ReadOnlyMemory? contentEncoding) : IResponseContent
{
@@ -39,24 +44,19 @@ public async ValueTask WriteAsync(IResponseSink sink)
return; // vanished between header and body (rare)
}
- if (AssetCache.IsFresh(asset, out var exists, out _))
+ // ioxide.file bakes no HTTP any more, so there is no cached response to write - the body
+ // is always read off the ring. The only question is WHICH file: the snapshot's descriptor
+ // when it still matches disk, or a fresh open when the file changed underneath it. The
+ // handler resolved the same question to set Content-Length, and `length` carries its
+ // answer, so the two cannot disagree.
+ if (AssetFreshness.IsFresh(asset, out var exists, out _))
{
- if (asset.Response != 0)
- {
- // Fresh + baked: write just the body (GenHTTP framed the header). The baked block is
- // header+body in native memory; the body is the trailing asset.Length bytes.
- await WriteNative(sink, asset.Response + (nint)(asset.ResponseLength - asset.Length), asset.Length);
- }
- else
- {
- // Fresh but too large to bake: read off the ring from the cached fd.
- await WriteFromDisk(sink, asset.Fd, length);
- }
+ await WriteFromDisk(sink, asset.Fd, length);
}
else if (exists)
{
- // Changed on disk (edit or atomic rename): open the current path fresh so a rename resolves
- // to the new inode, not the cached fd.
+ // Changed on disk (edit or atomic rename): open the current path so a rename resolves
+ // to the new inode rather than the descriptor the snapshot still holds.
await WriteChanged(sink, asset.Path, length);
}
}
@@ -80,6 +80,29 @@ private static async ValueTask WriteNative(IResponseSink sink, nint data, long l
private static unsafe void WriteChunk(IBufferWriter writer, nint data, int len)
=> writer.Write(new ReadOnlySpan((byte*)data, len));
+ private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len)
+ {
+ SafeFileHandle handle;
+
+ try
+ {
+ handle = File.OpenHandle(filePath);
+ }
+ catch
+ {
+ return; // raced with a delete
+ }
+
+ try
+ {
+ await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len);
+ }
+ finally
+ {
+ handle.Dispose();
+ }
+ }
+
private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long len)
{
var readers = RentPool();
@@ -108,29 +131,6 @@ private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long le
}
}
- private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len)
- {
- SafeFileHandle handle;
-
- try
- {
- handle = File.OpenHandle(filePath);
- }
- catch
- {
- return; // raced with a delete
- }
-
- try
- {
- await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len);
- }
- finally
- {
- handle.Dispose();
- }
- }
-
// The AssetReader pool is per-reactor; ioxide's GetService throws if absent, so create-and-self-
// register on first use on this reactor.
private static RingPool RentPool()
diff --git a/Modules/IoxideFiles/IoxideFilesHandler.cs b/Modules/IoxideFiles/IoxideFilesHandler.cs
index 5563a7f64..8db2de9aa 100644
--- a/Modules/IoxideFiles/IoxideFilesHandler.cs
+++ b/Modules/IoxideFiles/IoxideFilesHandler.cs
@@ -70,7 +70,9 @@ internal IoxideFilesHandler(StaticAssets assets)
return default; // raced away
}
- if (AssetCache.IsFresh(asset, out var exists, out var currentSize))
+ // This length becomes Content-Length, so it has to be the size the body writer will
+ // actually produce - hence resolving freshness here and not only at write time.
+ if (AssetFreshness.IsFresh(asset, out var exists, out var currentSize))
{
length = asset.Length;
}
diff --git a/Playground/Program.cs b/Playground/Program.cs
index 8333f9870..f4efc2b48 100644
--- a/Playground/Program.cs
+++ b/Playground/Program.cs
@@ -1,9 +1,290 @@
-using GenHTTP.Engine.Internal;
-
-using GenHTTP.Modules.IO;
-
-var app = Content.From(Resource.FromString("Hello World!"));
-
-await Host.Create()
- .Handler(app)
- .RunAsync();
+using System.Net;
+using System.Net.Security;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+using GenHTTP.Api.Infrastructure;
+
+using GenHTTP.Engine.Ioxide;
+
+using ioxide;
+
+using GenHTTP.Modules.Files;
+using GenHTTP.Modules.IO;
+using GenHTTP.Modules.Layouting;
+
+// The namespace and the class share a name, so the class needs an alias to be reachable.
+using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles;
+
+// One host, a protocol combination per port. HTTP/1.1 and HTTP/2 share a TCP socket, HTTP/3 is a
+// UDP socket on the same port number, and any combination of the three is allowed.
+//
+// http://localhost:8080 Http1 HTTP/1.1 only
+// http://localhost:8081 Http2 HTTP/2 only (h2c) - an HTTP/1.1 client is turned away
+// http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides
+// https://localhost:8443 All HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP
+// https://localhost:8444 Http1 HTTP/1.1 behind mutual TLS - a client certificate is
+// required, and one signed by the wrong CA is refused
+//
+// dotnet run -c Release --project Playground
+//
+// curl http://localhost:8080/ok
+// curl --http2-prior-knowledge http://localhost:8081/ok
+// curl --http1.1 http://localhost:8082/ok
+// curl -k --http1.1 https://localhost:8443/ok
+// curl -k --http2 https://localhost:8443/ok
+// curl -k --http3-only https://localhost:8443/ok
+// curl -k --cert certs/client.crt --key certs/client.key https://localhost:8444/ok
+//
+// The mutual TLS port needs a client certificate to answer at all, so the sample writes a CA, a
+// certificate signed by it and a second signed by nobody into ./certs on startup, and prints the
+// commands. Validation happens in OpenSSL against the CA - the endpoint's certificateValidator is
+// what marks the port as requiring one.
+//
+// Where two protocols share the TCP socket, ALPN decides during the handshake on a secure port and
+// the HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate,
+// since QUIC carries TLS 1.3 and has no cleartext mode.
+//
+// Http1AndHttp3 and Http2AndHttp3 exist too - HTTP/3 alongside just one of the TCP protocols - and
+// so does Http3 on its own, which opens a UDP socket and no TCP listener at all. Only one of them
+// can be live here: a server binds a single QUIC listener, so a second endpoint asking for HTTP/3
+// is refused at startup. Change what 8443 serves to try another.
+//
+// Browsers never try HTTP/3 first. They connect over TCP and only move to QUIC once a response has
+// told them where to look, so a browser-facing deployment adds an Alt-Svc header pointing at the
+// HTTP/3 port. curl reaches it directly with --http3-only, which is why the sample needs nothing.
+//
+// Two static handlers over the SAME directory, so the difference can be priced rather than argued:
+//
+// /ring/* IoxideFiles - ioxide.file opens every file once, shares the descriptors across
+// reactors and reads them positionally off the io_uring ring. Nothing is cached in
+// memory, so resident size stays flat whatever the asset set weighs.
+// /disk/* GenHTTP's built-in Files module, for comparison.
+//
+// GENHTTP_STATIC picks the directory; without it neither route is mounted.
+//
+// GENHTTP_STATIC=/srv/www dotnet run -c Release --project Playground
+// wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin
+// wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin
+
+var staticDir = Environment.GetEnvironmentVariable("GENHTTP_STATIC");
+
+var app = Layout.Create()
+ .Add("ok", Content.From(Resource.FromString("ok")));
+
+if (staticDir != null && Directory.Exists(staticDir))
+{
+ app = app.Add("ring", IoxideFilesModule.From(staticDir))
+ .Add("disk", Assets.From(staticDir));
+}
+
+// A throwaway certificate so the sample runs with no setup. Point GENHTTP_CERT at a PKCS#12 bundle
+// to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust.
+using var certificate = LoadCertificate();
+
+// ngtcp2 loads PEM by path, so serving HTTP/3 means having the certificate on disk. The engine will
+// not write one out on your behalf, so the sample writes its own throwaway certificate here and
+// names it below. A deployment points at the PEM it already has.
+var (serverCertPath, serverKeyPath) = WriteServerCertificate(certificate);
+
+// A CA, a client it signs, and an impostor it does not - so the mutual TLS port below can be tried
+// both ways without generating anything by hand.
+var clientCa = WriteClientCertificates();
+
+await Host.Create(
+ options: new EngineOptions
+ {
+ // What a port serves unless named below.
+ Protocols = Protocols.Http1,
+
+ Reactor = new ReactorOptions
+ {
+ ReactorCount = 2,
+ },
+
+ ProtocolsByPort =
+ {
+ [8081] = Protocols.Http2,
+ [8082] = Protocols.Http1AndHttp2,
+ [8443] = Protocols.All,
+ [8444] = Protocols.Http1,
+ },
+
+ Tcp = new TcpTransportOptions
+ {
+ // Hand the TLS record layer to the kernel instead of OpenSSL, which still
+ // performs the handshake. Both off because kTLS needs the Linux tls module
+ // and TLS 1.3, and without it the TLS ports fail every handshake silently:
+ //
+ // cat /proc/sys/net/ipv4/tcp_available_ulp # wanted: tls
+ TxKernelTls = false,
+ RxKernelTls = false,
+
+ // The rest of the TCP transport: accept backlog, the per-connection write
+ // slab and what happens when a response outgrows it, the connection pool,
+ // zero-copy send and recv queue depth. All shown at their defaults.
+ ListenBacklog = 1024,
+ WriteSlabSize = 16 * 1024,
+ WriteOverflow = WriteOverflowStrategy.Grow,
+ PoolMax = 1024,
+ ZeroCopySend = false,
+ RecvQueueEntries = 64,
+ },
+
+ Http3 = new Http3Options
+ {
+ // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every
+ // header literal, which costs bytes but can never stall a stream on a table
+ // update. In practice only browsers advertise a table of their own.
+ QpackDynamicTableCapacity = 4096,
+ QpackBlockedStreams = 100,
+
+ // The SAME certificate passed to Bind below, named again because ngtcp2
+ // loads PEM by path - OpenSSL, which terminates HTTP/1.1 and HTTP/2, takes
+ // the PEM text directly and touches no disk. Required for HTTP/3: the engine
+ // refuses to start a QUIC listener without it rather than writing a key out.
+ //
+ CertificatePath = serverCertPath,
+ KeyPath = serverKeyPath,
+ },
+ })
+ .Handler(app)
+ .Bind(IPAddress.Loopback, 8080)
+ .Bind(IPAddress.Loopback, 8081)
+ .Bind(IPAddress.Loopback, 8082)
+ .Bind(IPAddress.Loopback, 8443, certificate)
+ // mTLS
+ .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate(clientCa))
+ .RunAsync();
+
+///
+/// Writes the server certificate as PEM, so ngtcp2 can load it by path.
+///
+static (string Certificate, string Key) WriteServerCertificate(X509Certificate2 certificate)
+{
+ var directory = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "certs"));
+
+ var certPath = Path.Combine(directory.FullName, "server.crt");
+ var keyPath = Path.Combine(directory.FullName, "server.key");
+
+ File.WriteAllText(certPath, certificate.ExportCertificatePem());
+
+ WritePrivateKey(keyPath, certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem()
+ ?? throw new InvalidOperationException("The development certificate carries no RSA private key."));
+
+ return (certPath, keyPath);
+}
+
+///
+/// Writes a client CA, a certificate it signs, and one it does not, and returns the CA's path.
+///
+///
+/// A mutual TLS endpoint cannot be tried without a client certificate, so the sample makes one
+/// rather than asking you to. The impostor exists so the refusal can be seen as well as the pass.
+///
+static string WriteClientCertificates()
+{
+ var directory = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "certs"));
+
+ // One instant for every certificate here. Reading the clock per certificate gives the CA and
+ // the client it signs windows a second apart, and a leaf outliving its issuer is refused.
+ var from = DateTimeOffset.UtcNow.AddDays(-1);
+ var until = from.AddYears(1);
+
+ using var caKey = RSA.Create(2048);
+
+ var caRequest = new CertificateRequest("CN=playground client CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+
+ using var ca = caRequest.CreateSelfSigned(from, until);
+
+ var caPath = Path.Combine(directory.FullName, "client-ca.crt");
+ File.WriteAllText(caPath, ca.ExportCertificatePem());
+
+ Issue(ca, "client", "CN=alice", directory.FullName, from, until);
+ Issue(null, "impostor", "CN=mallory", directory.FullName, from, until);
+
+ return caPath;
+
+ static void Issue(X509Certificate2? issuer, string name, string subject, string directory,
+ DateTimeOffset from, DateTimeOffset until)
+ {
+ using var key = RSA.Create(2048);
+
+ var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ // Signed by the CA the server trusts, or self-signed - which is the impostor.
+ using var certificate = issuer is null
+ ? request.CreateSelfSigned(from, until)
+ : request.Create(issuer, from, until, Guid.NewGuid().ToByteArray());
+
+ File.WriteAllText(Path.Combine(directory, $"{name}.crt"), certificate.ExportCertificatePem());
+ WritePrivateKey(Path.Combine(directory, $"{name}.key"), key.ExportPkcs8PrivateKeyPem());
+ }
+}
+
+static X509Certificate2 LoadCertificate()
+{
+ if (Environment.GetEnvironmentVariable("GENHTTP_CERT") is { Length: > 0 } path && File.Exists(path))
+ {
+ return X509CertificateLoader.LoadPkcs12FromFile(path, Environment.GetEnvironmentVariable("GENHTTP_CERT_PASSWORD"));
+ }
+
+ using var key = RSA.Create(2048);
+
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ // Without a subject alternative name nothing modern will verify this, only skip the check.
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddDnsName("localhost");
+ names.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+
+ using var generated = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
+
+ // The private key has to come back through a PKCS#12 round trip before a TLS stack will use it.
+ return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null);
+}
+
+///
+/// Writes a private key readable only by this user.
+///
+///
+/// File.WriteAllText takes the umask, which on most machines leaves a key world-readable. These are
+/// throwaways, but a sample is read as an example of how to do it.
+///
+static void WritePrivateKey(string path, string pem)
+{
+ var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write };
+
+ if (!OperatingSystem.IsWindows())
+ {
+ options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ }
+
+ using var stream = new FileStream(path, options);
+ using var writer = new StreamWriter(stream);
+
+ writer.Write(pem);
+}
+
+///
+/// Marks an endpoint as requiring a client certificate, and names the CA the offered chain is
+/// validated against. Both travel with the endpoint, so another binding can require none or trust
+/// a different issuer.
+///
+///
+/// OpenSSL (or ngtcp2 on HTTP/3) validates the chain against that CA, so a bad one is refused
+/// before a request exists and is never called. Returning true here would
+/// not admit anyone the CA had already rejected.
+///
+internal sealed class RequireClientCertificate(string clientCaPath) : IMutualTlsValidator
+{
+ public bool RequireCertificate => true;
+
+ public string? ClientCaPath => clientCaPath;
+
+ public X509RevocationMode RevocationCheck => X509RevocationMode.NoCheck;
+
+ public bool Validate(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors policyErrors) => true;
+}
diff --git a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs
index 222279c06..fd865d8e0 100644
--- a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs
+++ b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs
@@ -4,7 +4,7 @@
using System.IO.Pipelines;
using System.Text;
-using GenHTTP.Engine.Ioxide.Protocol;
+using GenHTTP.Engine.Ioxide.Protocol.Http1;
namespace GenHTTP.Testing.Acceptance.Engine.Ioxide;