From 0a847584cc15fccbed9c5a53fd3a457a03a1bd76 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:08:53 +0100 Subject: [PATCH 01/16] feat: experimental HTTP/3 engine on Glyph3 and System.Net.Quic GenHTTP models HTTP/3 already - HttpProtocol.Http3 exists and endpoints carry an EnableQuic flag - but only the Kestrel engine acts on it. On the Internal and Ioxide engines the flag does nothing. This adds an engine that serves it. A separate engine rather than a mode of the Internal one, because the Internal engine's endpoint model is TCP by construction: the base EndPoint creates a SocketType.Stream socket, accepts Sockets, and hands each connection over as a single Stream. QUIC has none of those. A QuicEndPoint deriving from it would override everything but the constructor. Glyph3 does HTTP/3, System.Net.Quic does QUIC, and H3Connection is the bridge. Three things it has to reconcile: - Glyph3 is one state machine and MsQuic reads streams concurrently, so everything funnels through one channel and one consumer. - GenHTTP handlers do real I/O and must not run on that consumer, so they are started with Task.Run and their responses come back through a SynchronizationContext that posts to the pump. The context is installed only around calls into Glyph3: leaving it set made the pump post its own continuation to the queue only it drains, and wait for itself. - OpenUniStream is synchronous and OpenOutboundStreamAsync is not, so the unidirectional streams are opened before the connection starts. Two HTTP/1.1 assumptions had to be translated. HTTP/3 has no Host header, so :authority is surfaced as one (RFC 9114 4.3.1), without which GenHTTP's compliance check rejects every request. And :path carries the query string, so it is stripped before routing sees it. AltSvc.To(port) advertises the endpoint from a TCP server, which is the only way a browser ever reaches it. Verified against .NET's own HTTP/3 client: GET /hello -> HTTP/3.0 200 Hello from GenHTTP over HTTP/3! GET /json -> HTTP/3.0 200 {"ok":true} GET /missing -> HTTP/3.0 404 GET /hello?a=1&b=2 -> HTTP/3.0 200 (query no longer breaks routing) GET /hello -> HTTP/3.0 200 (connection reused) --- Engine/InternalH3Experimental/AltSvc.cs | 97 ++++++ ...nHTTP.Engine.InternalH3Experimental.csproj | 29 ++ Engine/InternalH3Experimental/Host.cs | 26 ++ .../Infrastructure/H3Server.cs | 89 +++++ .../Infrastructure/H3ServerHost.cs | 14 + .../Infrastructure/QuicEndPoint.cs | 140 ++++++++ .../Infrastructure/QuicEndPointCollection.cs | 41 +++ .../Protocol/H3Connection.cs | 310 ++++++++++++++++++ .../Protocol/H3KeyValueList.cs | 28 ++ .../Protocol/H3Request.cs | 123 +++++++ .../Protocol/H3RequestBody.cs | 25 ++ .../Protocol/H3RequestHeader.cs | 107 ++++++ .../Protocol/H3ResponseWriter.cs | 124 +++++++ .../InternalH3Experimental/Protocol/H3Sink.cs | 67 ++++ Engine/InternalH3Experimental/README.md | 42 +++ 15 files changed, 1262 insertions(+) create mode 100644 Engine/InternalH3Experimental/AltSvc.cs create mode 100644 Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj create mode 100644 Engine/InternalH3Experimental/Host.cs create mode 100644 Engine/InternalH3Experimental/Infrastructure/H3Server.cs create mode 100644 Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs create mode 100644 Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs create mode 100644 Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3Connection.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3Request.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3RequestBody.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs create mode 100644 Engine/InternalH3Experimental/Protocol/H3Sink.cs create mode 100644 Engine/InternalH3Experimental/README.md diff --git a/Engine/InternalH3Experimental/AltSvc.cs b/Engine/InternalH3Experimental/AltSvc.cs new file mode 100644 index 000000000..c3dad386f --- /dev/null +++ b/Engine/InternalH3Experimental/AltSvc.cs @@ -0,0 +1,97 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental; + +/// +/// Advertises an HTTP/3 endpoint from a server that speaks HTTP/1.1 or HTTP/2. +/// +/// +/// Browsers never start on HTTP/3. They connect over TCP, and only try QUIC once a response has +/// told them where to find it (RFC 7838). Without this header the HTTP/3 endpoint is reachable by +/// clients told to use it explicitly, and by nobody else. +/// +/// Two things stop it working, both silently: the advertisement is only honoured when it +/// arrives over TLS, and the certificate on the HTTP/3 port must be valid for the ORIGIN's host +/// name. A wrong port produces no error at all, the browser simply keeps using HTTP/1.1. +/// +public sealed class AltSvcConcern : IConcern +{ + private readonly ByteString _value; + + public IHandler Content { get; } + + public AltSvcConcern(IHandler content, ushort port, uint maxAge) + { + Content = content; + _value = new ByteString($"h3=\":{port}\"; ma={maxAge}"); + } + + public ValueTask PrepareAsync(IServer server) => Content.PrepareAsync(server); + + public async ValueTask HandleAsync(IRequest request) + { + IResponse? response = await Content.HandleAsync(request); + + // Pointless on a connection that is already HTTP/3, and ignored by clients anyway. + if (response is not null && request.Header.Protocol != HttpProtocol.Http3) + { + response.Rebuild().Header(AltSvcName, _value); + } + + return response; + } + + private static readonly ByteString AltSvcName = new("alt-svc"); +} + +/// +/// Builder for . +/// +public sealed class AltSvcConcernBuilder : IConcernBuilder +{ + private ushort _port = 443; + + private uint _maxAge = 86400; + + /// + /// The UDP port the HTTP/3 endpoint listens on. Must match what that server bound, or clients + /// silently never upgrade. + /// + public AltSvcConcernBuilder Port(ushort port) + { + _port = port; + return this; + } + + /// How long a client may cache the advertisement, in seconds. + public AltSvcConcernBuilder MaxAge(uint seconds) + { + _maxAge = seconds; + return this; + } + + public IConcern Build(IHandler content) => new AltSvcConcern(content, _port, _maxAge); +} + +/// +/// Advertises an HTTP/3 endpoint to clients arriving over TCP. +/// +public static class AltSvc +{ + + /// + /// Adds an Alt-Svc header pointing at an HTTP/3 endpoint on the given UDP port. + /// + /// + /// + /// var h1 = GenHTTP.Engine.Internal.Host.Create() + /// .Handler(app) + /// .Add(AltSvc.To(443)) + /// .Bind(IPAddress.Any, 443, certificate); + /// + /// + public static AltSvcConcernBuilder To(ushort port) => new AltSvcConcernBuilder().Port(port); + +} diff --git a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj new file mode 100644 index 000000000..5a3e68948 --- /dev/null +++ b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj @@ -0,0 +1,29 @@ + + + + + EXPERIMENTAL HTTP/3 engine for GenHTTP: QUIC via System.Net.Quic (MsQuic), HTTP/3 via Glyph3. Runs alongside another engine that serves HTTP/1.1 and advertises this one with Alt-Svc. + GenHTTP HTTP HTTP3 H3 QUIC Webserver Server Library C# Engine Experimental + README.md + + + $(NoWarn);CA1416 + + + + + + + + + + + + + + + + + + diff --git a/Engine/InternalH3Experimental/Host.cs b/Engine/InternalH3Experimental/Host.cs new file mode 100644 index 000000000..2697b1d42 --- /dev/null +++ b/Engine/InternalH3Experimental/Host.cs @@ -0,0 +1,26 @@ +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental; + +/// +/// Entry point to host an application over HTTP/3. +/// +/// +/// EXPERIMENTAL. QUIC comes from System.Net.Quic, which needs libmsquic present: Windows ships it +/// with the .NET runtime, Linux and macOS install it separately. HTTP/3 comes from Glyph3. +/// +/// Browsers do not reach HTTP/3 directly. They connect over HTTP/1.1 or HTTP/2 first and +/// only try QUIC once a server advertises it, so this engine is meant to run beside one that +/// serves TCP. See . +/// +public static class Host +{ + + /// + /// Provides a new server host serving HTTP/3 over QUIC. + /// + public static IServerHost Create() => new H3ServerHost(); + +} diff --git a/Engine/InternalH3Experimental/Infrastructure/H3Server.cs b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs new file mode 100644 index 000000000..cb171cab0 --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Reflection; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Infrastructure; +using GenHTTP.Engine.Shared.Types; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class H3Server : IServer +{ + private readonly QuicEndPointCollection _endPoints; + + private readonly PropertyBag _properties = new(); + + private readonly ILogger _logger; + + public string Version { get; } + + public bool Running => !_disposed; + + public bool Development => Configuration.DevelopmentMode; + + public IHandler Handler { get; } + + public IPropertyBag Properties => _properties; + + public ILoggerFactory Logging => Configuration.Logging; + + public IEndPointCollection EndPoints => _endPoints; + + internal ServerConfiguration Configuration { get; } + + internal H3Server(ServerConfiguration configuration, IHandler handler) + { + Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "(n/a)"; + + Configuration = configuration; + + Handler = handler; + + _logger = configuration.Logging.CreateLogger(); + + _endPoints = new QuicEndPointCollection(this, configuration.EndPoints); + } + + public async ValueTask StartAsync() + { + await PrepareHandlerAsync(Handler); + + await _endPoints.StartAsync(); + } + + private async ValueTask PrepareHandlerAsync(IHandler handler) + { + 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 bool _disposed; + + public ValueTask DisposeAsync() + { + if (!_disposed) + { + _endPoints.Dispose(); + + _disposed = true; + } + + return new(); + } +} diff --git a/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs new file mode 100644 index 000000000..ed6979ecd --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs @@ -0,0 +1,14 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Hosting; +using GenHTTP.Engine.Shared.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class H3ServerHost : ServerHost +{ + + protected override IServer Build(ServerConfiguration config, IHandler handler) => new H3Server(config, handler); + +} diff --git a/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs new file mode 100644 index 000000000..b3ece9262 --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Net.Quic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.InternalH3Experimental.Protocol; +using GenHTTP.Engine.Shared.Infrastructure; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +/// +/// A QUIC listener serving HTTP/3. +/// +/// +/// Does not derive from the Internal engine's EndPoint, which creates a TCP socket in its base +/// class and hands each connection over as a Stream. QUIC has neither. +/// +internal sealed class QuicEndPoint : IEndPoint +{ + private readonly IServer _server; + + private readonly ILogger _logger; + + private readonly EndPointConfiguration _configuration; + + private readonly CancellationTokenSource _shutdown = new(); + + private QuicListener? _listener; + + private Task? _accepting; + + internal QuicEndPoint(IServer server, EndPointConfiguration configuration) + { + _server = server; + _configuration = configuration; + _logger = server.Logging.CreateLogger(); + + Address = configuration.Address; + Port = configuration.Port; + DualStack = configuration.DualStack; + } + + public IPAddress? Address { get; } + + public ushort Port { get; } + + public bool DualStack { get; } + + // QUIC has no cleartext mode: TLS 1.3 is inside the transport. + public bool Secure => true; + + internal async ValueTask StartAsync() + { + if (!QuicListener.IsSupported) + { + throw new NotSupportedException( + "QUIC is not available on this system. libmsquic must be present and TLS 1.3 supported. " + + "See https://learn.microsoft.com/dotnet/fundamentals/networking/quic/quic-overview"); + } + + if (_configuration.Security is null) + { + throw new InvalidOperationException("An HTTP/3 endpoint requires a certificate; bind it with one."); + } + + X509Certificate2 certificate = _configuration.Security.CertificateProvider.Provide(null) + ?? throw new InvalidOperationException("The certificate provider did not supply a certificate for the HTTP/3 endpoint."); + + IPAddress address = Address ?? (DualStack ? IPAddress.IPv6Any : IPAddress.Any); + + _listener = await QuicListener.ListenAsync(new QuicListenerOptions + { + ListenEndPoint = new IPEndPoint(address, Port), + ApplicationProtocols = [SslApplicationProtocol.Http3], + ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions + { + DefaultStreamErrorCode = 0x010c, // H3_REQUEST_CANCELLED + DefaultCloseErrorCode = 0x0100, // H3_NO_ERROR + ServerAuthenticationOptions = new SslServerAuthenticationOptions + { + ApplicationProtocols = [SslApplicationProtocol.Http3], + ServerCertificate = certificate, + }, + }), + }); + + _logger.LogInformation("Listening on {Address}:{Port} (HTTP/3 over QUIC)", address, Port); + + _accepting = Task.Run(AcceptAsync); + } + + private async Task AcceptAsync() + { + try + { + while (!_shutdown.IsCancellationRequested) + { + QuicConnection connection = await _listener!.AcceptConnectionAsync(_shutdown.Token); + + _ = ServeAsync(connection); + } + } + catch (Exception e) + { + if (!_shutdown.IsCancellationRequested) + { + _logger.LogError(e, "Failed to accept incoming connection"); + } + } + } + + private async Task ServeAsync(QuicConnection connection) + { + try + { + await H3Connection.ServeAsync(connection, _server, this, _logger, _shutdown.Token); + } + catch (Exception e) + { + _logger.LogDebug(e, "Connection ended"); + } + } + + public void Dispose() + { + _shutdown.Cancel(); + + if (_listener is not null) + { + _listener.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _listener = null; + } + + _shutdown.Dispose(); + } +} diff --git a/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs b/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs new file mode 100644 index 000000000..dfd61400e --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs @@ -0,0 +1,41 @@ +using System.Collections; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class QuicEndPointCollection : IEndPointCollection, IDisposable +{ + private readonly List _endPoints; + + internal QuicEndPointCollection(IServer server, IEnumerable configuration) + { + _endPoints = configuration.Select(c => new QuicEndPoint(server, c)).ToList(); + } + + internal async ValueTask StartAsync() + { + foreach (QuicEndPoint endPoint in _endPoints) + { + await endPoint.StartAsync(); + } + } + + public IEndPoint this[int index] => _endPoints[index]; + + public int Count => _endPoints.Count; + + public IEnumerator GetEnumerator() => _endPoints.Cast().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Dispose() + { + foreach (QuicEndPoint endPoint in _endPoints) + { + endPoint.Dispose(); + } + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs new file mode 100644 index 000000000..7b586ca66 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -0,0 +1,310 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Quic; +using System.Threading.Channels; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using Glyph3; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// One HTTP/3 connection: MsQuic underneath, Glyph3 on top, GenHTTP's handler chain in the middle. +/// +/// +/// Glyph3 is a single state machine, so every call into it is funnelled through one channel and one +/// consumer. Handlers run off that thread and their responses are posted back, which is what lets +/// several requests be in flight on one connection without the parser ever seeing two threads. +/// +internal sealed class H3Connection : IHttp3Transport, IAsyncDisposable +{ + private readonly QuicConnection _quic; + + private readonly IServer _server; + + private readonly IEndPoint _endPoint; + + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _streams = new(); + + private readonly Queue _spareUniStreams = new(); + + private readonly Channel _ingress = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private readonly Channel _egress = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private Http3Connection? _h3; + + // Stream bytes, a stream ending, or a continuation that must run on the pump thread. + private readonly record struct Inbound(long StreamId, byte[]? Buffer, int Length, bool Fin, bool Closed, Action? Resume); + + private readonly record struct Outbound(long StreamId, byte[] Buffer, int Length, bool Fin); + + private H3Connection(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger) + { + _quic = quic; + _server = server; + _endPoint = endPoint; + _logger = logger; + } + + internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, CancellationToken cancellationToken) + { + await using var connection = new H3Connection(quic, server, endPoint, logger); + await connection.RunAsync(cancellationToken); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + // Opened before Glyph3 exists, because OpenUniStream answers synchronously. + for (int i = 0; i < 3; i++) + { + QuicStream uni = await _quic.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, cancellationToken); + _streams[uni.Id] = uni; + _spareUniStreams.Enqueue(uni); + } + + _h3 = new Http3Connection(this, DispatchAsync); + + Task accepting = AcceptStreamsAsync(cancellationToken); + Task writing = WriteLoopAsync(cancellationToken); + + try + { + await PumpAsync(cancellationToken); + } + finally + { + _h3.Close(); + _egress.Writer.TryComplete(); + _ingress.Writer.TryComplete(); + await Task.WhenAny(Task.WhenAll(accepting, writing), Task.Delay(1000, CancellationToken.None)); + } + } + + // Glyph3 calls this on the pump thread and awaits the result before submitting the response. + // Task.Run moves the handler chain off the pump, so its own awaits do not inherit the pump's + // context and it never blocks the parser. Glyph3's await, captured here, comes back through + // PumpContext, so the submit still happens on the pump thread. + private ValueTask DispatchAsync(Http3Request request) + => new(Task.Run(() => HandleAsync(request))); + + private async Task HandleAsync(Http3Request source) + { + try + { + // Glyph3 dispatches at end-of-headers, so the body is still arriving. Assemble it + // before the handler runs, which is the shape GenHTTP's IRequestBody expects. + ReadOnlyMemory body = await ReadBodyAsync(source); + + var request = new H3Request(_server, _endPoint, source, body, RemoteAddress()); + + IResponse response = await _server.Handler.HandleAsync(request) + ?? throw new InvalidOperationException("The root request handler did not return a response"); + + bool head = request.Header.Method == RequestMethod.Head; + + return await H3ResponseWriter.BuildAsync(response, head); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to handle request"); + return new Http3Response { Status = 500 }; + } + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + var context = new PumpContext(_ingress.Writer); + + Enter(context); + _h3!.Start(); + Leave(); + + // ConfigureAwait(false) matters: with the context installed the pump would post its OWN + // continuation to the queue only it drains, and wait forever for itself. + while (await _ingress.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + Enter(context); + + while (_ingress.Reader.TryRead(out Inbound item)) + { + if (item.Resume is { } resume) + { + resume(); + } + else if (item.Closed) + { + _h3.OnStreamClosed(item.StreamId); + } + else + { + _h3.Feed(item.StreamId, item.Buffer.AsSpan(0, item.Length), item.Fin); + + if (item.Buffer is not null) + { + ArrayPool.Shared.Return(item.Buffer); + } + } + } + + _h3.Flush(); + + Leave(); + + if (_h3.IsFaulted) + { + return; + } + } + } + + // Installed only around calls into Glyph3, so anything it awaits while dispatching resumes on + // the pump rather than the thread pool. + private static void Enter(SynchronizationContext context) => SynchronizationContext.SetSynchronizationContext(context); + + private static void Leave() => SynchronizationContext.SetSynchronizationContext(null); + + private static async ValueTask> ReadBodyAsync(Http3Request source) + { + if (source.BodyReader is not { } reader) + { + return source.Body; + } + + ArrayBufferWriter? assembled = null; + + while (true) + { + ReadOnlyMemory chunk = await reader.ReadAsync(); + + if (chunk.IsEmpty) + { + break; + } + + assembled ??= new ArrayBufferWriter(chunk.Length); + assembled.Write(chunk.Span); + } + + return assembled?.WrittenMemory ?? ReadOnlyMemory.Empty; + } + + private IPAddress? RemoteAddress() + => _quic.RemoteEndPoint is IPEndPoint endpoint ? endpoint.Address : null; + + private async Task AcceptStreamsAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + QuicStream stream = await _quic.AcceptInboundStreamAsync(cancellationToken); + _streams[stream.Id] = stream; + _ = ReadStreamAsync(stream, cancellationToken); + } + } + catch (Exception) + { + // The connection closed, which is how an accept loop ends. + } + } + + private async Task ReadStreamAsync(QuicStream stream, CancellationToken cancellationToken) + { + try + { + while (true) + { + byte[] buffer = ArrayPool.Shared.Rent(16 * 1024); + int read = await stream.ReadAsync(buffer, cancellationToken); + + if (read == 0) + { + ArrayPool.Shared.Return(buffer); + await _ingress.Writer.WriteAsync(new Inbound(stream.Id, null, 0, true, false, null), cancellationToken); + return; + } + + await _ingress.Writer.WriteAsync(new Inbound(stream.Id, buffer, read, false, false, null), cancellationToken); + } + } + catch (Exception) + { + _ingress.Writer.TryWrite(new Inbound(stream.Id, null, 0, false, true, null)); + } + } + + private async Task WriteLoopAsync(CancellationToken cancellationToken) + { + try + { + while (await _egress.Reader.WaitToReadAsync(cancellationToken)) + { + while (_egress.Reader.TryRead(out Outbound item)) + { + if (_streams.TryGetValue(item.StreamId, out QuicStream? stream)) + { + if (item.Length > 0) + { + await stream.WriteAsync(item.Buffer.AsMemory(0, item.Length), item.Fin, cancellationToken); + } + else if (item.Fin) + { + stream.CompleteWrites(); + } + } + ArrayPool.Shared.Return(item.Buffer); + } + } + } + catch (Exception) + { + // Peer went away mid-write. + } + } + + public long OpenUniStream() => _spareUniStreams.TryDequeue(out QuicStream? stream) ? stream.Id : -1; + + public void Send(long streamId, ReadOnlySpan data, bool fin) + { + byte[] buffer = ArrayPool.Shared.Rent(Math.Max(1, data.Length)); + data.CopyTo(buffer); + _egress.Writer.TryWrite(new Outbound(streamId, buffer, data.Length, fin)); + } + + public async ValueTask DisposeAsync() + { + foreach (QuicStream stream in _streams.Values) + { + await stream.DisposeAsync(); + } + await _quic.DisposeAsync(); + } + + /// + /// Posts continuations back to the pump, so anything Glyph3 awaits resumes on the one thread + /// allowed to touch it. + /// + private sealed class PumpContext : SynchronizationContext + { + private readonly ChannelWriter _pump; + + internal PumpContext(ChannelWriter pump) => _pump = pump; + + public override void Post(SendOrPostCallback d, object? state) + => _pump.TryWrite(new Inbound(0, null, 0, false, false, () => d(state))); + + public override void Send(SendOrPostCallback d, object? state) => Post(d, state); + + public override SynchronizationContext CreateCopy() => this; + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs b/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs new file mode 100644 index 000000000..3f970ffef --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs @@ -0,0 +1,28 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// Header and query lists over Glyph3's decoded fields. +/// +/// +/// The engine carries its own rather than reusing the shared one, which wraps Glyph11's list and +/// therefore assumes an HTTP/1.1 parse. +/// +internal sealed class H3KeyValueList : IRequestHeaders, IRequestQuery +{ + private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; + + internal H3KeyValueList(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/InternalH3Experimental/Protocol/H3Request.cs b/Engine/InternalH3Experimental/Protocol/H3Request.cs new file mode 100644 index 000000000..5c5194fec --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Request.cs @@ -0,0 +1,123 @@ +using System.IO.Pipelines; +using System.Net; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// An over a Glyph3 request. +/// +/// +/// Not the shared , whose Source is a Glyph11 BinaryRequest and so assumes an +/// HTTP/1.1 parse. Nothing here is pooled: HTTP/3 multiplexes, so several of these are live on one +/// connection at once and a per-connection pool would need locking to be safe. +/// +internal sealed class H3Request : IRequest +{ + private readonly H3RequestBody? _body; + + private readonly ClientConnection _client = new(); + + private readonly PropertyBag _properties = new(); + + private readonly ResponseBuilder _response = new(); + + private Func? _bodyWrapper; + + private IRequestBody? _wrappedBody; + + private bool _bodyFetched; + + internal H3Request(IServer server, IEndPoint endPoint, Http3Request source, ReadOnlyMemory body, IPAddress? remoteAddress) + { + Server = server; + EndPoint = endPoint; + + Header = new H3RequestHeader(source, ParseQuery(source.Path)); + + _body = body.IsEmpty ? null : new H3RequestBody(body); + + _client.Apply(remoteAddress, ClientProtocol.Https, 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 _wrappedBody = _bodyWrapper is not null ? _bodyWrapper(_body) : _body; + } + + public void WrapBody(Func wrapper) => _bodyWrapper = wrapper; + + public IResponseBuilder Respond() => _response.Status(ResponseStatus.Ok); + + /// + /// Not supported. Upgrading a request to a raw byte stream is an HTTP/1.1 mechanism, and QUIC + /// streams are reached through the transport rather than through the request. + /// + public PipeReader Upgrade() + => throw new NotSupportedException("Connection upgrades are not available over HTTP/3."); + + public ValueTask DisposeAsync() => new(); + + // The query string, which HTTP/3 carries inside :path exactly as HTTP/1.1 carries it inside the + // request target. + private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> ParseQuery(ReadOnlyMemory path) + { + var parameters = new List<(ReadOnlyMemory, ReadOnlyMemory)>(); + + int mark = path.Span.IndexOf((byte)'?'); + if (mark < 0) + { + return parameters; + } + + ReadOnlyMemory query = path[(mark + 1)..]; + + while (!query.IsEmpty) + { + int end = query.Span.IndexOf((byte)'&'); + ReadOnlyMemory pair = end < 0 ? query : query[..end]; + query = end < 0 ? default : query[(end + 1)..]; + + if (pair.IsEmpty) + { + continue; + } + + int equals = pair.Span.IndexOf((byte)'='); + + parameters.Add(equals < 0 + ? (pair, ReadOnlyMemory.Empty) + : (pair[..equals], pair[(equals + 1)..])); + } + + return parameters; + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs b/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs new file mode 100644 index 000000000..b96e42eda --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs @@ -0,0 +1,25 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// A request body that Glyph3 has already assembled. +/// +/// +/// Buffered because this engine uses Glyph3's buffered dispatch: the handler runs once the whole +/// request has arrived. Glyph3 also has a streamed flavour, which would replace this with a reader +/// the handler pulls while the body is still in flight. +/// +internal sealed class H3RequestBody : IRequestBody +{ + private readonly ReadOnlyMemory _content; + + internal H3RequestBody(ReadOnlyMemory content) + { + _content = content; + } + + public Stream AsStream() => new MemoryStream(_content.ToArray(), writable: false); + + public ValueTask> AsMemoryAsync() => new(_content); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs b/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs new file mode 100644 index 000000000..81eeaaf57 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs @@ -0,0 +1,107 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// An over a Glyph3 request. +/// +internal sealed class H3RequestHeader : IRequestHeader +{ + private readonly H3KeyValueList _headers; + + private readonly H3KeyValueList _query; + + private readonly RequestTarget _target; + + internal H3RequestHeader(Http3Request source, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query) + { + _headers = new H3KeyValueList(WithHost(source)); + _query = new H3KeyValueList(query); + + _target = new RequestTarget(); + + // :path carries the query string, exactly as an HTTP/1.1 request target does. Routing must + // see the path alone, or every request with a query 404s. + Path = new ByteString(WithoutQuery(source.Path)); + Method = new RequestMethod(source.Method); + + _target.Apply(Path); + } + + public RequestMethod Method { get; } + + public ByteString Path { get; } + + public IRequestTarget Target => _target; + + // Always HTTP/3: there is no version on the wire to read. QUIC settles it, and ALPN said "h3" + // before a single byte of this request arrived. + public HttpProtocol Protocol => HttpProtocol.Http3; + + public ReadOnlyMemory Version => Http3Version; + + public IRequestHeaders Headers => _headers; + + public IRequestQuery Query => _query; + + /// + /// HTTP/3 carries the authority as the :authority pseudo-header and clients omit Host entirely. + /// RFC 9114 4.3.1 has an intermediary translating to HTTP/1.1 construct Host from it, which is + /// what this does: everything above the engine expects a Host header to exist. + /// + private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> WithHost(Http3Request source) + { + if (source.Authority.IsEmpty) + { + return source.Headers; + } + + foreach ((ReadOnlyMemory name, ReadOnlyMemory _) in source.Headers) + { + if (name.Length == 4 && Matches(name.Span, "host"u8)) + { + return source.Headers; + } + } + + var headers = new List<(ReadOnlyMemory, ReadOnlyMemory)>(source.Headers.Count + 1) + { + (HostName, source.Authority), + }; + + headers.AddRange(source.Headers); + + return headers; + } + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + for (int i = 0; i < name.Length; i++) + { + byte 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) + { + int mark = path.Span.IndexOf((byte)'?'); + return mark < 0 ? path : path[..mark]; + } + + private static readonly ReadOnlyMemory HostName = "host"u8.ToArray(); + + private static readonly ReadOnlyMemory Http3Version = "HTTP/3.0"u8.ToArray(); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs new file mode 100644 index 000000000..869038174 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs @@ -0,0 +1,124 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// Turns a GenHTTP into a Glyph3 response. +/// +internal static class H3ResponseWriter +{ + + internal static async ValueTask BuildAsync(IResponse response, bool headRequest) + { + var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(); + + for (int i = 0; i < response.Headers.Count; i++) + { + KeyValuePair, ReadOnlyMemory> header = response.Headers.GetMemoryEntry(i); + + // Connection-specific fields are malformed in HTTP/3 (RFC 9114 4.2), and a peer may + // treat them as a protocol error rather than ignore them. + if (!IsConnectionSpecific(header.Key.Span)) + { + headers.Add((Lowercase(header.Key), header.Value)); + } + } + + ReadOnlyMemory body = default; + + 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 HEAD response keeps the headers its GET would have produced and sends no body. + if (!headRequest) + { + var buffer = new ArrayBufferWriter( + content.Length is { } length and > 0 and < int.MaxValue ? (int)length : 4096); + + await content.WriteAsync(new H3Sink(buffer)); + + body = buffer.WrittenMemory; + } + } + + var result = new Http3Response + { + Status = (int)response.Status, + Body = body, + }; + + foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in headers) + { + result.Headers.Add((name, value)); + } + + return result; + } + + // HTTP/3 requires lowercase field names; anything else is a malformed message. + private static ReadOnlyMemory Lowercase(ReadOnlyMemory name) + { + ReadOnlySpan span = name.Span; + + for (int i = 0; i < span.Length; i++) + { + if (span[i] is >= (byte)'A' and <= (byte)'Z') + { + byte[] lowered = name.ToArray(); + for (int j = 0; j < lowered.Length; j++) + { + if (lowered[j] is >= (byte)'A' and <= (byte)'Z') + { + lowered[j] += 32; + } + } + return lowered; + } + } + + return name; + } + + 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, "content-length"u8); + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + if (name.Length != lowercase.Length) + { + return false; + } + + for (int i = 0; i < name.Length; i++) + { + byte c = name[i]; + if (c is >= (byte)'A' and <= (byte)'Z') + { + c += 32; + } + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } + + private static readonly ReadOnlyMemory ContentTypeName = "content-type"u8.ToArray(); + private static readonly ReadOnlyMemory ContentEncodingName = "content-encoding"u8.ToArray(); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3Sink.cs b/Engine/InternalH3Experimental/Protocol/H3Sink.cs new file mode 100644 index 000000000..087c37902 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Sink.cs @@ -0,0 +1,67 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// The sink a response body writes into. +/// +internal sealed class H3Sink : IResponseSink +{ + private readonly ArrayBufferWriter _writer; + + internal H3Sink(ArrayBufferWriter writer) + { + _writer = writer; + } + + public IBufferWriter Writer => _writer; + + public Stream Stream => _stream ??= new BufferWriterStream(_writer); + + private Stream? _stream; + + // Content that writes to a Stream rather than an IBufferWriter, which most of the IO module + // does. + private sealed class BufferWriterStream : Stream + { + private readonly IBufferWriter _target; + + internal BufferWriterStream(IBufferWriter target) => _target = target; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + 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) => _target.Write(buffer); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _target.Write(buffer.Span); + return new ValueTask(); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _target.Write(buffer.AsSpan(offset, count)); + return Task.CompletedTask; + } + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + 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/InternalH3Experimental/README.md b/Engine/InternalH3Experimental/README.md new file mode 100644 index 000000000..e0c954da9 --- /dev/null +++ b/Engine/InternalH3Experimental/README.md @@ -0,0 +1,42 @@ +# GenHTTP HTTP/3 Engine (experimental) + +Serves an application over HTTP/3, using [Glyph3](https://github.com/dotnet-web-stack/Glyph3) for +HTTP/3 and `System.Net.Quic` (MsQuic) for QUIC. + +```csharp +var h3 = GenHTTP.Engine.InternalH3Experimental.Host.Create() + .Handler(app) + .Bind(IPAddress.Any, 443, certificate); +``` + +Browsers never start on HTTP/3. They connect over TCP first and only try QUIC once a response tells +them where to find it, so this engine is meant to run beside one that serves HTTP/1.1: + +```csharp +var h1 = GenHTTP.Engine.Internal.Host.Create() + .Handler(app) + .Add(AltSvc.To(443)) // Alt-Svc: h3=":443"; ma=86400 + .Bind(IPAddress.Any, 443, certificate); +``` + +TCP:443 and UDP:443 are different sockets, so both bind at once. Two things stop the upgrade +working, both silently: `Alt-Svc` is only honoured when it arrives over TLS, and the certificate on +the HTTP/3 port must be valid for the origin's host name. + +## Requirements + +QUIC needs libmsquic present. Windows 11 / Server 2022+ ships it with the .NET runtime; Linux +installs it (`sudo apt install libmsquic`); macOS uses Homebrew plus `DYLD_FALLBACK_LIBRARY_PATH`. +The engine throws at startup when it is missing rather than failing later. + +## Status + +Experimental. Known gaps: + +- Request bodies are assembled before the handler runs, so a large upload is held in memory. + Glyph3 can stream them; the engine does not yet. +- Response bodies are buffered for the same reason. +- `IRequest.Upgrade()` throws: connection upgrades are an HTTP/1.1 mechanism. +- No server push, no trailers, no 0-RTT. +- Requests carry their own `IRequest` implementation rather than the shared `Request`, whose + `Source` is a Glyph11 `BinaryRequest` and therefore assumes an HTTP/1.1 parse. From 23361bcf37273a3d6399141a49882927d8499892 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:11:06 +0100 Subject: [PATCH 02/16] build: add the HTTP/3 engine to the solution --- GenHTTP.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/GenHTTP.slnx b/GenHTTP.slnx index b3128e179..e94424b79 100644 --- a/GenHTTP.slnx +++ b/GenHTTP.slnx @@ -13,6 +13,7 @@ + From 9571ebfd5bb6eed720ef1d448d41dbfd51d1a188 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:18:02 +0100 Subject: [PATCH 03/16] docs: playground example serving one app over HTTP/1.1 and HTTP/3 Both engines bind 8443, since TCP and UDP are different sockets, and the HTTP/1.1 host advertises the HTTP/3 one with Alt-Svc. That advertisement is the only way a browser ever reaches HTTP/3, and nothing verifies the port in it matches what the other host bound. Verified from one process: TCP :8443 HTTP/1.1 200 OK, alt-svc: h3=":8443"; ma=86400 UDP :8443 HTTP/3.0 200, same handler, same body --- Playground/GenHTTP.Playground.csproj | 119 ++++++++++++++------------- Playground/Program.cs | 72 ++++++++++++++-- 2 files changed, 123 insertions(+), 68 deletions(-) diff --git a/Playground/GenHTTP.Playground.csproj b/Playground/GenHTTP.Playground.csproj index 19ebe7ed1..28f9bbfad 100644 --- a/Playground/GenHTTP.Playground.csproj +++ b/Playground/GenHTTP.Playground.csproj @@ -1,59 +1,60 @@ - - - - - Exe - net11.0 - true - - false - true - - runtime-async=on - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Exe + net11.0 + true + + false + true + + runtime-async=on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Playground/Program.cs b/Playground/Program.cs index 8333f9870..4843c7d09 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,9 +1,63 @@ -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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Engine.InternalH3Experimental; + +using GenHTTP.Modules.IO; +using GenHTTP.Modules.Layouting; + +// Serves one application over HTTP/1.1 and HTTP/3 at once. +// +// curl -k https://localhost:8443/hello # HTTP/1.1, over TCP +// curl -k --http3 https://localhost:8443/hello # HTTP/3, over QUIC +// curl -k -i https://localhost:8443/hello | grep -i alt-svc +// +// TCP:8443 and UDP:8443 are different sockets, so both engines bind the same number. +// +// A browser will only reach HTTP/3 via the Alt-Svc header below, and only after it has already +// loaded the page over TCP once. It also wants a certificate it trusts, which the self-signed one +// here is not, so use curl --http3 to see HTTP/3 actually serve. +// +// QUIC needs libmsquic: shipped with the .NET runtime on Windows, `apt install libmsquic` on Linux, +// `brew install libmsquic` on macOS. + +const ushort Port = 8443; + +var app = Layout.Create() + .Add("hello", Content.From(Resource.FromString("Hello World!"))); + +var certificate = CreateDevelopmentCertificate(); + +// HTTP/3 first, so it is listening before anything advertises it. +var h3 = Host.Create() + .Handler(app) + .Bind(IPAddress.Loopback, Port, certificate); + +await h3.StartAsync(); + +// HTTP/1.1, advertising the endpoint above. The port has to match, and nothing checks that it does: +// get it wrong and clients simply never upgrade. +await GenHTTP.Engine.Internal.Host.Create() + .Handler(app) + .Add(AltSvc.To(Port)) + .Bind(IPAddress.Loopback, Port, certificate) + .RunAsync(); + +static X509Certificate2 CreateDevelopmentCertificate() +{ + using var key = RSA.Create(2048); + + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Without a SAN nothing modern will verify this, only skip it with -k. + 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 PFX round-trip before a TLS stack will use it. + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); +} From 916dec2be7245bd9e9968d8b8e5cf7aab75f9828 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:19:24 +0100 Subject: [PATCH 04/16] first idea, both h1 and h3 serving --- Playground/Program.cs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 4843c7d09..b8ccb2cbe 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -7,22 +7,13 @@ using GenHTTP.Modules.IO; using GenHTTP.Modules.Layouting; -// Serves one application over HTTP/1.1 and HTTP/3 at once. -// // curl -k https://localhost:8443/hello # HTTP/1.1, over TCP // curl -k --http3 https://localhost:8443/hello # HTTP/3, over QUIC // curl -k -i https://localhost:8443/hello | grep -i alt-svc // // TCP:8443 and UDP:8443 are different sockets, so both engines bind the same number. -// -// A browser will only reach HTTP/3 via the Alt-Svc header below, and only after it has already -// loaded the page over TCP once. It also wants a certificate it trusts, which the self-signed one -// here is not, so use curl --http3 to see HTTP/3 actually serve. -// -// QUIC needs libmsquic: shipped with the .NET runtime on Windows, `apt install libmsquic` on Linux, -// `brew install libmsquic` on macOS. -const ushort Port = 8443; +const ushort H3Port = 8443; var app = Layout.Create() .Add("hello", Content.From(Resource.FromString("Hello World!"))); @@ -32,7 +23,7 @@ // HTTP/3 first, so it is listening before anything advertises it. var h3 = Host.Create() .Handler(app) - .Bind(IPAddress.Loopback, Port, certificate); + .Bind(IPAddress.Loopback, H3Port, certificate); await h3.StartAsync(); @@ -40,8 +31,8 @@ // get it wrong and clients simply never upgrade. await GenHTTP.Engine.Internal.Host.Create() .Handler(app) - .Add(AltSvc.To(Port)) - .Bind(IPAddress.Loopback, Port, certificate) + .Add(AltSvc.To(H3Port)) + .Bind(IPAddress.Loopback, H3Port, certificate) .RunAsync(); static X509Certificate2 CreateDevelopmentCertificate() From 6d3771622d529e1d5d0602b570c8704f76960928 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:43:01 +0100 Subject: [PATCH 05/16] fix: release finished request streams, and off the writer loop Two bugs a load test found, both in the MsQuic bridge. A finished request stream was never disposed, so its stream credit was never returned to the peer. Every connection stalled after MaxInboundBidirectionalStreams requests: exactly 800 completed and 256 failed across 8 connections, run after run, which is a limit rather than a measurement. Disposing it then had to happen off the writer loop. Awaiting DisposeAsync there serialised every other request on the connection behind one stream's teardown: 612 req/s with the await, 235,704 without. Unidirectional streams are exempt: those are the control and QPACK streams and live as long as the connection. Also renames the playground's port constant, which said H3Port while the HTTP/1.1 host bound it too. Both hosts binding 8443 is correct - TCP and UDP are different sockets - but the name read like a bug. --- .../InternalH3Experimental/Protocol/H3Connection.cs | 11 +++++++++++ Playground/Program.cs | 11 +++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 7b586ca66..95ba793b4 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -261,6 +261,17 @@ private async Task WriteLoopAsync(CancellationToken cancellationToken) { stream.CompleteWrites(); } + + // A finished request stream must be released, or its stream credit is never + // returned and the peer stalls after MaxInboundBidirectionalStreams + // requests. Unidirectional streams are the connection's control and QPACK + // streams and live as long as it does. + if (item.Fin && (item.StreamId & 0x3) == 0x0 && _streams.TryRemove(item.StreamId, out QuicStream? finished)) + { + // Off the writer: tearing a stream down is slow enough that awaiting it + // here serialises every other request behind it. + _ = finished.DisposeAsync().AsTask(); + } } ArrayPool.Shared.Return(item.Buffer); } diff --git a/Playground/Program.cs b/Playground/Program.cs index b8ccb2cbe..f9a6d1625 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -13,7 +13,10 @@ // // TCP:8443 and UDP:8443 are different sockets, so both engines bind the same number. -const ushort H3Port = 8443; +// One number, two sockets: the HTTP/1.1 host binds TCP 8443 and the HTTP/3 host binds +// UDP 8443. They do not collide, and Alt-Svc can then advertise the same port the client is +// already talking to. +const ushort Port = 8443; var app = Layout.Create() .Add("hello", Content.From(Resource.FromString("Hello World!"))); @@ -23,7 +26,7 @@ // HTTP/3 first, so it is listening before anything advertises it. var h3 = Host.Create() .Handler(app) - .Bind(IPAddress.Loopback, H3Port, certificate); + .Bind(IPAddress.Loopback, Port, certificate); await h3.StartAsync(); @@ -31,8 +34,8 @@ // get it wrong and clients simply never upgrade. await GenHTTP.Engine.Internal.Host.Create() .Handler(app) - .Add(AltSvc.To(H3Port)) - .Bind(IPAddress.Loopback, H3Port, certificate) + .Add(AltSvc.To(Port)) + .Bind(IPAddress.Loopback, Port, certificate) .RunAsync(); static X509Certificate2 CreateDevelopmentCertificate() From 2b0d5d62adf5bd07c773825ab123087dcef81b04 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:53:31 +0100 Subject: [PATCH 06/16] perf: run the handler inline instead of on the thread pool Dispatch started every request with Task.Run and waited for the response to come back through the pump's SynchronizationContext, so each one paid two thread transitions. Under load that also starved the pool: requests failed outright, around 100 per ten-second run. Starting the handler inline costs nothing when the chain completes synchronously, which is the common case, and Glyph3 submits without ever leaving the pump. A handler that genuinely suspends still returns a Task at its first incomplete await and resumes through PumpContext, so the pump is only ever held for the synchronous prefix - the same contract any event loop offers, and the same one ioxide's reactor has. 31,144 -> 218,409 req/s, and failures went from ~100 per run to zero. --- Engine/InternalH3Experimental/Protocol/H3Connection.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 95ba793b4..4a9311eb4 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -95,7 +95,15 @@ private async Task RunAsync(CancellationToken cancellationToken) // context and it never blocks the parser. Glyph3's await, captured here, comes back through // PumpContext, so the submit still happens on the pump thread. private ValueTask DispatchAsync(Http3Request request) - => new(Task.Run(() => HandleAsync(request))); + { + // Start the handler INLINE. A chain that completes synchronously - which the common case + // does - then costs no thread hop at all, and Glyph3 submits the response without ever + // leaving the pump. Only a handler that actually suspends pays for a continuation, and it + // comes back through PumpContext. + Task pending = HandleAsync(request); + + return new ValueTask(pending); + } private async Task HandleAsync(Http3Request source) { From 96e3bdc1a4302313431fdcb3d7de2240fdfc008d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 15:56:11 +0100 Subject: [PATCH 07/16] docs: turn request logging off in the playground A console line per request is enough to dominate a throughput measurement, and it was the first thing that had to be switched off to benchmark the engine. The comment says how to get it back. --- Playground/Program.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Playground/Program.cs b/Playground/Program.cs index f9a6d1625..7f54c7ba2 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -7,6 +7,8 @@ using GenHTTP.Modules.IO; using GenHTTP.Modules.Layouting; +using Microsoft.Extensions.Logging.Abstractions; + // curl -k https://localhost:8443/hello # HTTP/1.1, over TCP // curl -k --http3 https://localhost:8443/hello # HTTP/3, over QUIC // curl -k -i https://localhost:8443/hello | grep -i alt-svc @@ -24,8 +26,12 @@ var certificate = CreateDevelopmentCertificate(); // HTTP/3 first, so it is listening before anything advertises it. +// +// Request logging is off on both hosts: it writes a console line per request, which is enough to +// dominate a throughput measurement. Drop the Logging call to get it back. var h3 = Host.Create() .Handler(app) + .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate); await h3.StartAsync(); @@ -35,6 +41,7 @@ await GenHTTP.Engine.Internal.Host.Create() .Handler(app) .Add(AltSvc.To(Port)) + .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate) .RunAsync(); From 08267fde9bec991541901fcea992eb9c175cc8a3 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 16:49:29 +0100 Subject: [PATCH 08/16] docs: the class comments still described the Task.Run dispatch --- .../InternalH3Experimental/Protocol/H3Connection.cs | 7 ++----- Playground/Program.cs | 12 ++---------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 4a9311eb4..aa1a3866d 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -18,8 +18,8 @@ namespace GenHTTP.Engine.InternalH3Experimental.Protocol; /// /// /// Glyph3 is a single state machine, so every call into it is funnelled through one channel and one -/// consumer. Handlers run off that thread and their responses are posted back, which is what lets -/// several requests be in flight on one connection without the parser ever seeing two threads. +/// consumer - the pump. Handlers start on that thread and, if they suspend, resume back onto it +/// through PumpContext, so several requests can be in flight without the parser seeing two threads. /// internal sealed class H3Connection : IHttp3Transport, IAsyncDisposable { @@ -91,9 +91,6 @@ private async Task RunAsync(CancellationToken cancellationToken) } // Glyph3 calls this on the pump thread and awaits the result before submitting the response. - // Task.Run moves the handler chain off the pump, so its own awaits do not inherit the pump's - // context and it never blocks the parser. Glyph3's await, captured here, comes back through - // PumpContext, so the submit still happens on the pump thread. private ValueTask DispatchAsync(Http3Request request) { // Start the handler INLINE. A chain that completes synchronously - which the common case diff --git a/Playground/Program.cs b/Playground/Program.cs index 7f54c7ba2..0e9e66064 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -15,9 +15,6 @@ // // TCP:8443 and UDP:8443 are different sockets, so both engines bind the same number. -// One number, two sockets: the HTTP/1.1 host binds TCP 8443 and the HTTP/3 host binds -// UDP 8443. They do not collide, and Alt-Svc can then advertise the same port the client is -// already talking to. const ushort Port = 8443; var app = Layout.Create() @@ -25,19 +22,14 @@ var certificate = CreateDevelopmentCertificate(); -// HTTP/3 first, so it is listening before anything advertises it. -// -// Request logging is off on both hosts: it writes a console line per request, which is enough to -// dominate a throughput measurement. Drop the Logging call to get it back. var h3 = Host.Create() .Handler(app) .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate); -await h3.StartAsync(); +await h3.StartAsync(); // start h3 server, non blocking -// HTTP/1.1, advertising the endpoint above. The port has to match, and nothing checks that it does: -// get it wrong and clients simply never upgrade. +// h1 internal engine await GenHTTP.Engine.Internal.Host.Create() .Handler(app) .Add(AltSvc.To(Port)) From 9ad3c4ecc92dcf3c724a91a8b09bc745eaeac837 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 19:37:48 +0100 Subject: [PATCH 09/16] fix: open one unidirectional stream, not three Glyph3 calls OpenUniStream exactly once, for its control stream. HTTP/3 also defines QPACK encoder and decoder streams, but Glyph3 advertises a dynamic table capacity of 0, so there is nothing to insert and nothing to acknowledge. The other two were opened, never written, and held until the connection ended. Named the count so the reason is written down rather than rediscovered. --- .../InternalH3Experimental/Protocol/H3Connection.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index aa1a3866d..890f3875f 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -43,6 +43,8 @@ internal sealed class H3Connection : IHttp3Transport, IAsyncDisposable private Http3Connection? _h3; + private const int ServerUniStreams = 1; + // Stream bytes, a stream ending, or a continuation that must run on the pump thread. private readonly record struct Inbound(long StreamId, byte[]? Buffer, int Length, bool Fin, bool Closed, Action? Resume); @@ -64,8 +66,14 @@ internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndP private async Task RunAsync(CancellationToken cancellationToken) { - // Opened before Glyph3 exists, because OpenUniStream answers synchronously. - for (int i = 0; i < 3; i++) + // Opened before Glyph3 exists, because OpenUniStream answers synchronously while + // OpenOutboundStreamAsync does not. + // + // One is enough: Glyph3 asks for a single unidirectional stream, for control and SETTINGS. + // HTTP/3 also defines QPACK encoder and decoder streams, but Glyph3 advertises a dynamic + // table capacity of 0, so there is nothing to insert and nothing to acknowledge. Raise this + // if that ever changes. + for (int i = 0; i < ServerUniStreams; i++) { QuicStream uni = await _quic.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, cancellationToken); _streams[uni.Id] = uni; From df4f11d5887fce77f41a2944beb11939fca6d873 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 23:30:24 +0100 Subject: [PATCH 10/16] feat: expose the QPACK dynamic table, and serve static files in the playground Host.Create takes a QPACK dynamic-table capacity now, plumbed through to Glyph3. Zero stays the default and switches the mechanism off entirely. A nonzero value also needs two more unidirectional streams - QPACK decoder, and encoder once the client advertises a table of its own - so the bridge opens three rather than one. Requires Glyph3 0.3.0, which is where both directions of the table landed. The playground now serves a wwwroot over both protocols instead of one string: an index, three images and a script, deliberately several subresources so a browser makes repeated requests on one connection. That is the traffic a dynamic table exists to compress, and no client tested so far uses one - curl 8.21 advertises a capacity of 0, .NET's HTTP/3 client is static-table only, and h2o inserts nothing. Browsers are the remaining candidate, which is what this page is for: it reports the protocol it arrived over. A browser will not speak HTTP/3 to an untrusted certificate, so startup prints the certificate's SPKI hash for Chrome's --ignore-certificate-errors-spki-list, alongside --origin-to-force-quic-on. Verified over both protocols with curl: index, image and script all 200. --- ...nHTTP.Engine.InternalH3Experimental.csproj | 2 +- Engine/InternalH3Experimental/Host.cs | 12 ++++- .../Infrastructure/H3Server.cs | 6 ++- .../Infrastructure/H3ServerHost.cs | 6 ++- .../Infrastructure/QuicEndPoint.cs | 3 +- .../Protocol/H3Connection.cs | 22 +++++--- Playground/GenHTTP.Playground.csproj | 4 ++ Playground/Program.cs | 52 ++++++++++++++----- Playground/wwwroot/img/a.svg | 1 + Playground/wwwroot/img/b.svg | 1 + Playground/wwwroot/img/c.svg | 1 + Playground/wwwroot/index.html | 26 ++++++++++ Playground/wwwroot/js/app.js | 5 ++ 13 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 Playground/wwwroot/img/a.svg create mode 100644 Playground/wwwroot/img/b.svg create mode 100644 Playground/wwwroot/img/c.svg create mode 100644 Playground/wwwroot/index.html create mode 100644 Playground/wwwroot/js/app.js diff --git a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj index 5a3e68948..c0d2cb91b 100644 --- a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj +++ b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj @@ -18,7 +18,7 @@ - + diff --git a/Engine/InternalH3Experimental/Host.cs b/Engine/InternalH3Experimental/Host.cs index 2697b1d42..f970b3f9d 100644 --- a/Engine/InternalH3Experimental/Host.cs +++ b/Engine/InternalH3Experimental/Host.cs @@ -21,6 +21,16 @@ public static class Host /// /// Provides a new server host serving HTTP/3 over QUIC. /// - public static IServerHost Create() => new H3ServerHost(); + /// + /// Bytes of QPACK dynamic table advertised to clients, and the ceiling on what this server will + /// use for its own responses. 0 (the default) switches the mechanism off: headers are encoded + /// with the static table and literals only. + /// + /// A nonzero value lets a client compress headers it repeats - cookies and user-agent, mostly - + /// to about two bytes each. Most clients decline: curl, and .NET's own HTTP/3 client, advertise + /// no table at all. Browsers are the ones that may use it. + /// + public static IServerHost Create(int qpackDynamicTableCapacity = 0) + => new H3ServerHost(qpackDynamicTableCapacity); } diff --git a/Engine/InternalH3Experimental/Infrastructure/H3Server.cs b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs index cb171cab0..bcb443351 100644 --- a/Engine/InternalH3Experimental/Infrastructure/H3Server.cs +++ b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs @@ -35,8 +35,12 @@ internal sealed class H3Server : IServer internal ServerConfiguration Configuration { get; } - internal H3Server(ServerConfiguration configuration, IHandler handler) + internal int QpackCapacity { get; } + + internal H3Server(ServerConfiguration configuration, IHandler handler, int qpackCapacity) { + QpackCapacity = qpackCapacity; + Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "(n/a)"; Configuration = configuration; diff --git a/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs index ed6979ecd..45b557103 100644 --- a/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs +++ b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs @@ -8,7 +8,11 @@ namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; internal sealed class H3ServerHost : ServerHost { + private readonly int _qpackCapacity; - protected override IServer Build(ServerConfiguration config, IHandler handler) => new H3Server(config, handler); + internal H3ServerHost(int qpackCapacity) => _qpackCapacity = qpackCapacity; + + protected override IServer Build(ServerConfiguration config, IHandler handler) + => new H3Server(config, handler, _qpackCapacity); } diff --git a/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs index b3ece9262..4328a6a5a 100644 --- a/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs +++ b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs @@ -117,7 +117,8 @@ private async Task ServeAsync(QuicConnection connection) { try { - await H3Connection.ServeAsync(connection, _server, this, _logger, _shutdown.Token); + await H3Connection.ServeAsync(connection, _server, this, _logger, + (_server as H3Server)?.QpackCapacity ?? 0, _shutdown.Token); } catch (Exception e) { diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 890f3875f..9faa258b3 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -50,37 +50,47 @@ internal sealed class H3Connection : IHttp3Transport, IAsyncDisposable private readonly record struct Outbound(long StreamId, byte[] Buffer, int Length, bool Fin); - private H3Connection(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger) + private readonly int _qpackCapacity; + + private H3Connection(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, int qpackCapacity) { _quic = quic; _server = server; _endPoint = endPoint; _logger = logger; + _qpackCapacity = qpackCapacity; } - internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, CancellationToken cancellationToken) + internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, + int qpackCapacity, CancellationToken cancellationToken) { - await using var connection = new H3Connection(quic, server, endPoint, logger); + await using var connection = new H3Connection(quic, server, endPoint, logger, qpackCapacity); await connection.RunAsync(cancellationToken); } private async Task RunAsync(CancellationToken cancellationToken) { // Opened before Glyph3 exists, because OpenUniStream answers synchronously while - // OpenOutboundStreamAsync does not. + // OpenOutboundStreamAsync does not. With the dynamic table on it also wants a QPACK + // decoder stream, and an encoder stream if the client advertises a table of its own. // // One is enough: Glyph3 asks for a single unidirectional stream, for control and SETTINGS. // HTTP/3 also defines QPACK encoder and decoder streams, but Glyph3 advertises a dynamic // table capacity of 0, so there is nothing to insert and nothing to acknowledge. Raise this // if that ever changes. - for (int i = 0; i < ServerUniStreams; i++) + int uniStreams = _qpackCapacity > 0 ? ServerUniStreams + 2 : ServerUniStreams; + + for (int i = 0; i < uniStreams; i++) { QuicStream uni = await _quic.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, cancellationToken); _streams[uni.Id] = uni; _spareUniStreams.Enqueue(uni); } - _h3 = new Http3Connection(this, DispatchAsync); + _h3 = new Http3Connection(this, DispatchAsync, new Http3Options + { + QpackDynamicTableCapacity = _qpackCapacity, + }); Task accepting = AcceptStreamsAsync(cancellationToken); Task writing = WriteLoopAsync(cancellationToken); diff --git a/Playground/GenHTTP.Playground.csproj b/Playground/GenHTTP.Playground.csproj index 28f9bbfad..9b3bdb383 100644 --- a/Playground/GenHTTP.Playground.csproj +++ b/Playground/GenHTTP.Playground.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/Playground/Program.cs b/Playground/Program.cs index 0e9e66064..284c8c13a 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -4,34 +4,53 @@ using GenHTTP.Engine.InternalH3Experimental; +using GenHTTP.Modules.StaticWebsites; using GenHTTP.Modules.IO; -using GenHTTP.Modules.Layouting; using Microsoft.Extensions.Logging.Abstractions; -// curl -k https://localhost:8443/hello # HTTP/1.1, over TCP -// curl -k --http3 https://localhost:8443/hello # HTTP/3, over QUIC -// curl -k -i https://localhost:8443/hello | grep -i alt-svc +// Serves wwwroot over HTTP/1.1 and HTTP/3 at once, so a browser can be watched deciding which to +// use. The page reports the protocol it arrived over. // -// TCP:8443 and UDP:8443 are different sockets, so both engines bind the same number. - +// dotnet run -c Release --project Playground +// curl -k https://localhost:8443/ # HTTP/1.1, and an Alt-Svc header +// snap run curl -k --http3-only https://localhost:8443/ +// +// A browser will not speak HTTP/3 to an untrusted certificate. Chrome can be told to anyway, using +// the SPKI hash this prints on startup: +// +// google-chrome --origin-to-force-quic-on=localhost:8443 \ +// --ignore-certificate-errors-spki-list= \ +// https://localhost:8443/ +// +// One number, two sockets: the HTTP/1.1 host binds TCP 8443 and the HTTP/3 host binds UDP 8443. const ushort Port = 8443; -var app = Layout.Create() - .Add("hello", Content.From(Resource.FromString("Hello World!"))); +// Bytes of QPACK dynamic table advertised to clients. Nonzero here on purpose: this playground +// exists partly to find out whether a browser uses one, since curl and .NET's client do not. +const int QpackCapacity = 4096; + +// Beside the binary, since wwwroot is copied to the output rather than served from the source +// tree - a relative path would resolve against whatever directory you happened to run from. +string root = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + +var content = StaticWebsite.From(ResourceTree.FromDirectory(root)); var certificate = CreateDevelopmentCertificate(); -var h3 = Host.Create() - .Handler(app) +Console.WriteLine($"SPKI hash: {SpkiHash(certificate)}"); + +// HTTP/3 first, so it is listening before anything advertises it. +var h3 = Host.Create(QpackCapacity) + .Handler(content) .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate); -await h3.StartAsync(); // start h3 server, non blocking +await h3.StartAsync(); -// h1 internal engine +// HTTP/1.1, advertising the endpoint above. The port has to match, and nothing checks that it does. await GenHTTP.Engine.Internal.Host.Create() - .Handler(app) + .Handler(content) .Add(AltSvc.To(Port)) .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate) @@ -43,7 +62,7 @@ static X509Certificate2 CreateDevelopmentCertificate() var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - // Without a SAN nothing modern will verify this, only skip it with -k. + // Without a SAN nothing modern will verify this, only skip it. var names = new SubjectAlternativeNameBuilder(); names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback); @@ -54,3 +73,8 @@ static X509Certificate2 CreateDevelopmentCertificate() // The private key has to come back through a PFX round-trip before a TLS stack will use it. return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); } + +// What Chrome's --ignore-certificate-errors-spki-list wants: base64 of the SHA-256 of the +// certificate's public key info. +static string SpkiHash(X509Certificate2 certificate) + => Convert.ToBase64String(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo())); diff --git a/Playground/wwwroot/img/a.svg b/Playground/wwwroot/img/a.svg new file mode 100644 index 000000000..cab2ef3e5 --- /dev/null +++ b/Playground/wwwroot/img/a.svg @@ -0,0 +1 @@ +a diff --git a/Playground/wwwroot/img/b.svg b/Playground/wwwroot/img/b.svg new file mode 100644 index 000000000..e08954913 --- /dev/null +++ b/Playground/wwwroot/img/b.svg @@ -0,0 +1 @@ +b diff --git a/Playground/wwwroot/img/c.svg b/Playground/wwwroot/img/c.svg new file mode 100644 index 000000000..0b37869ca --- /dev/null +++ b/Playground/wwwroot/img/c.svg @@ -0,0 +1 @@ +c diff --git a/Playground/wwwroot/index.html b/Playground/wwwroot/index.html new file mode 100644 index 000000000..38e19405b --- /dev/null +++ b/Playground/wwwroot/index.html @@ -0,0 +1,26 @@ + + +GenHTTP over HTTP/3 + +

GenHTTP over HTTP/3

+

+ Served by the experimental HTTP/3 engine, with HTTP/3 framing and QPACK from Glyph3 and QUIC from + System.Net.Quic. +

+

+ The first load arrives over HTTP/1.1. The response carries an Alt-Svc header pointing + at the same port over UDP, so a reload should switch to HTTP/3. Check the Protocol column in the + network tab. +

+ + + +

+ Several subresources on purpose: repeated requests on one connection are what a QPACK dynamic + table would compress. +

+ diff --git a/Playground/wwwroot/js/app.js b/Playground/wwwroot/js/app.js new file mode 100644 index 000000000..d835c4cbf --- /dev/null +++ b/Playground/wwwroot/js/app.js @@ -0,0 +1,5 @@ +// Reports the protocol the browser actually used, which is the point of the page. +const nav = performance.getEntriesByType("navigation")[0]; +const note = document.createElement("p"); +note.innerHTML = `This document arrived over ${nav ? nav.nextHopProtocol || "(unknown)" : "(unknown)"}.`; +document.body.appendChild(note); From f23c387bf2e628723d2b81adfa571becb6cbb6ee Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 23:33:26 +0100 Subject: [PATCH 11/16] docs: name the capacity argument at the playground's call site Host.Create(QpackCapacity) does not say what the number is; the named form does, and the playground's other knobs are all self-describing. --- Playground/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 284c8c13a..f26d97b73 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -41,7 +41,7 @@ Console.WriteLine($"SPKI hash: {SpkiHash(certificate)}"); // HTTP/3 first, so it is listening before anything advertises it. -var h3 = Host.Create(QpackCapacity) +var h3 = Host.Create(qpackDynamicTableCapacity: QpackCapacity) .Handler(content) .Logging(NullLoggerFactory.Instance, logRequests: false) .Bind(IPAddress.Loopback, Port, certificate); From bbf651b7a5720aa51173899f174c07f724a0344b Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 13 Aug 2026 00:50:17 +0100 Subject: [PATCH 12/16] fix(engine): keep-alive was matched case-sensitively, so browsers never got it Connection options are case-insensitive tokens (RFC 9110 7.6.1), but the value was compared with SequenceEqual against "Keep-Alive". Browsers send "keep-alive" in lower case, so the match failed and every browser request was answered with Connection: close - a new TCP connection, and a new TLS handshake, per request. curl hid it by sending no Connection header at all, which falls through to the protocol default and keeps the connection alive. --- Engine/Internal/Protocol/ClientHandler.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Engine/Internal/Protocol/ClientHandler.cs b/Engine/Internal/Protocol/ClientHandler.cs index 797be9821..7a6ead22c 100644 --- a/Engine/Internal/Protocol/ClientHandler.cs +++ b/Engine/Internal/Protocol/ClientHandler.cs @@ -2,6 +2,7 @@ using System.IO.Pipelines; using System.Net.Sockets; using System.Runtime.CompilerServices; +using System.Text; using GenHTTP.Api.Protocol; using GenHTTP.Engine.Internal.Context; using GenHTTP.Engine.Shared.Types; @@ -28,7 +29,7 @@ internal sealed class ClientHandler(ClientContext context) private static readonly TimeSpan KeepAliveTimeout = TimeSpan.FromSeconds(60); - private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray(); + private static readonly ReadOnlyMemory KeepAliveValue = "keep-alive"u8.ToArray(); private static readonly ParserLimits Limits = ParserLimits.Default; @@ -203,7 +204,12 @@ internal async ValueTask HandleRequestAsync(Request request) var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection); - var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11); + // Connection options are case-insensitive tokens (RFC 9110 7.6.1). Matching them exactly + // read a browser, which sends "keep-alive" in lower case, as asking to close - so every + // browser request got a new connection, and a new TLS handshake with it. + var keepAliveRequested = connectionHeader is { } connection + ? Ascii.EqualsIgnoreCase(connection.Bytes.Span, KeepAliveValue.Span) + : header.Protocol == HttpProtocol.Http11; var response = await context.Server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response"); From 836c78d02f1c85159de8ce1a60d0f39bbcef861c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 13 Aug 2026 00:50:17 +0100 Subject: [PATCH 13/16] feat(h3): Glyph3 0.11.652, and report what QPACK actually did Glyph3 now exposes the peer's advertised QPACK capacity and the per-direction insert counts, so a connection can be asked whether the dynamic table was used rather than guessed at. Logged at Debug when a connection closes, separating "the peer advertised 0" from "SETTINGS never arrived" - both leave the counters at 0 while meaning different things. Measured with it: Chrome advertises 65536 B and decodes our dynamic references, so the outbound path pays off. curl, h3x and .NET's client all advertise 0, which makes the table inert - and nothing tested inserts into our decode table at all. The playground binds IPv6Any rather than Loopback. A browser resolves "localhost" itself and prefers ::1, so an IPv4-only listener never sees a packet from one; TCP hides this by falling back to IPv4, QUIC does not, and it surfaces as ERR_QUIC_PROTOCOL_ERROR rather than as too narrow a bind. It also takes a certificate through PLAYGROUND_CERT, since a browser needs one from a CA it trusts before it will speak HTTP/3 at all. --- ...nHTTP.Engine.InternalH3Experimental.csproj | 2 +- .../Protocol/H3Connection.cs | 37 +++++++++++--- Playground/Program.cs | 50 ++++++++++++++++++- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj index c0d2cb91b..65bd4d7f3 100644 --- a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj +++ b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj @@ -18,7 +18,7 @@ - +
diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 9faa258b3..50525db5d 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -71,13 +71,13 @@ internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndP private async Task RunAsync(CancellationToken cancellationToken) { // Opened before Glyph3 exists, because OpenUniStream answers synchronously while - // OpenOutboundStreamAsync does not. With the dynamic table on it also wants a QPACK - // decoder stream, and an encoder stream if the client advertises a table of its own. + // OpenOutboundStreamAsync does not. // - // One is enough: Glyph3 asks for a single unidirectional stream, for control and SETTINGS. - // HTTP/3 also defines QPACK encoder and decoder streams, but Glyph3 advertises a dynamic - // table capacity of 0, so there is nothing to insert and nothing to acknowledge. Raise this - // if that ever changes. + // One is enough at capacity 0: Glyph3 asks for a single unidirectional stream, for control + // and SETTINGS, and with no dynamic table there is nothing to insert and nothing to + // acknowledge. With a capacity it also wants the QPACK decoder stream, plus an encoder + // stream for the case where the client advertises a table of its own. The encoder one goes + // unused if the client then advertises 0, which every non-browser client does. int uniStreams = _qpackCapacity > 0 ? ServerUniStreams + 2 : ServerUniStreams; for (int i = 0; i < uniStreams; i++) @@ -101,6 +101,8 @@ private async Task RunAsync(CancellationToken cancellationToken) } finally { + ReportQpack(); + _h3.Close(); _egress.Writer.TryComplete(); _ingress.Writer.TryComplete(); @@ -108,6 +110,29 @@ private async Task RunAsync(CancellationToken cancellationToken) } } + /// + /// Reports what QPACK actually did on this connection, which is otherwise invisible. + /// + /// + /// A capacity of 0 is worth distinguishing from SETTINGS that never arrived, since both leave + /// the counters at 0 while meaning entirely different things. In practice only browsers + /// advertise a table at all: everything else sends 0, which makes the dynamic table inert no + /// matter what capacity this engine was configured with. + /// + private void ReportQpack() + { + if (_h3 is null || !_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + _logger.LogDebug( + "HTTP/3 connection closed: peer QPACK table {Capacity}, dynamic inserts in {Inbound} / out {Outbound}", + _h3.PeerSettingsReceived ? $"{_h3.PeerDynamicTableCapacity} B" : "never advertised", + _h3.InboundDynamicInserts, + _h3.OutboundDynamicInserts); + } + // Glyph3 calls this on the pump thread and awaits the result before submitting the response. private ValueTask DispatchAsync(Http3Request request) { diff --git a/Playground/Program.cs b/Playground/Program.cs index f26d97b73..32dec51f1 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -40,11 +40,15 @@ Console.WriteLine($"SPKI hash: {SpkiHash(certificate)}"); +// IPv6Any rather than Loopback: a browser resolves "localhost" itself and prefers ::1, so an +// IPv4-only listener never sees a packet from one. TCP masks this by falling back to IPv4, QUIC +// does not, and the result looks like a broken HTTP/3 server rather than a bind that is too narrow. +// // HTTP/3 first, so it is listening before anything advertises it. var h3 = Host.Create(qpackDynamicTableCapacity: QpackCapacity) .Handler(content) .Logging(NullLoggerFactory.Instance, logRequests: false) - .Bind(IPAddress.Loopback, Port, certificate); + .Bind(IPAddress.IPv6Any, Port, certificate); await h3.StartAsync(); @@ -53,11 +57,31 @@ await GenHTTP.Engine.Internal.Host.Create() .Handler(content) .Add(AltSvc.To(Port)) .Logging(NullLoggerFactory.Instance, logRequests: false) - .Bind(IPAddress.Loopback, Port, certificate) + .Bind(IPAddress.IPv6Any, Port, certificate) .RunAsync(); static X509Certificate2 CreateDevelopmentCertificate() { + // A PKCS#12 issued by a local CA that the browser already trusts. This is the only arrangement + // Chrome accepts: it dropped support for directly-trusted leaf certificates, so the ASP.NET + // development certificate below (CA:FALSE, self-signed) can never satisfy it however it is + // marked in the trust store. Point PLAYGROUND_CERT at a mkcert-style bundle to use one. + // + // It matters because a certificate the browser merely tolerates is not enough: an origin with + // certificate errors has its Alt-Svc ignored outright, so HTTP/3 stays unreachable. + if (Environment.GetEnvironmentVariable("PLAYGROUND_CERT") is { Length: > 0 } path && File.Exists(path)) + { + return X509CertificateLoader.LoadPkcs12FromFile(path, null); + } + + // Prefer the ASP.NET development certificate if one exists. It is the same across runs, so the + // SPKI hash below stays stable, and `dotnet dev-certs https --trust` is enough for Firefox and + // curl, which do honour a trusted leaf. + if (FindAspNetDevelopmentCertificate() is { } trusted) + { + return trusted; + } + using var key = RSA.Create(2048); var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); @@ -74,6 +98,28 @@ static X509Certificate2 CreateDevelopmentCertificate() return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); } +/// +/// The ASP.NET development certificate, if one is installed and still valid. Identified by the +/// extension the tooling stamps on it rather than by its subject, which anything could claim. +/// +static X509Certificate2? FindAspNetDevelopmentCertificate() +{ + const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; + + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + // The NEWEST valid one, because that is the one `dotnet dev-certs https --trust` marks. Taking + // whichever the store happened to enumerate first served an older certificate that was equally + // valid and entirely untrusted, which a browser reports as an ordinary certificate error. + return store.Certificates + .Where(candidate => candidate.HasPrivateKey + && candidate.NotAfter > DateTime.Now + && candidate.Extensions.Any(e => e.Oid?.Value == AspNetHttpsOid)) + .OrderByDescending(candidate => candidate.NotAfter) + .FirstOrDefault(); +} + // What Chrome's --ignore-certificate-errors-spki-list wants: base64 of the SHA-256 of the // certificate's public key info. static string SpkiHash(X509Certificate2 certificate) From 3787dee04be242bdf9efcec0e05b1208f6b635ba Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Fri, 14 Aug 2026 14:23:50 +0100 Subject: [PATCH 14/16] perf(h3): stop allocating two lists and a name array per response Headers were collected into a list and then copied item by item into the response's own list, so every response allocated a second list and its backing array for nothing. They are now written straight into the response. Lowercasing allocated as well. HTTP/3 requires lowercase field names, and the names GenHTTP emits - Server, Date, Content-Type - all arrive with capitals, so each one copied itself into a fresh array on every response. Known names now resolve to a shared pre-lowercased array, leaving the allocation for names not in the table. Worth about 3% of allocation on a small-response benchmark. It does not move throughput, which is bounded elsewhere: the engine allocates less per second than Kestrel does while serving fewer requests, so this path is not GC-bound. --- .../Protocol/H3ResponseWriter.cs | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs index 869038174..760a73a49 100644 --- a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs +++ b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs @@ -14,7 +14,28 @@ internal static class H3ResponseWriter internal static async ValueTask BuildAsync(IResponse response, bool headRequest) { - var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(); + ReadOnlyMemory body = default; + + IResponseContent? content = response.Content; + + // A HEAD response keeps the headers its GET would have produced and sends no body. + if (content is not null && !headRequest) + { + var buffer = new ArrayBufferWriter( + content.Length is { } length and > 0 and < int.MaxValue ? (int)length : 4096); + + await content.WriteAsync(new H3Sink(buffer)); + + body = buffer.WrittenMemory; + } + + // Written straight into the response. Collecting into a list first and copying it across + // allocated a second list and its backing array on every single response. + var result = new Http3Response + { + Status = (int)response.Status, + Body = body, + }; for (int i = 0; i < response.Headers.Count; i++) { @@ -24,50 +45,38 @@ internal static async ValueTask BuildAsync(IResponse response, bo // treat them as a protocol error rather than ignore them. if (!IsConnectionSpecific(header.Key.Span)) { - headers.Add((Lowercase(header.Key), header.Value)); + result.Headers.Add((Lowercase(header.Key), header.Value)); } } - ReadOnlyMemory body = default; - - if (response.Content is { } content) + if (content is not null) { if (content.Type is { } type) { - headers.Add((ContentTypeName, type.Bytes)); + result.Headers.Add((ContentTypeName, type.Bytes)); } if (content.Encoding is { } encoding) { - headers.Add((ContentEncodingName, encoding)); - } - - // A HEAD response keeps the headers its GET would have produced and sends no body. - if (!headRequest) - { - var buffer = new ArrayBufferWriter( - content.Length is { } length and > 0 and < int.MaxValue ? (int)length : 4096); - - await content.WriteAsync(new H3Sink(buffer)); - - body = buffer.WrittenMemory; + result.Headers.Add((ContentEncodingName, encoding)); } } - var result = new Http3Response - { - Status = (int)response.Status, - Body = body, - }; - - foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in headers) - { - result.Headers.Add((name, value)); - } - return result; } + // The field names GenHTTP actually emits, pre-lowercased. Every one of these arrives with + // capitals, so without this table each of them allocated a fresh array on every response. + private static readonly byte[][] KnownNames = + [ + "server"u8.ToArray(), "date"u8.ToArray(), "content-type"u8.ToArray(), + "content-encoding"u8.ToArray(), "content-disposition"u8.ToArray(), "content-range"u8.ToArray(), + "cache-control"u8.ToArray(), "last-modified"u8.ToArray(), "expires"u8.ToArray(), + "location"u8.ToArray(), "etag"u8.ToArray(), "vary"u8.ToArray(), + "accept-ranges"u8.ToArray(), "set-cookie"u8.ToArray(), "alt-svc"u8.ToArray(), + "access-control-allow-origin"u8.ToArray(), "www-authenticate"u8.ToArray(), + ]; + // HTTP/3 requires lowercase field names; anything else is a malformed message. private static ReadOnlyMemory Lowercase(ReadOnlyMemory name) { @@ -77,6 +86,15 @@ private static ReadOnlyMemory Lowercase(ReadOnlyMemory name) { if (span[i] is >= (byte)'A' and <= (byte)'Z') { + // Matches compares case-insensitively, so a known name resolves to a shared array. + foreach (byte[] known in KnownNames) + { + if (Matches(span, known)) + { + return known; + } + } + byte[] lowered = name.ToArray(); for (int j = 0; j < lowered.Length; j++) { From 8df3cad6a0188114a80d4e27feb62b2507373a5f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Fri, 14 Aug 2026 14:58:59 +0100 Subject: [PATCH 15/16] feat(h3): Glyph3 0.12.0, and drop the lowercasing it made redundant Glyph3 now encodes response headers against the QPACK static table rather than using it for :status alone, so a field whose name it knows is never written out: name and value both in the table cost one byte, a known name costs an index plus the literal value. That makes the lowercasing here redundant twice over. Names it resolves are matched case-insensitively and never reach the wire, and names it has to write out it lowercases itself. Converting them here only duplicated the work and allocated an array per header to do it, so the table of pre-lowercased names and the function that used it are both gone. Bytes per response, not throughput: a header block carrying server, date, content-type, cache-control, etag, vary and accept-ranges drops from 172 to 67. The benchmark here is a single-header response over loopback, which is not bandwidth-bound and measures no difference at all. --- ...nHTTP.Engine.InternalH3Experimental.csproj | 2 +- .../Protocol/H3ResponseWriter.cs | 51 ++----------------- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj index 65bd4d7f3..fd15da2a6 100644 --- a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj +++ b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj @@ -18,7 +18,7 @@ - + diff --git a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs index 760a73a49..95e9f74a7 100644 --- a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs +++ b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs @@ -41,11 +41,15 @@ internal static async ValueTask BuildAsync(IResponse response, bo { KeyValuePair, ReadOnlyMemory> header = response.Headers.GetMemoryEntry(i); + // Names pass through as they are. HTTP/3 requires them lowercase, but Glyph3 resolves + // static-table names case-insensitively - and lowercases the ones it has to write out - + // so converting here only duplicated the work and allocated to do it. + // // Connection-specific fields are malformed in HTTP/3 (RFC 9114 4.2), and a peer may // treat them as a protocol error rather than ignore them. if (!IsConnectionSpecific(header.Key.Span)) { - result.Headers.Add((Lowercase(header.Key), header.Value)); + result.Headers.Add((header.Key, header.Value)); } } @@ -65,51 +69,6 @@ internal static async ValueTask BuildAsync(IResponse response, bo return result; } - // The field names GenHTTP actually emits, pre-lowercased. Every one of these arrives with - // capitals, so without this table each of them allocated a fresh array on every response. - private static readonly byte[][] KnownNames = - [ - "server"u8.ToArray(), "date"u8.ToArray(), "content-type"u8.ToArray(), - "content-encoding"u8.ToArray(), "content-disposition"u8.ToArray(), "content-range"u8.ToArray(), - "cache-control"u8.ToArray(), "last-modified"u8.ToArray(), "expires"u8.ToArray(), - "location"u8.ToArray(), "etag"u8.ToArray(), "vary"u8.ToArray(), - "accept-ranges"u8.ToArray(), "set-cookie"u8.ToArray(), "alt-svc"u8.ToArray(), - "access-control-allow-origin"u8.ToArray(), "www-authenticate"u8.ToArray(), - ]; - - // HTTP/3 requires lowercase field names; anything else is a malformed message. - private static ReadOnlyMemory Lowercase(ReadOnlyMemory name) - { - ReadOnlySpan span = name.Span; - - for (int i = 0; i < span.Length; i++) - { - if (span[i] is >= (byte)'A' and <= (byte)'Z') - { - // Matches compares case-insensitively, so a known name resolves to a shared array. - foreach (byte[] known in KnownNames) - { - if (Matches(span, known)) - { - return known; - } - } - - byte[] lowered = name.ToArray(); - for (int j = 0; j < lowered.Length; j++) - { - if (lowered[j] is >= (byte)'A' and <= (byte)'Z') - { - lowered[j] += 32; - } - } - return lowered; - } - } - - return name; - } - 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, "content-length"u8); From 54c16058aeab927730266a198ff141dae6961626 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Fri, 14 Aug 2026 14:58:59 +0100 Subject: [PATCH 16/16] perf(h3): issue writes for the whole batch before awaiting any of them The writer loop awaited each write before starting the next, so MsQuic only ever had one stream's data pending and could not fill a datagram from several streams at once. Every response became its own datagram. Writes are now issued for everything drained from the queue and awaited together, so a batch of responses is visible to MsQuic simultaneously. 272-286k to 291-310k req/s, and CPU for the same work falls from 14.7 cores to 10.6. Ordering within a stream still holds: a second write to a stream already in flight drains first, since QuicStream is not safe for concurrent writes. Buffers go back to the pool and finished streams are released only after their write completes - releasing early strands the stream's credit and the peer stalls after MaxInboundBidirectionalStreams requests. Handlers stay inline on the pump deliberately. Dispatching them with Task.Run instead collapses this to 75k req/s and leaves streams unanswered, because the pump then waits on a pool the offloaded work is competing for. --- .../Protocol/H3Connection.cs | 104 +++++++++++++++--- 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs index 50525db5d..653f53c08 100644 --- a/Engine/InternalH3Experimental/Protocol/H3Connection.cs +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -136,7 +136,9 @@ private void ReportQpack() // Glyph3 calls this on the pump thread and awaits the result before submitting the response. private ValueTask DispatchAsync(Http3Request request) { - // Start the handler INLINE. A chain that completes synchronously - which the common case + // Start the handler INLINE. Measured against the alternative: dispatching it to the pool + // with Task.Run collapsed throughput to 75k req/s from 291k, because the pump then waits on + // a pool that the offloaded work is itself competing for. A chain that completes synchronously - which the common case // does - then costs no thread hop at all, and Glyph3 submits the response without ever // leaving the pump. Only a handler that actually suspends pays for a continuation, and it // comes back through PumpContext. @@ -291,38 +293,65 @@ private async Task ReadStreamAsync(QuicStream stream, CancellationToken cancella } } + /// + /// Issues every write it can, then awaits them together. + /// + /// + /// Awaiting each write before starting the next leaves MsQuic with one stream's data pending at + /// a time, so it cannot fill a datagram from several streams at once - the coalescing that makes + /// UDP segmentation offload worth anything. Issuing first and draining after lets it see the + /// whole batch. Ordering within a stream still holds: a second write to a stream already in + /// flight drains first. + /// private async Task WriteLoopAsync(CancellationToken cancellationToken) { + var inflight = new List(); + var busy = new HashSet(); + try { while (await _egress.Reader.WaitToReadAsync(cancellationToken)) { while (_egress.Reader.TryRead(out Outbound item)) { - if (_streams.TryGetValue(item.StreamId, out QuicStream? stream)) + if (!_streams.TryGetValue(item.StreamId, out QuicStream? stream)) { - if (item.Length > 0) - { - await stream.WriteAsync(item.Buffer.AsMemory(0, item.Length), item.Fin, cancellationToken); - } - else if (item.Fin) + ArrayPool.Shared.Return(item.Buffer); + continue; + } + + if (item.Length == 0) + { + if (item.Fin) { stream.CompleteWrites(); } - // A finished request stream must be released, or its stream credit is never - // returned and the peer stalls after MaxInboundBidirectionalStreams - // requests. Unidirectional streams are the connection's control and QPACK - // streams and live as long as it does. - if (item.Fin && (item.StreamId & 0x3) == 0x0 && _streams.TryRemove(item.StreamId, out QuicStream? finished)) - { - // Off the writer: tearing a stream down is slow enough that awaiting it - // here serialises every other request behind it. - _ = finished.DisposeAsync().AsTask(); - } + ArrayPool.Shared.Return(item.Buffer); + Release(item.StreamId, item.Fin); + continue; + } + + if (!busy.Add(item.StreamId)) + { + await DrainAsync(inflight, busy); + busy.Add(item.StreamId); + } + + ValueTask write = stream.WriteAsync(item.Buffer.AsMemory(0, item.Length), item.Fin, cancellationToken); + + if (write.IsCompletedSuccessfully) + { + ArrayPool.Shared.Return(item.Buffer); + Release(item.StreamId, item.Fin); + } + else + { + inflight.Add(new Pending(write, item.Buffer, item.StreamId, item.Fin)); } - ArrayPool.Shared.Return(item.Buffer); } + + await DrainAsync(inflight, busy); } } catch (Exception) @@ -331,6 +360,45 @@ private async Task WriteLoopAsync(CancellationToken cancellationToken) } } + private async ValueTask DrainAsync(List inflight, HashSet busy) + { + foreach (Pending pending in inflight) + { + try + { + await pending.Write; + } + catch (Exception) + { + // Peer went away mid-write; the connection is finished either way. + } + + ArrayPool.Shared.Return(pending.Buffer); + Release(pending.StreamId, pending.Fin); + } + + inflight.Clear(); + busy.Clear(); + } + + /// + /// Releases a finished request stream. Skipping this strands its credit and the peer stalls + /// after MaxInboundBidirectionalStreams requests. + /// + private void Release(long streamId, bool fin) + { + // Unidirectional streams are the connection's control and QPACK streams; they live as long + // as it does. + if (fin && (streamId & 0x3) == 0x0 && _streams.TryRemove(streamId, out QuicStream? finished)) + { + // Off the writer: tearing a stream down is slow enough that awaiting it here serialises + // every other request behind it. + _ = finished.DisposeAsync().AsTask(); + } + } + + private readonly record struct Pending(ValueTask Write, byte[] Buffer, long StreamId, bool Fin); + public long OpenUniStream() => _spareUniStreams.TryDequeue(out QuicStream? stream) ? stream.Id : -1; public void Send(long streamId, ReadOnlySpan data, bool fin)