// dotnet add package ioxide
@@ -932,7 +936,7 @@ HTTP/2 · pure C# over TLS
}
catch (Exception e)
{
- Console.Error.WriteLine($"[http2-managed-tls] connection failed: {e.Message}");
+ Console.Error.WriteLine($"[http2-tls] connection failed: {e.Message}");
}
finally
{
@@ -945,7 +949,7 @@ HTTP/2 · pure C# over TLS
threads[i].Start();
}
-Console.WriteLine($"[http2-managed-tls] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+Console.WriteLine($"[http2-tls] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ $"ALPN h2 then http/1.1, cert {certPath}, "
+ $"rx={(kernelTx && kernelRx ? "kernel" : "openssl")}, "
+ $"tx={(kernelTx ? "kernel" : "openssl")}");
@@ -981,15 +985,15 @@ HTTP/2 · pure C# over TLS
_len -= count;
}
}
- The pure-C# HTTP/2 server behind TLS, and the diff against is three type names and a package - no native library anywhere. Both take an IDuplexPipe, so neither learns what is under it. Measured on this rig at 2 reactors, 64 connections, 32 streams, a 2-byte body: pure C# runs 2.46× nghttp2 over TLS and 2.73× cleartext, at about a third of the CPU per request. That ordering does not generalize - at 32 connections over 4 reactors nghttp2 is ahead instead - and a 2-byte body measures framing and HPACK rather than moving data. TLS itself costs both the same 0.05µs per request.
How a browser actually reaches h2: over TLS, with the protocol chosen during the handshake. Alpn = ["h2", "http/1.1"] is an ordered preference, not a weighting - the server takes the first entry the client also offered, and this sample then branches on what was agreed, so ONE port serves both. That is why the h2c samples are the exception rather than the rule. What Http2Connection is handed is a TlsConnectionDualPipe: it never learns TLS is involved, so the protocol code is byte-for-byte the h2c sample's. TLS is OpenSSL both ways by default; the kernelTx/kernelRx knobs at the top move either direction into the kernel, and cost the same 0.05µs per request either way.
// dotnet add package ioxide
-// dotnet add package ioxide.nghttp2
+// dotnet add package ioxide.http2
// curl -k --http2 https://127.0.0.1:8443/
using System.IO.Pipelines;
@@ -997,7 +1001,7 @@ HTTP/2 · over SslStream
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using ioxide;
-using ioxide.nghttp2;
+using ioxide.http2;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
@@ -1062,8 +1066,8 @@ HTTP/2 · over SslStream
return; // this sample only serves h2; see Playground/Http2/Tls for the fallback
}
- await new Nghttp2Connection(new StreamDuplexPipe(ssl)).RunBufferedAsync(
- _ => new Nghttp2Response { Status = 200, Body = body });
+ await new Http2Connection(new StreamDuplexPipe(ssl)).RunBufferedAsync(
+ _ => new Http2Response { Status = 200, Body = body });
}
catch (Exception e)
{
@@ -1101,7 +1105,7 @@ HTTP/2 · over SslStream
// dotnet add package ioxide
@@ -1178,7 +1182,7 @@ HTTP/3 · pure C#
// dotnet add package ioxide
@@ -1278,7 +1282,7 @@ HTTP/3 · streamed response
// dotnet add package ioxide
@@ -1428,7 +1432,7 @@ HTTP/3 · streamed both ways
// dotnet add package ioxide
@@ -1640,7 +1644,7 @@ QUIC · two protocols by ALPN
int udpRecvSlots = 16;
// Per-connection send-retention high-water (default 16 MiB): a response larger than it streams out
-// paced by acks instead of buffering whole. See Playground/Http3/Buffered for the full knob set.
+// paced by acks instead of buffering whole. See Playground/Http3/Nghttp3Buffered for the full knob set.
long maxSendRetentionBytes = 16L << 20;
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -3387,18 +3391,19 @@ OpenSSL · pipes
}
Diff this against and the only functional difference is KernelTx; the rest is the banner and the log tag. That is the point of the pipe seam - TlsConnectionDualPipe pairs TcpConnectionPipeReader or TlsDecryptingPipeReader with TcpConnectionPipeWriter or TlsEncryptingPipeWriter, chosen from the session. It has to be the session and not the config, because a handshake that left a partial record keeps the userspace reader whatever was asked for.
// dotnet add package ioxide
-// dotnet add package ioxide.nghttp2
+// dotnet add package ioxide.http2
// curl --http2-prior-knowledge http://127.0.0.1:8080/
using System.Text;
using ioxide;
-using ioxide.nghttp2;
+using ioxide.http2;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
@@ -3449,9 +3454,9 @@ HTTP/2 · nghttp2
{
try
{
- // The connection owns the read loop from here: it feeds nghttp2, dispatches each
- // request once its stream ends, and drains the egress once per batch.
- await new Nghttp2Connection(conn).RunBufferedAsync(_ => new Nghttp2Response
+ // The connection owns the read loop from here: it parses frames, dispatches each
+ // request once its stream ends, and flushes the batch in one write.
+ await new Http2Connection(conn).RunBufferedAsync(_ => new Http2Response
{
Status = 200,
Body = body,
@@ -3467,24 +3472,24 @@ HTTP/2 · nghttp2
threads[i].Start();
}
-Console.WriteLine($"[nghttp2] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+Console.WriteLine($"[http2-buffered] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ $"{body.Length}-byte body (h2c prior knowledge)");
foreach (Thread thread in threads)
{
thread.Join();
}
- This is h2c with prior knowledge: the peer opens with the HTTP/2 connection preface and there is no upgrade dance. For h2 over TLS see the tab - the protocol code there is byte-for-byte identical, because Nghttp2Connection takes an IDuplexPipe and never learns what is under it.
h2c with prior knowledge: the peer opens with the HTTP/2 connection preface and there is no upgrade dance. For h2 over TLS see - the protocol code there is byte-for-byte this one, because Http2Connection takes an IDuplexPipe and never learns what is under it. BUFFERED is the dispatch mode: the handler runs once the request has fully arrived, so request.Body holds the whole body and the answer is one Http2Response. That is the right default, and the wrong one for a large upload or an endless response - the three tabs after it are those cases.
// dotnet add package ioxide
// dotnet add package ioxide.http2
// curl --http2-prior-knowledge http://127.0.0.1:8080/
+// curl --http2-prior-knowledge -N http://127.0.0.1:8080/feed # never ends
using System.Text;
using ioxide;
@@ -3494,12 +3499,17 @@ HTTP/2 · pure C#
ushort port = 8080;
int reactors = Environment.ProcessorCount;
-int bodyBytes = 2;
+int bodyBytes = 2; // unused here: the body is produced chunk by chunk
// Per-connection recv buffer rings (kernel 6.12+) instead of one shared ring per reactor. The
// handler code is identical either way; this only changes how recv buffers are handed out.
bool incrementalBuffers = false;
+
+
+// Chunks written per response on "/", and the size of each. Their product is never held at once.
+int chunkCount = 64;
+int chunkBytes = 16 * 1024;
// ─────────────────────────────────────────────────────────────────────────────────────────────
var config = new ServerConfig
@@ -3531,6 +3541,8 @@ HTTP/2 · pure C#
var threads = new Thread[config.ReactorCount];
+byte[] chunk = Encoding.ASCII.GetBytes(new string('x', chunkBytes - 1) + "\n");
+
for (int i = 0; i < threads.Length; i++)
{
var reactor = new Reactor(i, config);
@@ -3539,12 +3551,25 @@ HTTP/2 · pure C#
{
try
{
- // The connection owns the read loop from here: it feeds nghttp2, dispatches each
- // request once its stream ends, and drains the egress once per batch.
- await new Http2Connection(conn).RunBufferedAsync(_ => new Http2Response
+ await new Http2Connection(conn).RunAsync(async (request, writer) =>
{
- Status = 200,
- Body = body,
+ bool endless = request.Path.Span.SequenceEqual("/feed"u8);
+
+ // Headers first and once. No content-length: the length is not known yet, and for
+ // /feed never will be - END_STREAM is what marks the end instead.
+ var response = new Http2Response { Status = 200 };
+ response.Headers.Add("content-type"u8.ToArray(),
+ endless ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray());
+ writer.WriteHeaders(response);
+
+ for (int n = 0; endless || n < chunkCount; n++)
+ {
+ chunk.CopyTo(writer.GetSpan(chunk.Length));
+ writer.Advance(chunk.Length);
+
+ // Waits when either window is exhausted, and resumes on the WINDOW_UPDATE.
+ await writer.FlushAsync();
+ }
});
}
finally
@@ -3557,72 +3582,52 @@ HTTP/2 · pure C#
threads[i].Start();
}
-Console.WriteLine($"[http2] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
- + $"{body.Length}-byte body (h2c prior knowledge)");
+Console.WriteLine($"[http2-streamed-response] {config.ReactorCount} reactors on :{config.Tcp!.Port} "
+ + $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks per response");
foreach (Thread thread in threads)
{
thread.Join();
}
- Which to take? They measure the same. Interleaved warm runs on one rig - h2load -n 300000 -c 32 -m 32, 4 reactors, 2-byte body - put the ratio between 0.98× and 1.09×, and a 1 KiB body holds the same. On a small response the cost is the loop and the syscalls, not the header codec. So take ioxide.http2 when shipping a native library is inconvenient, and ioxide.nghttp2 when you want the reference implementation's coverage of the protocol's darker corners. The same choice exists one version up: ioxide.http3 is the pure-C# drop-in for ioxide.nghttp3.
The RESPONSE body produced over time instead of returned whole - each flush becomes a DATA frame. /feed is why the mode exists: an endless response has no final byte, so a buffered API cannot express it at all. Http2ResponseWriter is an IBufferWriter<byte>, so a serializer or a framework's response sink writes into it unchanged. What HTTP/2 adds over is that credit is SHARED: every stream rides one TCP connection, so a flush waits on whichever of the stream and connection windows runs out first, and a WINDOW_UPDATE for either wakes it.
// dotnet add package ioxide
-// dotnet add package ioxide.nghttp2
-// curl -k --http2 https://127.0.0.1:8443/
+// dotnet add package ioxide.http2
+// head -c 50000000 /dev/zero | curl --http2-prior-knowledge --data-binary @- \
+// http://127.0.0.1:8080/upload
-using System.IO.Pipelines;
using System.Text;
using ioxide;
-using ioxide.nghttp2;
-using ioxide.tls;
+using ioxide.http2;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
-ushort port = 8443; // https://127.0.0.1:8443/
-int reactors = Environment.ProcessorCount; // one ring per reactor, one reactor per core
-int bodyBytes = 2; // "ok" - this sample is about ALPN, not throughput
-
-
-
-// Hand OUTBOUND encryption to the kernel: the handler writes plaintext and the kernel makes the
-// records. Off by default - OpenSSL both ways is the portable path, and on loopback the kernel
-// is not faster. Its real payoff is sendfile and NIC offload, which a benchmark here cannot see.
-bool kernelTx = false;
-
-// Hand INBOUND decryption to the kernel as well. Requires kernelTx - the RX handoff happens at
-// the same moment as the TX one - and is experimental for a reason: a TLS 1.3 KeyUpdate cannot be
-// read through IORING_OP_RECV, and roughly one first connection in twelve fails outright.
-bool kernelRx = false;
+ushort port = 8080;
+int reactors = Environment.ProcessorCount;
-// Per-connection recv buffer rings (kernel 6.12+) instead of one shared ring per reactor. The
-// handler code is identical either way; this only changes how recv buffers are handed out.
-bool incrementalBuffers = false;
+// Advertised per stream. This is the ceiling on how far ahead of the handler a peer may get, so
+// on a streamed request it is the memory bound - not a throughput knob.
+int streamWindow = 256 * 1024;
// ─────────────────────────────────────────────────────────────────────────────────────────────
-const string certPath = "cert.pem"; // any PEM pair
-const string keyPath = "key.pem";
-
var config = new ServerConfig
{
- ReactorCount = reactors, // io_uring rings/threads - one per core
- RingEntries = 8192, // SQ/CQ depth per ring
- DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
- RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
- RecvSlots = 4096, // shared recv buffer-ring depth
- Incremental = incrementalBuffers ? new IncrementalOptions { MaxConnections = 1024, RecvSlots = 8, RecvBufferSize = 16 * 1024 } : null, // per-connection recv rings (6.12+) - see Tcp/Incremental
- Udp = null, // no raw UDP sockets (TCP-only server)
- Quic = null, // no QUIC transport - see Http3/* and Quic/Alpn
+ ReactorCount = reactors, // io_uring rings/threads - one per core
+ RingEntries = 8192, // SQ/CQ depth per ring
+ DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
+ RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
+ RecvSlots = 4096, // shared recv buffer-ring depth
+ Udp = null, // no raw UDP sockets (TCP-only server)
+ Quic = null, // no QUIC transport - see Http3/*
Tcp = new TcpOptions
{
Port = port,
- ExtraPorts = [], // extra listener ports (one handler, several doors)
ListenBacklog = 1024, // accept-queue depth per SO_REUSEPORT listener
WriteSlabSize = 16 * 1024, // per-connection write buffer before overflow kicks in
PoolMax = 1024, // pooled connection objects kept per reactor
@@ -3632,13 +3637,11 @@ HTTP/2 · TLS & ALPN
},
};
-byte[] body = bodyBytes == 2 ? "ok"u8.ToArray() : [.. Enumerable.Repeat((byte)'x', bodyBytes)];
-
-byte[] http11Response =
-[
- .. Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Length: {body.Length}\r\n\r\n"),
- .. body,
-];
+var http2 = new Http2Options
+{
+ StreamRequestBodies = true, // the whole point: dispatch at the headers, body follows
+ InitialWindowSize = streamWindow, // how far ahead of the handler the peer may run
+};
var threads = new Thread[config.ReactorCount];
@@ -3646,94 +3649,158 @@ HTTP/2 · TLS & ALPN
{
var reactor = new Reactor(i, config);
- reactor.OnStart = r => TlsService.Start(r, new TlsOptions
- {
- CertificatePath = certPath, // PEM certificate chain file (leaf first)
- CertificatePem = null, // in-memory PEM alternative - set one, not both
- KeyPath = keyPath, // PEM private key file
- KeyPem = null, // in-memory PEM alternative to KeyPath
- Alpn = ["h2", "http/1.1"], // ORDERED, most preferred first - a client offering both gets h2
- KernelTx = kernelTx, // kTLS encrypt: kernel makes the records (off = OpenSSL both ways)
- KernelRx = kernelRx, // kTLS decrypt: needs KernelTx; experimental (see the knob above)
- });
-
reactor.TcpHandle = async (r, conn) =>
{
- TlsSession? tls = null;
try
{
- tls = await r.GetService<TlsService>()!.AcceptAsync(conn);
-
- if (tls.NegotiatedAlpn == "h2")
+ await new Http2Connection(conn, http2).RunBufferedAsync(async request =>
{
- // The decrypt lives in the pipe, so the HTTP/2 code below is identical to the
- // cleartext sample. Which halves the pipe uses is decided by what the handshake
- // achieved, not by anything chosen here.
- await using var pipe = new TlsConnectionDualPipe(conn, tls, ownsSession: false);
+ // BodyReader is set because StreamRequestBodies is on; with it off the body would
+ // be in request.Body instead and this would be null.
+ long total = 0;
+ if (request.BodyReader is { } body)
+ {
+ while (true)
+ {
+ // Empty means end of body. Each read hands back the peer's credit for the
+ // chunk it returns, which is what lets the next one arrive - and the
+ // memory it points at is recycled by the NEXT read, so anything worth
+ // keeping has to be copied out here.
+ ReadOnlyMemory<byte> chunk = await body.ReadAsync();
+ if (chunk.IsEmpty)
+ {
+ break;
+ }
+ total += chunk.Length;
+ }
+ }
- await new Nghttp2Connection(pipe).RunBufferedAsync(_ => new Nghttp2Response
+ return new Http2Response
{
Status = 200,
- Body = body,
- });
- return;
- }
+ Body = Encoding.ASCII.GetBytes($"{total} bytes\n"),
+ };
+ });
+ }
+ finally
+ {
+ conn.DecRef();
+ }
+ };
- // Anything else: HTTP/1.1 on the same port.
- //
- // The carry is not incidental. TLS hands back RECORDS, not requests, so a request
- // split across two records decrypts twice - and answering on "plaintext arrived"
- // would answer twice to one request. Framing is ours; ioxide does not parse HTTP.
- var carry = new Carry();
+ threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+ threads[i].Start();
+}
- // The client's first request usually rides in with its Finished flight, so the
- // handshake already decrypted it and it is sitting in the session, not in any recv
- // buffer. Miss this and that request is dropped and the loop parks on bytes that
- // already arrived - which is exactly what happened here before.
- carry.Append(tls.DrainPlaintext());
+Console.WriteLine($"[http2-streamed-request] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ + $"request bodies streamed, {streamWindow / 1024} KiB window per stream");
- while (true)
- {
- bool wrote = false;
- int end;
- while ((end = carry.Span.IndexOf("\r\n\r\n"u8)) >= 0)
- {
- carry.Consume(end + 4);
- // Correct whichever backend the session ended up with.
- tls.Write(conn, http11Response);
- wrote = true;
- }
+foreach (Thread thread in threads)
+{
+ thread.Join();
+}
+ The other direction, and a different problem. StreamRequestBodies dispatches at the HEADERS and hands the handler an Http2BodyReader, so it runs while the upload is still arriving. What changes is what bounds memory: buffered holds the whole body, so MaxRequestBytes is all that stands between a hostile peer and the arena; streamed holds ONE flow-control window, because a chunk credits the peer's window only as the handler reads it. Fall behind and the peer runs out of credit and stops sending - backpressure the peer takes part in, rather than a buffer you hope is big enough.
// dotnet add package ioxide
+// dotnet add package ioxide.http2
+// curl --http2-prior-knowledge -N http://127.0.0.1:8080/feed
+// head -c 50000000 /dev/zero | curl --http2-prior-knowledge --data-binary @- \
+// http://127.0.0.1:8080/echo
- if (wrote)
- {
- await conn.FlushAsync();
- }
+using ioxide;
+using ioxide.http2;
- RecvSnapshot snapshot = await conn.ReadAsync();
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
- unsafe
+ushort port = 8080;
+int reactors = Environment.ProcessorCount;
+int chunkBytes = 1024; // one DATA frame per flush on /feed
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+var config = new ServerConfig
+{
+ ReactorCount = reactors, // io_uring rings/threads - one per core
+ RingEntries = 8192, // SQ/CQ depth per ring
+ DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
+ RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
+ RecvSlots = 4096, // shared recv buffer-ring depth
+ Udp = null, // no raw UDP sockets (TCP-only server)
+ Quic = null, // no QUIC transport - see Http3/*
+ Tcp = new TcpOptions
+ {
+ Port = port,
+ ListenBacklog = 1024, // accept-queue depth per SO_REUSEPORT listener
+ WriteSlabSize = 16 * 1024, // per-connection write buffer before overflow kicks in
+ PoolMax = 1024, // pooled connection objects kept per reactor
+ WriteOverflow = WriteOverflowStrategy.Grow, // Grow = realloc one slab; Segmented = chain + vectored SENDMSG
+ ZeroCopySend = false, // SEND_ZC: kernel copies less, wins on large writes
+ RecvQueueEntries = 64, // per-connection recv completion queue depth
+ },
+};
+
+// Both halves are opt-in and independent: this one turns the REQUEST direction on, RunAsync below
+// is what turns the RESPONSE direction on.
+var http2 = new Http2Options { StreamRequestBodies = true };
+
+byte[] chunk = [.. Enumerable.Repeat((byte)'x', chunkBytes)];
+
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+ var reactor = new Reactor(i, config);
+
+ reactor.TcpHandle = async (r, conn) =>
+ {
+ try
+ {
+ await new Http2Connection(conn, http2).RunAsync(async (request, writer) =>
+ {
+ bool echo = request.Path.Span.SequenceEqual("/echo"u8);
+
+ // No content-length: on /feed the length will never be known, and on /echo it is
+ // not known yet. END_STREAM is what marks the end instead.
+ var response = new Http2Response { Status = 200 };
+ response.Headers.Add("content-type"u8.ToArray(), "text/plain"u8.ToArray());
+ writer.WriteHeaders(response);
+
+ if (echo)
{
- while (conn.TryGetItem(snapshot, out ioxide.utils.SpscRecvRing.Item item))
+ // Both directions at once. Nothing here holds more than one chunk: the read
+ // credits the peer for what it hands back, and the flush waits for room on the
+ // way out - so a fast uploader is paced by the slower of the two, not buffered.
+ while (true)
{
- if (item.HasBuffer)
+ ReadOnlyMemory<byte> incoming = await request.BodyReader!.ReadAsync();
+ if (incoming.IsEmpty)
{
- carry.Append(tls.Decrypt(item.Ptr, item.Len));
- conn.ReturnBuffer(in item);
+ break;
}
+
+ incoming.Span.CopyTo(writer.GetSpan(incoming.Length));
+ writer.Advance(incoming.Length);
+ await writer.FlushAsync();
}
+ return;
}
- if (snapshot.IsClosed || tls.Closed) return;
- conn.ResetRead();
- }
- }
- catch (Exception e)
- {
- Console.Error.WriteLine($"[http2-tls] connection failed: {e.Message}");
+ // /feed: a response with no end at all. There is no final byte to wait for, which
+ // is the case a buffered API has no way to express.
+ while (true)
+ {
+ chunk.CopyTo(writer.GetSpan(chunk.Length));
+ writer.Advance(chunk.Length);
+ await writer.FlushAsync();
+ }
+ });
}
finally
{
- tls?.Dispose();
conn.DecRef();
}
};
@@ -3742,45 +3809,106 @@ HTTP/2 · TLS & ALPN
threads[i].Start();
}
-Console.WriteLine($"[http2-tls] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
- + $"ALPN h2 then http/1.1, cert {certPath}, "
- + $"rx={(kernelTx && kernelRx ? "kernel" : "openssl")}, "
- + $"tx={(kernelTx ? "kernel" : "openssl")}");
+Console.WriteLine($"[http2-streamed-both] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ + $"request pulled and response pushed ({chunkBytes}-byte chunks on /feed)");
foreach (Thread thread in threads)
{
thread.Join();
-}
+}
+ Both at once, which is the shape a proxy needs: /echo reads a chunk and writes a chunk, so neither the upload nor the download is ever held whole. The two directions are separate switches - StreamRequestBodies for the read side, RunAsync with a writer for the write side - because they solve different problems and most servers want exactly one of them. Mirrors ; the difference is HTTP/2's shared connection window, which a handler that stops reading holds down for every other stream on the connection.
// dotnet add package ioxide
+// dotnet add package ioxide.nghttp2
+// curl --http2-prior-knowledge http://127.0.0.1:8080/
-// Decrypted-but-unframed bytes: append at the end, consume from the front. A List<byte> pressed
-// into this job needs CollectionsMarshal to be searched and RemoveRange to be consumed; a plain
-// array does both directly.
-sealed class Carry
+using System.Text;
+using ioxide;
+using ioxide.nghttp2;
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+
+ushort port = 8080;
+int reactors = Environment.ProcessorCount;
+int bodyBytes = 2;
+
+
+// Per-connection recv buffer rings (kernel 6.12+) instead of one shared ring per reactor. The
+// handler code is identical either way; this only changes how recv buffers are handed out.
+bool incrementalBuffers = false;
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+var config = new ServerConfig
{
- private byte[] _buf = new byte[8 * 1024];
- private int _len;
+ ReactorCount = reactors, // io_uring rings/threads - one per core
+ RingEntries = 8192, // SQ/CQ depth per ring
+ DualStack = false, // true = one IPv6 socket also accepts IPv4-mapped
+ RecvBufferSize = 32 * 1024, // bytes per shared recv buffer
+ RecvSlots = 4096, // shared recv buffer-ring depth
+ Incremental = incrementalBuffers ? new IncrementalOptions { MaxConnections = 1024, RecvSlots = 8, RecvBufferSize = 16 * 1024 } : null, // per-connection recv rings (6.12+) - see Tcp/Incremental
+ Udp = null, // no raw UDP sockets (TCP-only server)
+ Quic = null, // no QUIC transport - see Http3/* and Quic/Alpn
+ Tcp = new TcpOptions
+ {
+ Port = port,
+ ExtraPorts = [], // extra listener ports (one handler, several doors)
+ ListenBacklog = 1024, // accept-queue depth per SO_REUSEPORT listener
+ WriteSlabSize = 16 * 1024, // per-connection write buffer before overflow kicks in
+ PoolMax = 1024, // pooled connection objects kept per reactor
+ WriteOverflow = WriteOverflowStrategy.Grow, // Grow = realloc one slab; Segmented = chain + vectored SENDMSG
+ ZeroCopySend = false, // SEND_ZC: kernel copies less, wins on large writes
+ RecvQueueEntries = 64, // per-connection recv completion queue depth
+ },
+};
- public ReadOnlySpan<byte> Span => _buf.AsSpan(0, _len);
+byte[] body = bodyBytes == 2
+ ? "ok"u8.ToArray()
+ : [.. Enumerable.Repeat((byte)'x', bodyBytes)];
- public void Append(ReadOnlySpan<byte> bytes)
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+ var reactor = new Reactor(i, config);
+
+ reactor.TcpHandle = async (r, conn) =>
{
- if (_buf.Length - _len < bytes.Length)
+ try
{
- Array.Resize(ref _buf, Math.Max(_buf.Length * 2, _len + bytes.Length));
+ // The connection owns the read loop from here: it feeds nghttp2, dispatches each
+ // request once its stream ends, and drains the egress once per batch.
+ await new Nghttp2Connection(conn).RunBufferedAsync(_ => new Nghttp2Response
+ {
+ Status = 200,
+ Body = body,
+ });
}
- bytes.CopyTo(_buf.AsSpan(_len));
- _len += bytes.Length;
- }
+ finally
+ {
+ conn.DecRef();
+ }
+ };
- public void Consume(int count)
- {
- _buf.AsSpan(count, _len - count).CopyTo(_buf);
- _len -= count;
- }
+ threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+ threads[i].Start();
+}
+
+Console.WriteLine($"[nghttp2] {config.ReactorCount} reactors on :{config.Tcp!.Port}, "
+ + $"{body.Length}-byte body (h2c prior knowledge)");
+
+foreach (Thread thread in threads)
+{
+ thread.Join();
}
- How a browser actually reaches h2: over TLS, with the protocol chosen during the handshake. Alpn = ["h2", "http/1.1"] is an ordered preference, not a weighting - the server takes the first entry the client also offered, and this sample then branches on what was agreed, so one port serves both. That is why the h2c samples are the exception rather than the rule. TLS here is OpenSSL both ways by default; the kernelTx/kernelRx knobs at the top are what move either direction into the kernel.
The same h2c server on the reference implementation. Kept because being battle-tested is a property no amount of benchmarking substitutes for: nghttp2 is continuously fuzzed, patched by people whose job it is when the next HTTP/2 CVE lands, and has a decade of interop against every other stack. What it does not have is the streaming: it is buffered only, so there is no counterpart here to or , and its dispatch loop still waits for each handler in turn. Measured as a client on this rig, the managed stack runs 1.35×-1.39× it. Take this one when you want the reference implementation's coverage; take for everything else.
// dotnet add package ioxide
@@ -4023,7 +4151,7 @@ HTTP/3 · nghttp3
// One engine for the whole server. ALPN pinned to h3, so nothing else negotiates. The last arg is
// the per-connection send-retention high-water (default 16 MiB): a response larger than it streams
// out paced by acks instead of buffering whole, so h3 serves large files in bounded memory. See
-// Playground/Http3/Buffered for the full QUIC/h3 knob set.
+// Playground/Http3/Nghttp3Buffered for the full QUIC/h3 knob set.
using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"], maxSendRetentionBytes: 16L << 20);
var config = new ServerConfig
@@ -4697,7 +4825,7 @@ HTTP/1.1 in · HTTP/3 out
ioxide + ioxide.httpclient
// dotnet add package ioxide ioxide.httpclient
-// dotnet run --project Playground/Http3/Nghttp3 # h3 origin on udp :8443
+// dotnet run --project Playground/Http3/Nghttp3Request # h3 origin on udp :8443
// PLAYGROUND_UPSTREAM_PORT=8443 dotnet run --project Playground/Proxy/H1ToH3
// curl -k https://127.0.0.1:8443/
@@ -4905,16 +5033,16 @@ HTTP/1.1 in · HTTP/3 out
HTTP/2 in · HTTP/1.1 out
- ioxide + ioxide.nghttp2 + ioxide.httpclient
+ ioxide + ioxide.http2 + ioxide.httpclient
-// dotnet add package ioxide ioxide.nghttp2 ioxide.httpclient
+// dotnet add package ioxide ioxide.http2 ioxide.httpclient
// PLAYGROUND_PORT=8444 dotnet run --project Playground/Tls/Ktls # a TLS origin
// curl -k --http2 https://127.0.0.1:8443/
using System.Text;
using ioxide;
using ioxide.httpclient;
-using ioxide.nghttp2;
+using ioxide.http2;
using ioxide.tls;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
@@ -5024,7 +5152,7 @@ HTTP/2 in · HTTP/1.1 out
// Buffered + async: each stream dispatches with its body assembled, and the handler
// may await - the upstream round trip resumes inline on this reactor. Concurrent
// streams interleave here, which is exactly why the h1 pool has to be deep.
- await new Nghttp2Connection(pipe).RunBufferedAsync(async request =>
+ await new Http2Connection(pipe).RunBufferedAsync(async request =>
{
try
{
@@ -5033,11 +5161,11 @@ HTTP/2 in · HTTP/1.1 out
using HttpClientResponse response = await client.SendAsync(new HttpClientRequest(
request.Method, request.Path) { Body = request.Body });
- // Copy before Dispose: the response arena is freed then, and nghttp2 copies
- // the h2 response only AFTER this handler returns. A real proxy would also
+ // Copy before Dispose: the response arena is freed then, and the h2 response
+ // is framed only AFTER this handler returns. A real proxy would also
// drop hop-by-hop headers - Connection, Keep-Alive, Transfer-Encoding are all
// illegal in h2 and would be a protocol error to forward.
- var proxied = new Nghttp2Response
+ var proxied = new Http2Response
{
Status = response.Status,
Body = response.Body.ToArray(),
@@ -5053,7 +5181,7 @@ HTTP/2 in · HTTP/1.1 out
// Upstream down is a gateway error on this stream, not a dead h2 connection:
// every other stream on it keeps working. A refused certificate arrives the
// same way - the handshake is part of opening the upstream connection.
- return new Nghttp2Response
+ return new Http2Response
{
Status = 502,
Body = Encoding.ASCII.GetBytes($"upstream failed: {e.Message}\n"),
@@ -5090,16 +5218,16 @@ HTTP/2 in · HTTP/1.1 out
HTTP/2 in · HTTP/2 out
- ioxide + ioxide.nghttp2 + ioxide.httpclient
+ ioxide + ioxide.http2 + ioxide.httpclient
-// dotnet add package ioxide ioxide.nghttp2 ioxide.httpclient
+// dotnet add package ioxide ioxide.http2 ioxide.httpclient
// PLAYGROUND_PORT=8444 dotnet run --project Playground/Http2/Tls # an h2-over-TLS origin
// curl -k --http2 https://127.0.0.1:8443/
using System.Text;
using ioxide;
using ioxide.httpclient;
-using ioxide.nghttp2;
+using ioxide.http2;
using ioxide.tls;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
@@ -5206,7 +5334,7 @@ HTTP/2 in · HTTP/2 out
// Buffered + async: each stream dispatches with its body assembled, and the handler
// may await - the upstream round trip resumes inline on this reactor.
- await new Nghttp2Connection(pipe).RunBufferedAsync(async request =>
+ await new Http2Connection(pipe).RunBufferedAsync(async request =>
{
try
{
@@ -5215,11 +5343,11 @@ HTTP/2 in · HTTP/2 out
using HttpClientResponse response = await client.SendAsync(new HttpClientRequest(
request.Method, request.Path) { Body = request.Body });
- // Copy before Dispose: the response arena is freed then, and nghttp2 copies
- // the h2 response only AFTER this handler returns. A real proxy would also
+ // Copy before Dispose: the response arena is freed then, and the h2 response
+ // is framed only AFTER this handler returns. A real proxy would also
// drop hop-by-hop headers - Connection, Keep-Alive, Transfer-Encoding are all
// illegal in h2 and would be a protocol error to forward.
- var proxied = new Nghttp2Response
+ var proxied = new Http2Response
{
Status = response.Status,
Body = response.Body.ToArray(),
@@ -5235,7 +5363,7 @@ HTTP/2 in · HTTP/2 out
// Upstream down is a gateway error on this stream, not a dead h2 connection:
// every other stream on it keeps working. A refused certificate arrives the
// same way - the handshake is part of opening the upstream connection.
- return new Nghttp2Response
+ return new Http2Response
{
Status = 502,
Body = Encoding.ASCII.GetBytes($"upstream failed: {e.Message}\n"),
@@ -5267,22 +5395,22 @@ HTTP/2 in · HTTP/2 out
{
thread.Join();
}
- The narrowest proxy in this set: two sockets per reactor no matter how many requests are in flight. Two independent nghttp2 sessions are involved and they share nothing - HPACK state is per-connection, so a proxy always re-encodes. “Just splice the frames” is not a shortcut that exists.
+ The narrowest proxy in this set: two sockets per reactor no matter how many requests are in flight. Two independent HTTP/2 sessions are involved and they share nothing - HPACK state is per-connection, so a proxy always re-encodes. “Just splice the frames” is not a shortcut that exists.
HTTP/2 in · HTTP/3 out
- ioxide + ioxide.nghttp2 + ioxide.httpclient
+ ioxide + ioxide.http2 + ioxide.httpclient
-// dotnet add package ioxide ioxide.nghttp2 ioxide.httpclient
-// dotnet run --project Playground/Http3/Nghttp3 # h3 origin on udp :8443
+// dotnet add package ioxide ioxide.http2 ioxide.httpclient
+// dotnet run --project Playground/Http3/Nghttp3Request # h3 origin on udp :8443
// PLAYGROUND_UPSTREAM_PORT=8443 dotnet run --project Playground/Proxy/H2ToH3
// curl -k --http2 https://127.0.0.1:8443/
using System.Text;
using ioxide;
using ioxide.httpclient;
-using ioxide.nghttp2;
+using ioxide.http2;
using ioxide.tls;
// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
@@ -5374,7 +5502,7 @@ HTTP/2 in · HTTP/3 out
// Buffered + async: each stream dispatches with its body assembled, and the handler
// may await - the upstream round trip resumes inline on this reactor.
- await new Nghttp2Connection(pipe).RunBufferedAsync(async request =>
+ await new Http2Connection(pipe).RunBufferedAsync(async request =>
{
try
{
@@ -5383,11 +5511,11 @@ HTTP/2 in · HTTP/3 out
using HttpClientResponse response = await client.SendAsync(new HttpClientRequest(
request.Method, request.Path) { Body = request.Body });
- // Copy before Dispose: the response arena is freed then, and nghttp2 copies
- // the h2 response only AFTER this handler returns. A real proxy would also
+ // Copy before Dispose: the response arena is freed then, and the h2 response
+ // is framed only AFTER this handler returns. A real proxy would also
// drop hop-by-hop headers - Connection, Keep-Alive, Transfer-Encoding are all
// illegal in h2 and would be a protocol error to forward.
- var proxied = new Nghttp2Response
+ var proxied = new Http2Response
{
Status = response.Status,
Body = response.Body.ToArray(),
@@ -5403,7 +5531,7 @@ HTTP/2 in · HTTP/3 out
// Upstream down is a gateway error on this stream, not a dead h2 connection:
// every other stream on it keeps working. A refused certificate arrives the
// same way - the handshake is part of opening the upstream connection.
- return new Nghttp2Response
+ return new Http2Response
{
Status = 502,
Body = Encoding.ASCII.GetBytes($"upstream failed: {e.Message}\n"),
@@ -5744,7 +5872,7 @@ HTTP/3 in · HTTP/3 out
ioxide + ioxide.ngtcp2 + ioxide.nghttp3 + ioxide.httpclient
// dotnet add package ioxide ioxide.ngtcp2 ioxide.nghttp3 ioxide.httpclient
-// PLAYGROUND_QUIC_PORT=8444 dotnet run --project Playground/Http3/Nghttp3
+// PLAYGROUND_QUIC_PORT=8444 dotnet run --project Playground/Http3/Nghttp3Request
// curl --http3-only -k https://127.0.0.1:8443/
using ioxide;
@@ -5872,7 +6000,7 @@ HTTP client · alt-svc
// dotnet add package ioxide
// dotnet add package ioxide.httpclient
-// dotnet run -c Release --project Playground/Http3/Nghttp3 # an origin advertising h3
+// dotnet run -c Release --project Playground/Http3/Nghttp3Request # an origin advertising h3
// PLAYGROUND_UPSTREAM_PORT=8080 dotnet run -c Release --project Playground/Clients/Http
using System.Text;
diff --git a/docs/learn/overview.html b/docs/learn/overview.html
index ec5ea53..a277fd2 100644
--- a/docs/learn/overview.html
+++ b/docs/learn/overview.html
@@ -121,7 +121,7 @@ Speaking a protocol
SslStream. HTTP/2 and HTTP/3 each come twice - a bundled native, or the same
surface in pure C# with no native code at all:
- ioxide.nghttp2 / ioxide.http2 - HTTP/2. h2c with prior
+ ioxide.http2 - HTTP/2, pure C#. h2c with prior
knowledge, or h2 over TLS chosen by ALPN.
ioxide.nghttp3 / ioxide.http3 - HTTP/3, over
QUIC from ioxide.ngtcp2.
diff --git a/docs/learn/quic-h3.html b/docs/learn/quic-h3.html
index 66526dd..1369ab8 100644
--- a/docs/learn/quic-h3.html
+++ b/docs/learn/quic-h3.html
@@ -125,7 +125,7 @@ The layer map
Wiring it up, shown with every QUIC/h3 knob at its default
- (Playground/Http3/Buffered is the same as an editable reference):
Playground/Http3/Nghttp3Buffered is the same as an editable reference):
var engine = new QuicEngine( certPath, keyPath, cidLength: 8, // connection-id length this endpoint mints diff --git a/docs/learn/tls.html b/docs/learn/tls.html index 2890403..af5b064 100644 --- a/docs/learn/tls.html +++ b/docs/learn/tls.html @@ -289,7 +289,7 @@/// -/// A drop-in alternative tokTLS: performance
size: 0.79× at 64 bytes is not the same server as 0.35× at 64 KiB.kTLS is behind on large single writes - the kernel has to split one 256 KiB write into sixteen records - and level once the protocol above it already chunks. Over HTTP/2, where - nghttp2 frames responses into 16 KiB DATA frames before they reach the socket, the two are + HTTP/2 frames responses into 16 KiB DATA frames before they reach the socket, the two are within noise of each other at every size.
kTLS: where the cost actually is
diff --git a/ioxide.slnx b/ioxide.slnx index 8bef36e..bc770cf 100644 --- a/ioxide.slnx +++ b/ioxide.slnx @@ -4,8 +4,8 @@- + @@ -55,19 +55,21 @@ - - + + + + + - - + + - - - + + diff --git a/scripts/gen-docs-panes.py b/scripts/gen-docs-panes.py index 8dc50b1..26216c5 100644 --- a/scripts/gen-docs-panes.py +++ b/scripts/gen-docs-panes.py @@ -108,30 +108,20 @@ "connection and pool state stay single-threaded without a lock even when a handler wanders."), # ── protocols ──────────────────────────────────────────────────────────────────────────── - "h2tls": ( - "Http2/Tls", "HTTP/2 · TLS & ALPN", "ioxide + ioxide.nghttp2", + "h2cstls": ( + "Http2/Tls", "HTTP/2 · TLS & ALPN", "ioxide + ioxide.http2", ["curl -k --http2 https://127.0.0.1:8443/"], "How a browser actually reaches h2: over TLS, with the protocol chosen during the " "handshake. Alpn = [\"h2\", \"http/1.1\"]is an ordered preference, not a " "weighting - the server takes the first entry the client also offered, and this sample " - "then branches on what was agreed, so one port serves both. That is why the h2c samples " - "are the exception rather than the rule. TLS here is " - "OpenSSL both ways by default; thekernelTx/kernelRx" - "knobs at the top are what move either direction into the kernel."), - "h2cstls": ( - "Http2/ManagedTls", "HTTP/2 · pure C# over TLS", "ioxide + ioxide.http2", - ["curl -k --http2 https://127.0.0.1:8443/"], - "The pure-C# HTTP/2 server behind TLS, and the diff against " - " is three " - "type names and a package - no native library anywhere. Both take an " - "IDuplexPipe, so neither learns what is under it. " - "Measured on this rig at 2 reactors, 64 connections, 32 streams, a 2-byte body: pure C# " - "runs 2.46× nghttp2 over TLS and 2.73× cleartext, at about a third of " - "the CPU per request. That ordering does not generalize - at 32 connections over 4 " - "reactors nghttp2 is ahead instead - and a 2-byte body measures framing and HPACK rather " - "than moving data. TLS itself costs both the same 0.05µs per request."), + "then branches on what was agreed, so ONE port serves both. That is why the h2c samples " + "are the exception rather than the rule. WhatHttp2Connectionis handed is a " + "TlsConnectionDualPipe: it never learns TLS is involved, so the protocol code " + "is byte-for-byte the h2c sample's. TLS is OpenSSL both ways by default; the " + "kernelTx/kernelRxknobs at the top move either direction into " + "the kernel, and cost the same 0.05µs per request either way."), "h2bcl": ( - "Http2/SslStream", "HTTP/2 · over SslStream", "ioxide + ioxide.nghttp2", + "Http2/SslStream", "HTTP/2 · over SslStream", "ioxide + ioxide.http2", ["curl -k --http2 https://127.0.0.1:8443/"], "HTTP/2 over the BCL'sSslStream, and the point is the ten-line " "Stream-to-IDuplexPipeadapter at the bottom. " @@ -139,7 +129,7 @@ "directly, over ioxide's TLS, or overSslStream- the transport is a " "constructor argument, not a branch inside the protocol."), "h3cs": ( - "Http3/Managed", "HTTP/3 · pure C#", "ioxide + ioxide.ngtcp2 + ioxide.http3", + "Http3/Buffered", "HTTP/3 · buffered", "ioxide + ioxide.ngtcp2 + ioxide.http3", ["curl --http3-only -k https://127.0.0.1:8443/"], "HTTP/3 with no native library above the transport - frames, QPACK and Huffman are " "all managed code, and only QUIC itself stays native. Drop-in for " @@ -150,7 +140,7 @@ "from sending the header frame and a short body in ONE call rather than two; the large-body " "lead is the native shim copying every response body at submit, which this never does."), "h3stream": ( - "Http3/Streamed", "HTTP/3 · streamed response", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", + "Http3/Nghttp3Response", "HTTP/3 · response streamed (nghttp3)", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/", "curl --http3-only -kN https://127.0.0.1:8443/feed # never ends"], "The response body produced OVER TIME instead of handed over whole - each flush becomes a " @@ -162,7 +152,7 @@ "PULLS body bytes rather than accepting pushes, which is why this carries a resume and a " "drain the pure-C# writer does not need."), "h3csstream": ( - "Http3/ManagedStreamed", "HTTP/3 · streamed both ways", "ioxide + ioxide.ngtcp2 + ioxide.http3", + "Http3/StreamedBoth", "HTTP/3 · request + response streamed", "ioxide + ioxide.ngtcp2 + ioxide.http3", ["curl --http3-only -k https://127.0.0.1:8443/", "curl --http3-only -kN https://127.0.0.1:8443/feed # never ends", "curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo"], @@ -178,7 +168,7 @@ "resume and a drain because nghttp3 pulls instead; this measures 1.32× its " "throughput on the same 8×1 KiB response."), "h3buf": ( - "Http3/Buffered", "HTTP/3 · buffered dispatch", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", + "Http3/Nghttp3Buffered", "HTTP/3 · buffered (nghttp3)", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"], "The same server as with the other " "dispatch mode - one method call is the whole difference. Buffered waits for " @@ -232,16 +222,71 @@ "appends across recvs into the same buffer, so a request split over several reads arrives " "contiguous. Costs memory per connection - see " "."), - "h2": ( - "Http2/Nghttp2", "HTTP/2 · nghttp2", "ioxide + ioxide.nghttp2", - ["curl --http2-prior-knowledge http://127.0.0.1:8080/"], - 'This is h2c with prior knowledge: the peer opens with the HTTP/2 connection preface and there is no upgrade dance. For h2 over TLS see the tab - the protocol code there is byte-for-byte identical, becauseNghttp2Connectiontakes anIDuplexPipeand never learns what is under it.'), "h2cs": ( - "Http2/Managed", "HTTP/2 · pure C#", "ioxide + ioxide.http2", + "Http2/Buffered", "HTTP/2 · buffered", "ioxide + ioxide.http2", + ["curl --http2-prior-knowledge http://127.0.0.1:8080/"], + "h2c with prior knowledge: the peer opens with the HTTP/2 connection preface and " + "there is no upgrade dance. For h2 over TLS see " + " - the protocol code " + "there is byte-for-byte this one, becauseHttp2Connectiontakes an " + "IDuplexPipeand never learns what is under it. " + "BUFFERED is the dispatch mode: the handler runs once the request has fully arrived, so " + "request.Bodyholds the whole body and the answer is one " + "Http2Response. That is the right default, and the wrong one for a large " + "upload or an endless response - the three tabs after it are those cases."), + "h2sresp": ( + "Http2/StreamedResponse", "HTTP/2 · response streamed", "ioxide + ioxide.http2", + ["curl --http2-prior-knowledge http://127.0.0.1:8080/", + "curl --http2-prior-knowledge -N http://127.0.0.1:8080/feed # never ends"], + "The RESPONSE body produced over time instead of returned whole - each flush becomes a " + "DATA frame./feedis why the mode exists: an endless response has no final " + "byte, so a buffered API cannot express it at all.Http2ResponseWriteris an " + "IBufferWriter<byte>, so a serializer or a framework's response sink " + "writes into it unchanged. What HTTP/2 adds over " + " is that credit " + "is SHARED: every stream rides one TCP connection, so a flush waits on whichever of the " + "stream and connection windows runs out first, and a WINDOW_UPDATE for either wakes it."), + "h2sreq": ( + "Http2/StreamedRequest", "HTTP/2 · request streamed", "ioxide + ioxide.http2", + ["head -c 50000000 /dev/zero | curl --http2-prior-knowledge --data-binary @- \\", + " http://127.0.0.1:8080/upload"], + "The other direction, and a different problem.StreamRequestBodiesdispatches " + "at the HEADERS and hands the handler anHttp2BodyReader, so it runs while " + "the upload is still arriving. What changes is what bounds memory: buffered holds the " + "whole body, soMaxRequestBytesis all that stands between a hostile peer and " + "the arena; streamed holds ONE flow-control window, because a chunk credits the peer's " + "window only as the handler reads it. Fall behind and the peer runs out of credit " + "and stops sending - backpressure the peer takes part in, rather than a buffer you hope is " + "big enough."), + "h2sboth": ( + "Http2/StreamedBoth", "HTTP/2 · both directions streamed", "ioxide + ioxide.http2", + ["curl --http2-prior-knowledge -N http://127.0.0.1:8080/feed", + "head -c 50000000 /dev/zero | curl --http2-prior-knowledge --data-binary @- \\", + " http://127.0.0.1:8080/echo"], + "Both at once, which is the shape a proxy needs:/echoreads a chunk and " + "writes a chunk, so neither the upload nor the download is ever held whole. The two " + "directions are separate switches -StreamRequestBodiesfor the read side, " + "RunAsyncwith a writer for the write side - because they solve different " + "problems and most servers want exactly one of them. Mirrors " + "; the difference is HTTP/2's shared connection window, which a handler " + "that stops reading holds down for every other stream on the connection."), + "h2ng": ( + "Http2/Nghttp2Buffered", "HTTP/2 · nghttp2", "ioxide + ioxide.nghttp2", ["curl --http2-prior-knowledge http://127.0.0.1:8080/"], - "Which to take? They measure the same. Interleaved warm runs on one rig -h2load -n 300000 -c 32 -m 32, 4 reactors, 2-byte body - put the ratio between 0.98× and 1.09×, and a 1 KiB body holds the same. On a small response the cost is the loop and the syscalls, not the header codec. So takeioxide.http2when shipping a native library is inconvenient, andioxide.nghttp2when you want the reference implementation's coverage of the protocol's darker corners. The same choice exists one version up:ioxide.http3is the pure-C# drop-in forioxide.nghttp3."), + "The same h2c server on the reference implementation. Kept because being battle-tested " + "is a property no amount of benchmarking substitutes for: nghttp2 is continuously fuzzed, " + "patched by people whose job it is when the next HTTP/2 CVE lands, and has a decade of " + "interop against every other stack. " + "What it does not have is the streaming: it is buffered only, so there is no " + "counterpart here to " + "or , and its dispatch " + "loop still waits for each handler in turn. Measured as a client on this rig, the managed " + "stack runs 1.35×-1.39× it. Take this one when you want the reference " + "implementation's coverage; take " + " for everything else."), "h3": ( - "Http3/Nghttp3", "HTTP/3 · nghttp3", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", + "Http3/Nghttp3Request", "HTTP/3 · request streamed (nghttp3)", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3", ["curl --http3-only -k https://127.0.0.1:8443/"], "HTTP/3 over QUIC, dispatched as the body streams. Compare " ", which waits for end-of-stream " @@ -263,7 +308,7 @@ "ioxide.nghttp3sits on."), "http": ( "Clients/Http", "HTTP client · alt-svc", "ioxide + ioxide.httpclient", - ["dotnet run -c Release --project Playground/Http3/Nghttp3 # an origin advertising h3", + ["dotnet run -c Release --project Playground/Http3/Nghttp3Request # an origin advertising h3", "PLAYGROUND_UPSTREAM_PORT=8080 dotnet run -c Release --project Playground/Clients/Http"], "Both hops - the inbound connection and the outbound call - ride this reactor's ring and " "resume inline, so a request never leaves the thread it arrived on. The knob is " diff --git a/scripts/gen-docs-proxy-panes.py b/scripts/gen-docs-proxy-panes.py index d2af797..d230d40 100644 --- a/scripts/gen-docs-proxy-panes.py +++ b/scripts/gen-docs-proxy-panes.py @@ -28,23 +28,23 @@ "H1ToH3": ("h1 → h3", "HTTP/1.1 in · HTTP/3 out", "ioxide + ioxide.httpclient", [ - 'dotnet run --project Playground/Http3/Nghttp3 # h3 origin on udp :8443', + 'dotnet run --project Playground/Http3/Nghttp3Request # h3 origin on udp :8443', 'PLAYGROUND_UPSTREAM_PORT=8443 dotnet run --project Playground/Proxy/H1ToH3', 'curl -k https://127.0.0.1:8443/']), "H2ToH1": ("h2 → h1", "HTTP/2 in · HTTP/1.1 out", - "ioxide + ioxide.nghttp2 + ioxide.httpclient", + "ioxide + ioxide.http2 + ioxide.httpclient", [ 'PLAYGROUND_PORT=8444 dotnet run --project Playground/Tls/Ktls # a TLS origin', 'curl -k --http2 https://127.0.0.1:8443/']), "H2ToH2": ("h2 → h2", "HTTP/2 in · HTTP/2 out", - "ioxide + ioxide.nghttp2 + ioxide.httpclient", + "ioxide + ioxide.http2 + ioxide.httpclient", [ 'PLAYGROUND_PORT=8444 dotnet run --project Playground/Http2/Tls # an h2-over-TLS origin', 'curl -k --http2 https://127.0.0.1:8443/']), "H2ToH3": ("h2 → h3", "HTTP/2 in · HTTP/3 out", - "ioxide + ioxide.nghttp2 + ioxide.httpclient", + "ioxide + ioxide.http2 + ioxide.httpclient", [ - 'dotnet run --project Playground/Http3/Nghttp3 # h3 origin on udp :8443', + 'dotnet run --project Playground/Http3/Nghttp3Request # h3 origin on udp :8443', 'PLAYGROUND_UPSTREAM_PORT=8443 dotnet run --project Playground/Proxy/H2ToH3', 'curl -k --http2 https://127.0.0.1:8443/']), "H3ToH1": ("h3 → h1", "HTTP/3 in · HTTP/1.1 out", @@ -60,7 +60,7 @@ "H3ToH3": ("h3 → h3", "HTTP/3 in · HTTP/3 out", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3 + ioxide.httpclient", [ - 'PLAYGROUND_QUIC_PORT=8444 dotnet run --project Playground/Http3/Nghttp3', + 'PLAYGROUND_QUIC_PORT=8444 dotnet run --project Playground/Http3/Nghttp3Request', 'curl --http3-only -k https://127.0.0.1:8443/']), } @@ -79,7 +79,7 @@ "concurrency - a hundred h2 streams need a hundred h1 connections, because h1 has no " "multiplexing to borrow.", "H2ToH2": "The narrowest proxy in this set: two sockets per reactor no matter how many requests " - "are in flight. Two independent nghttp2 sessions are involved and they share nothing - " + "are in flight. Two independent HTTP/2 sessions are involved and they share nothing - " "HPACK state is per-connection, so a proxy always re-encodes. “Just splice the frames” " "is not a shortcut that exists.", "H2ToH3": "TCP in, QUIC out - the client half of a migration where the origin moved to HTTP/3 and " diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj index d73d3cd..4802d6e 100644 --- a/src/clients/ioxide.file/ioxide.file.csproj +++ b/src/clients/ioxide.file/ioxide.file.csproj @@ -8,7 +8,7 @@ioxide.file ioxide.file -0.4.169 +0.4.176 MDA2AV File serving for the ioxide io_uring runtime: immutable asset snapshots with baked responses, pooled positional ring reads, atomic reloads. MIT diff --git a/src/clients/ioxide.httpclient/Http2/Http2ClientConnection.cs b/src/clients/ioxide.httpclient/Http2/Http2ClientConnection.cs index 76ca6c4..229b8b3 100644 --- a/src/clients/ioxide.httpclient/Http2/Http2ClientConnection.cs +++ b/src/clients/ioxide.httpclient/Http2/Http2ClientConnection.cs @@ -1,50 +1,71 @@ using System.Buffers; -using System.Runtime.InteropServices; +using System.Buffers.Binary; using System.Threading.Tasks.Sources; -using ioxide.httpclient; -using ioxide.nghttp2; +using ioxide.http2; namespace ioxide.httpclient; ///-/// One HTTP/2 connection over a ring socket: many requests in flight at once, multiplexed by -/// nghttp2 onto a single TCP byte stream. +/// One HTTP/2 connection over a ring socket: many requests in flight at once, multiplexed onto a +/// single TCP byte stream. /// -/// nghttp2 is sans-I/O, so the split is clean - it owns framing, HPACK and flow control, and this -/// class owns the socket, the loop and the waiters. A pump task reads bytes, feeds them to -///
///ih2_read , and drains whatever that produced back to the socket. +/// Framing, HPACK and flow control are ioxide.http2's - the same managed code the server side runs, +/// pointed the other way round. What is here is the client's half of the protocol: the preface, odd +/// stream ids, requests instead of responses, and the retry rules that decide whether a failed +/// exchange may be sent again. /// -/// This is h2c - cleartext HTTP/2 with prior knowledge (RFC 9113 §3.3). There is no TLS and -/// therefore no ALPN, so the peer must already be expecting HTTP/2 on this port; there is no -/// Upgrade dance and no h2-over-TLS here. +/// This speaks h2c with prior knowledge (RFC 9113 section 3.3) on cleartext and h2 by +/// ALPN over TLS. There is no Upgrade dance in either direction: on a plaintext port the origin +/// must already be expecting HTTP/2. ///-/// Reactor thread only. Response completions are recorded by the native callbacks and resumed -/// AFTER public sealed class Http2ClientConnection : IDisposable { - // nghttp2's largest frame is 16 MiB, but it only emits SETTINGS_MAX_FRAME_SIZE-sized DATA - // frames (16 KiB by default) plus a 9-byte header. 64 KiB leaves ample room for a drain pass. - private const int EgressBufferSize = 64 * 1024; private const int IngressBufferSize = 64 * 1024; + ///ih2_read returns, so a caller that immediately submits another request can never -/// re-enter nghttp2 while it is still on the stack. +/// Reactor thread only. Completions are recorded while frames are being parsed and resumed after +/// the parse unwinds, so a caller that immediately submits another request - or retries through the +/// pool, which may dispose this connection - never re-enters the parser from inside itself. ///What we advertise per stream. Large, because the origin is the one streaming to us.
+ private const int SelfInitialWindow = 1 << 20; + private readonly IClientTransport _transport; private readonly byte[] _authority; - private readonly byte[] _egress = new byte[EgressBufferSize]; + private readonly int _maxResponseBytes; + + private readonly Dictionary_pending = new(); + private readonly HpackDecoder _decoder = new(); + private byte[] _headerScratch = []; + + // Client-initiated streams are odd and strictly increasing (RFC 9113 section 5.1.1). + private int _nextStreamId = 1; + + // Ingress accumulator. A frame boundary has nothing to do with a recv boundary, so a partial + // frame has to survive to the next read. + private byte[] _inbound = []; + private int _inboundUsed; + + // Egress staging, double-buffered: a drain swaps the buffers and sends the full one, so bytes + // staged while that send is awaited land in the empty one and keep their order. + private byte[] _egress = new byte[16 * 1024]; + private byte[] _sending = new byte[16 * 1024]; + private int _egressUsed; + + // Peer flow-control state. The connection window and per-stream windows both have to allow a + // DATA frame before it may go out. + private int _peerConnectionWindow = 65535; + private int _peerInitialStreamWindow = 65535; + private int _peerMaxFrameSize = 16384; - private nint _nghttp2Handle; - private GCHandle _self; private bool _failed; private bool _disposed; - private readonly Dictionary _pending = new(); + // Set when the peer stops accepting new streams (GOAWAY, or a max-requests limit). What is in + // flight can still finish; the pool must stop handing this connection new work. + private bool _retiring; - // Completions and stream failures recorded during the current ih2_read/ih2_write call, - // resumed once it unwinds. Failures ride the same list because a resumed waiter can retry - // through the pool - and the pool may dispose this connection, which must never happen while - // nghttp2 is still on the stack. + // Completions and stream failures recorded during the current parse, resumed once it unwinds. private readonly List<(PendingRequest Pending, HttpClientResponse? Response, Exception? Error)> _completedThisPass = []; // Set while the pump owns the drain, so a request submitted by a resumed caller rides the @@ -52,18 +73,12 @@ public sealed class Http2ClientConnection : IDisposable private bool _inPumpPass; // One drain at a time. Both the pump and SendAsync can start one, and a drain awaits socket - // sends - so without this two of them interleave writes out of the single _egress buffer and - // put a corrupt frame stream on the wire (the peer then closes on a protocol error). + // sends - so without this two of them interleave and put a corrupt frame stream on the wire. private bool _draining; private bool _drainAgain; private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); - // Ceiling on headers + body per response. nghttp2 streams a response in through callbacks with - // no length known up front, so without this a single origin can grow the arena until the - // process dies. - private readonly int _maxResponseBytes; - private Http2ClientConnection(IClientTransport transport, string authority, int maxResponseBytes) { _transport = transport; @@ -73,11 +88,6 @@ private Http2ClientConnection(IClientTransport transport, string authority, int public bool IsBroken => _failed || _disposed || _retiring; - // Set when the peer refuses a stream (GOAWAY / max-requests reached). The connection can still - // finish what is in flight, but the pool must stop handing it new requests and open a - // replacement. - private bool _retiring; - public int InFlight => _pending.Count; public static async Task ConnectAsync(IRingHost host, string ip, ushort port, @@ -97,36 +107,15 @@ public static async Task ConnectAsync(IRingHost host, str } var connection = new Http2ClientConnection(transport, authority, maxResponseBytes); - connection.Setup(); + connection.WritePrefaceAndSettings(); _ = connection.PumpLoopAsync(); await connection._ready.Task; // preface + SETTINGS on the wire before the first request return connection; } - private unsafe void Setup() - { - _self = GCHandle.Alloc(this); - var callbacks = new Nghttp2.Callbacks - { - OnBeginHeaders = &CallbackBeginHeaders, - OnHeader = &CallbackHeader, - OnEndHeaders = &CallbackEndHeaders, - OnData = &CallbackData, - OnEndStream = &CallbackEndStream, - OnStreamError = &CallbackStreamError, - }; - - _nghttp2Handle = Nghttp2.ih2_client_new(callbacks, (void*)GCHandle.ToIntPtr(_self)); - if (_nghttp2Handle == 0) - { - _failed = true; - _ready.TrySetException(new Http2ClientException("nghttp2 client init failed")); - } - } - // --- requests ------------------------------------------------------------------------------- - public unsafe ValueTask SendAsync(HttpClientRequest request) + public ValueTask SendAsync(HttpClientRequest request) { if (IsBroken) { @@ -135,24 +124,20 @@ public unsafe ValueTask SendAsync(HttpClientRequest request) throw new Http2StreamRefusedException("connection is retiring or closed; request was not submitted"); } - byte[] headers = PackRequestHeaders(request, out int headersLength); - int streamId; - fixed (byte* headersPointer = headers) - fixed (byte* bodyPointer = request.Body.Span) - { - streamId = Nghttp2.ih2_submit_request(_nghttp2Handle, headersPointer, (nuint)headersLength, - bodyPointer, (nuint)request.Body.Length); - } - ArrayPool .Shared.Return(headers); + int streamId = _nextStreamId; + _nextStreamId += 2; - if (streamId < 0) + var pending = new PendingRequest { - throw new Http2ClientException($"submit_request failed: {Nghttp2.StrError(streamId)}"); - } - - var pending = new PendingRequest(); + StreamId = streamId, + SendWindow = _peerInitialStreamWindow, + BodyRemaining = request.Body, + }; _pending[streamId] = pending; + WriteRequestHeaders(request, streamId, endStream: request.Body.Length == 0); + PumpBody(pending); + if (!_inPumpPass) { _ = DrainDetachedAsync(); // issued from outside a pass: put it on the wire now @@ -177,60 +162,112 @@ private async Task DrainDetachedAsync() } } - // [u16 namelen][name][u16 valuelen][value]... - pseudo-headers first, in the order h2 requires. - private byte[] PackRequestHeaders(HttpClientRequest request, out int written) + /// + /// The request's field section, HPACK-encoded into one HEADERS frame. + ///
+ ///+ /// The encoder never touches the dynamic table, so it holds no per-connection compression state + /// and cannot desynchronise from the origin's decoder. On a client that costs more than it does + /// on a server - a client repeats its own header set on every request, which is exactly what + /// indexing is for - but the pseudo-headers that dominate a small request are static-table hits + /// either way, and a desynchronised HPACK table poisons every later stream on the connection. + /// + private void WriteRequestHeaders(HttpClientRequest request, int streamId, bool endStream) { - int capacity = 64 + request.Method.Length + request.Path.Length + _authority.Length + 32; + int capacity = HpackEncoder.MaxEncodedLength(":authority".Length, _authority.Length) + + HpackEncoder.MaxEncodedLength(":method".Length, request.Method.Length) + + HpackEncoder.MaxEncodedLength(":path".Length, request.Path.Length) + + HpackEncoder.MaxEncodedLength(":scheme".Length, 5); foreach (KeyValuePair, ReadOnlyMemory > field in request.Headers.AsSpan()) { - capacity += 4 + field.Key.Length + field.Value.Length; + capacity += HpackEncoder.MaxEncodedLength(field.Key.Length, field.Value.Length); } - byte[] buffer = ArrayPool .Shared.Rent(capacity); - int cursor = 0; + byte[] block = ArrayPool .Shared.Rent(capacity); + int used = 0; + + // Pseudo-headers first and in this order, per RFC 9113 section 8.3.1. + used += HpackEncoder.Encode(block.AsSpan(used), ":method"u8, request.Method.Span); + // :scheme must match the transport. An origin reached over TLS sees ":scheme http" as a + // mismatch, and the strict ones reject the stream for it. + used += HpackEncoder.Encode(block.AsSpan(used), ":scheme"u8, _transport.IsSecure ? "https"u8 : "http"u8); + used += HpackEncoder.Encode(block.AsSpan(used), ":authority"u8, _authority); + used += HpackEncoder.Encode(block.AsSpan(used), ":path"u8, request.Path.Span); - static void Write(byte[] destination, ref int offset, ReadOnlySpan name, ReadOnlySpan value) + foreach (KeyValuePair , ReadOnlyMemory > field in request.Headers.AsSpan()) { - // Lengths are framed as u16. Truncating one while still copying the bytes would - // desync the whole packed block, so refuse instead. - if (name.Length > ushort.MaxValue || value.Length > ushort.MaxValue) - { - throw new Http2ClientException("header name or value exceeds 65535 bytes"); - } + used += HpackEncoder.Encode(block.AsSpan(used), field.Key.Span, field.Value.Span); + } + + WriteHeaderBlock(streamId, block.AsSpan(0, used), endStream); + ArrayPool .Shared.Return(block); + } - destination[offset++] = (byte)name.Length; - destination[offset++] = (byte)(name.Length >> 8); - name.CopyTo(destination.AsSpan(offset)); - offset += name.Length; + // One HEADERS, then CONTINUATION for whatever did not fit the peer's frame size. The block + // cannot be split anywhere else: HPACK is a stream, so a decoder needs the pieces contiguous + // and uninterrupted by any other stream's frames. + private void WriteHeaderBlock(int streamId, ReadOnlySpan block, bool endStream) + { + int first = Math.Min(block.Length, _peerMaxFrameSize); + bool complete = first == block.Length; - destination[offset++] = (byte)value.Length; - destination[offset++] = (byte)(value.Length >> 8); - value.CopyTo(destination.AsSpan(offset)); - offset += value.Length; - } + FrameFlags flags = FrameFlags.None; + if (complete) flags |= FrameFlags.EndHeaders; + if (endStream) flags |= FrameFlags.EndStream; - Write(buffer, ref cursor, ":method"u8, request.Method.Span); - // Must match the transport. An origin reached over TLS sees ":scheme http" as a - // mismatch, and the strict ones reject the stream for it - harmless while this client was - // h2c-only, reachable the moment h2-over-TLS works. - Write(buffer, ref cursor, ":scheme"u8, _transport.IsSecure ? "https"u8 : "http"u8); - Write(buffer, ref cursor, ":authority"u8, _authority); - Write(buffer, ref cursor, ":path"u8, request.Path.Span); + Span header = stackalloc byte[FrameHeader.Size]; + new FrameHeader(first, FrameType.Headers, flags, streamId).Write(header); + Stage(header); + Stage(block[..first]); - foreach (KeyValuePair , ReadOnlyMemory > field in request.Headers.AsSpan()) + int offset = first; + while (offset < block.Length) { - Write(buffer, ref cursor, field.Key.Span, field.Value.Span); + int chunk = Math.Min(block.Length - offset, _peerMaxFrameSize); + bool last = offset + chunk == block.Length; + new FrameHeader(chunk, FrameType.Continuation, + last ? FrameFlags.EndHeaders : FrameFlags.None, streamId).Write(header); + Stage(header); + Stage(block.Slice(offset, chunk)); + offset += chunk; } + } + + /// + /// Push as much of the request body as the peer's windows allow. Called at submit and again on + /// every WINDOW_UPDATE, because credit is what decides when the rest may go - a body larger + /// than the initial 65535-byte window leaves in pieces as the origin reads it. + ///
+ private void PumpBody(PendingRequest pending) + { + while (pending.BodyRemaining.Length > 0) + { + int credit = Math.Min(pending.SendWindow, _peerConnectionWindow); + if (credit <= 0) + { + return; // parked; a WINDOW_UPDATE brings us back + } + + int chunk = Math.Min(Math.Min(credit, _peerMaxFrameSize), pending.BodyRemaining.Length); + bool last = chunk == pending.BodyRemaining.Length; - written = cursor; - return buffer; + Spanheader = stackalloc byte[FrameHeader.Size]; + new FrameHeader(chunk, FrameType.Data, last ? FrameFlags.EndStream : FrameFlags.None, + pending.StreamId).Write(header); + Stage(header); + Stage(pending.BodyRemaining.Span[..chunk]); + + pending.BodyRemaining = pending.BodyRemaining[chunk..]; + pending.SendWindow -= chunk; + _peerConnectionWindow -= chunk; + } } // --- the pump ------------------------------------------------------------------------------- private async Task PumpLoopAsync() { - nint receive = Marshal.AllocHGlobal(IngressBufferSize); + nint receive = System.Runtime.InteropServices.Marshal.AllocHGlobal(IngressBufferSize); try { await PumpEgressAsync(); // connection preface + SETTINGS @@ -244,16 +281,14 @@ private async Task PumpLoopAsync() throw new Http2ClientException(n == 0 ? "peer closed the connection" : $"recv failed: errno {-n}"); } + Accumulate(receive, n); + _inPumpPass = true; try { - nint consumed = Feed(receive, n); - if (consumed < 0) - { - throw new Http2ClientException($"read failed: {Nghttp2.StrError((int)consumed)}"); - } + ParseAvailable(); - // nghttp2 has unwound: safe to resume the waiters. Their follow-up requests + // The parser has unwound: safe to resume the waiters. Their follow-up requests // submit while _inPumpPass is set, so they all ride the drain below. CompleteFinishedRequests(); } @@ -264,10 +299,9 @@ private async Task PumpLoopAsync() await PumpEgressAsync(); // ACKs, WINDOW_UPDATEs and any newly submitted requests - if (_nghttp2Handle != 0 && Nghttp2.ih2_is_dead(_nghttp2Handle) != 0) + if (_retiring && _pending.Count == 0) { - _retiring = true; // GOAWAY drained; the pool opens a replacement - break; + break; // GOAWAY drained and nothing left in flight; the pool opens a replacement } } } @@ -277,7 +311,7 @@ private async Task PumpLoopAsync() } finally { - Marshal.FreeHGlobal(receive); + System.Runtime.InteropServices.Marshal.FreeHGlobal(receive); // A clean retirement (GOAWAY drained, nothing left to read) leaves only streams the // peer never processed: ones past last_stream_id or never sent. RFC 9113 8.7 makes @@ -290,11 +324,33 @@ private async Task PumpLoopAsync() } } + private unsafe void Accumulate(nint receive, int length) + { + if (_inbound.Length - _inboundUsed < length) + { + long size = Math.Max(IngressBufferSize, (long)_inbound.Length * 2); + while (size < (long)_inboundUsed + length) + { + size *= 2; + } + byte[] grown = ArrayPool .Shared.Rent((int)Math.Min(size, Array.MaxLength)); + _inbound.AsSpan(0, _inboundUsed).CopyTo(grown); + if (_inbound.Length > 0) + { + ArrayPool .Shared.Return(_inbound); + } + _inbound = grown; + } + + new ReadOnlySpan ((void*)receive, length).CopyTo(_inbound.AsSpan(_inboundUsed)); + _inboundUsed += length; + } + private async ValueTask PumpEgressAsync() { if (_draining) { - _drainAgain = true; // a drain is mid-flight; it will pick up what we just queued + _drainAgain = true; // a drain is mid-flight; it will pick up what we just staged return; } @@ -312,36 +368,20 @@ private async ValueTask PumpEgressAsync() finally { _draining = false; - - // Stream failures can be deposited DURING a drain: after a GOAWAY, nghttp2 refuses - // queued streams inside mem_send. Resolve them here, outside the native call - on the - // error path too, or they would be lost (their pendings left _pending already). - CompleteFinishedRequests(); } } private async ValueTask DrainLoopAsync() { - while (true) + while (_egressUsed > 0 && !_disposed) { - if (_nghttp2Handle == 0 || _disposed) - { - return; - } - - nint produced = DrainEgress(); + // Swap rather than copy: whatever is staged during the await below lands in the buffer + // this one just vacated, so ordering holds without a second pass over the bytes. + (_egress, _sending) = (_sending, _egress); + int length = _egressUsed; + _egressUsed = 0; - if (produced < 0) - { - _failed = true; - throw new Http2ClientException($"write failed: {Nghttp2.StrError((int)produced)}"); - } - if (produced == 0) - { - return; - } - - await SendAllAsync(_egress, (int)produced); + await SendAllAsync(_sending, length); } } @@ -363,128 +403,300 @@ private async ValueTask SendAllAsync(byte[] buffer, int length) } } - // Pointer-touching helpers, kept out of the async methods above (no await may appear in an - // unsafe context). - private unsafe nint Feed(nint receive, int length) - => Nghttp2.ih2_read(_nghttp2Handle, (byte*)receive, (nuint)length); + private static unsafe nint PointerOf(MemoryHandle pinned) => (nint)pinned.Pointer; - private unsafe nint DrainEgress() + private void Stage(ReadOnlySpan bytes) { - fixed (byte* egressPointer = _egress) + if (_disposed) + { + return; + } + + if (_egress.Length - _egressUsed < bytes.Length) { - return Nghttp2.ih2_write(_nghttp2Handle, egressPointer, (nuint)_egress.Length); + long size = Math.Max(16 * 1024, (long)_egress.Length * 2); + while (size < (long)_egressUsed + bytes.Length) + { + size *= 2; + } + byte[] grown = new byte[(int)Math.Min(size, Array.MaxLength)]; + _egress.AsSpan(0, _egressUsed).CopyTo(grown); + _egress = grown; } + + bytes.CopyTo(_egress.AsSpan(_egressUsed)); + _egressUsed += bytes.Length; } - private static unsafe nint PointerOf(MemoryHandle pinned) => (nint)pinned.Pointer; + // --- frames out ----------------------------------------------------------------------------- - // Runs OUTSIDE ih2_read/ih2_write, so a resumed caller may safely submit again - or retry - // through the pool, which may dispose this very connection. - private void CompleteFinishedRequests() + private void WritePrefaceAndSettings() { - if (_completedThisPass.Count == 0) + Stage(FrameHeader.ClientPreface); + + // ENABLE_PUSH off: this client has no cache to push into, and refusing it here is simpler + // than answering PUSH_PROMISE frames. INITIAL_WINDOW_SIZE is ours to set - the origin is + // the side sending bodies, so a small window here would throttle every download. + Span frame = stackalloc byte[FrameHeader.Size + 12]; + new FrameHeader(12, FrameType.Settings, FrameFlags.None, 0).Write(frame); + BinaryPrimitives.WriteUInt16BigEndian(frame[9..], Http2Setting.EnablePush); + BinaryPrimitives.WriteUInt32BigEndian(frame[11..], 0); + BinaryPrimitives.WriteUInt16BigEndian(frame[15..], Http2Setting.InitialWindowSize); + BinaryPrimitives.WriteUInt32BigEndian(frame[17..], SelfInitialWindow); + Stage(frame); + + // Lift the connection window to match what we just advertised per stream. The connection + // window is NOT covered by SETTINGS_INITIAL_WINDOW_SIZE and stays at 65535 otherwise, which + // would cap every download on the connection no matter what the streams allow. + WriteWindowUpdate(0, SelfInitialWindow - 65535); + } + + private void WriteSettingsAck() + { + Span frame = stackalloc byte[FrameHeader.Size]; + new FrameHeader(0, FrameType.Settings, FrameFlags.Ack, 0).Write(frame); + Stage(frame); + } + + private void WriteWindowUpdate(int streamId, int increment) + { + if (increment <= 0) { return; } - // Snapshot-and-clear first: a resumed caller can land new completions here, and the list - // must not be mutated while it is being walked. - var finished = _completedThisPass.ToArray(); - _completedThisPass.Clear(); + Span frame = stackalloc byte[FrameHeader.Size + 4]; + new FrameHeader(4, FrameType.WindowUpdate, FrameFlags.None, streamId).Write(frame); + BinaryPrimitives.WriteUInt32BigEndian(frame[FrameHeader.Size..], (uint)increment); + Stage(frame); + } - foreach ((PendingRequest pending, HttpClientResponse? response, Exception? error) in finished) + private void WritePingAck(ReadOnlySpan opaque) + { + Span frame = stackalloc byte[FrameHeader.Size + 8]; + new FrameHeader(8, FrameType.Ping, FrameFlags.Ack, 0).Write(frame); + opaque.CopyTo(frame[FrameHeader.Size..]); + Stage(frame); + } + + private void GoAway(uint error) + { + Span frame = stackalloc byte[FrameHeader.Size + 8]; + new FrameHeader(8, FrameType.GoAway, FrameFlags.None, 0).Write(frame); + BinaryPrimitives.WriteUInt32BigEndian(frame[FrameHeader.Size..], 0); + BinaryPrimitives.WriteUInt32BigEndian(frame[(FrameHeader.Size + 4)..], error); + Stage(frame); + _failed = true; + } + + // --- frames in ------------------------------------------------------------------------------ + + private void ParseAvailable() + { + int position = 0; + + while (!_failed) { - if (error is not null) + if (!FrameHeader.TryRead(_inbound.AsSpan(position, _inboundUsed - position), out FrameHeader header)) { - pending.Fail(error); - continue; + break; // not even a header yet } - // The arena refused bytes for exceeding MaxResponseBytes. The callbacks could only - // record that; failing the caller happens here, where a throw no longer crosses - // nghttp2's frames. - if (response!.Overflowed) + int total = FrameHeader.Size + header.Length; + if (_inboundUsed - position < total) { - response.Dispose(); - pending.Fail(new Http2ClientException( - $"response exceeds MaxResponseBytes ({_maxResponseBytes})")); - continue; + break; // header is in, payload is not } - pending.Complete(response); + Handle(header, _inbound.AsSpan(position + FrameHeader.Size, header.Length)); + position += total; } + + Compact(position); } - private void FailAll(Exception error) + private void Compact(int consumed) { - // Broken before anything resumes: an inline-resumed caller that immediately retries must - // see IsBroken and go to the pool, not submit onto this connection. - _failed = true; - _ready.TrySetException(error); - - // Deposited but not yet flushed: those pendings already left _pending, so they would be - // missed below. A recorded response is a real completed exchange - deliver it. - CompleteFinishedRequests(); - - if (_pending.Count == 0) + if (consumed == 0) { return; } - // Snapshot-and-clear: resumes run inline and may re-enter (a retry submitting elsewhere - // still touches pool state), and _pending must not change under the enumeration. - PendingRequest[] pendings = [.. _pending.Values]; - _pending.Clear(); - foreach (PendingRequest pending in pendings) + int remaining = _inboundUsed - consumed; + if (remaining > 0) { - pending.Fail(error); + _inbound.AsSpan(consumed, remaining).CopyTo(_inbound); + } + _inboundUsed = remaining; + } + + private void Handle(in FrameHeader header, ReadOnlySpan payload) + { + switch (header.Type) + { + case FrameType.Headers: HandleHeaders(header, payload); break; + case FrameType.Continuation: HandleContinuation(header, payload); break; + case FrameType.Data: HandleData(header, payload); break; + case FrameType.Settings: HandleSettings(header, payload); break; + case FrameType.WindowUpdate: HandleWindowUpdate(header, payload); break; + case FrameType.Ping: HandlePing(header, payload); break; + case FrameType.RstStream: HandleRstStream(header, payload); break; + case FrameType.GoAway: HandleGoAway(payload); break; + + // We advertised ENABLE_PUSH=0, so a PUSH_PROMISE is the origin breaking the setting we + // gave it. PRIORITY carries no obligation and is ignored. + case FrameType.PushPromise: + GoAway(Http2Error.ProtocolError); + break; + + case FrameType.Priority: + default: + break; } } - // --- native callbacks: deposit only --------------------------------------------------------- + private void HandleHeaders(in FrameHeader header, ReadOnlySpan payload) + { + ReadOnlySpan block = payload; - private static unsafe Http2ClientConnection FromUser(void* user) - => (Http2ClientConnection)GCHandle.FromIntPtr((nint)user).Target!; + // Padding and priority sit in front of the header block and are not part of it. + if ((header.Flags & FrameFlags.Padded) != 0) + { + if (block.Length < 1) + { + GoAway(Http2Error.FrameSizeError); + return; + } + int padding = block[0]; + block = block[1..]; + if (padding > block.Length) + { + GoAway(Http2Error.ProtocolError); + return; + } + block = block[..^padding]; + } + + if ((header.Flags & FrameFlags.Priority) != 0) + { + if (block.Length < 5) + { + GoAway(Http2Error.FrameSizeError); + return; + } + block = block[5..]; + } + + // A response on a stream we no longer have (cancelled, or already complete) still has to be + // decoded: HPACK is stateful across the whole connection, so skipping the block would + // desynchronise the table for every stream after it. + if (_pending.TryGetValue(header.StreamId, out PendingRequest? pending)) + { + pending.AppendHeaderBlock(block); + } + else + { + _orphanBlock.AppendHeaderBlock(block); + } - [UnmanagedCallersOnly] - private static unsafe void CallbackBeginHeaders(void* user, int streamId) + // Recorded rather than acted on: END_STREAM is carried by the HEADERS that OPENS the block, + // but the block may run on into CONTINUATION frames. Completing here would hand back a + // response whose header fields have not been decoded yet. + if ((header.Flags & FrameFlags.EndStream) != 0 && pending is not null) + { + pending.HeadersEndedStream = true; + } + + if ((header.Flags & FrameFlags.EndHeaders) != 0) + { + if (!DecodeHeaderBlock(pending)) + { + return; + } + + if (pending is { HeadersEndedStream: true }) + { + Complete(pending); + } + } + } + + private void HandleContinuation(in FrameHeader header, ReadOnlySpan payload) { - Http2ClientConnection connection = FromUser(user); + _pending.TryGetValue(header.StreamId, out PendingRequest? pending); + (pending ?? _orphanBlock).AppendHeaderBlock(payload); - // One response per request, created on the FIRST field section and kept. nghttp2 reports - // trailers as HCAT_HEADERS, the same category as the real response after a 1xx, so this - // callback fires again at the end of a trailered stream. Replacing the response there - // would throw away the assembled one while BodyStart/BodyLength still describe its arena, - // and CallbackEndStream would then slice those offsets out of the fresh, near-empty one. - // Interim (1xx) heads are cleared by ResetForInterim instead, which reuses this object. - if (connection._pending.TryGetValue(streamId, out PendingRequest? pending) && - pending.Response is null) + if ((header.Flags & FrameFlags.EndHeaders) != 0) { - var response = new HttpClientResponse(); - response.SetMaxArenaBytes(connection._maxResponseBytes); - pending.Response = response; + if (!DecodeHeaderBlock(pending)) + { + return; + } + + // CONTINUATION carries no END_STREAM of its own; it is on the HEADERS that opened the + // block, which we recorded there. + if (pending is { HeadersEndedStream: true }) + { + Complete(pending); + } } } - [UnmanagedCallersOnly] - private static unsafe void CallbackHeader(void* user, int streamId, byte* name, nuint nameLen, - byte* value, nuint valueLen) + /// Header blocks for streams we no longer track, decoded only to keep HPACK in step.
+ private readonly PendingRequest _orphanBlock = new(); + + private bool DecodeHeaderBlock(PendingRequest? pending) { - Http2ClientConnection connection = FromUser(user); - if (!connection._pending.TryGetValue(streamId, out PendingRequest? pending) || - pending.Response is not { } response) + PendingRequest target = pending ?? _orphanBlock; + try { - return; + ReadOnlySpanblock = target.HeaderBlock; + + // Huffman can expand a literal well past its encoded length, so the scratch has to be + // able to hold the worst case for the whole block. + int needed = Huffman.MaxDecodedLength(block.Length) + block.Length; + if (_headerScratch.Length < needed) + { + _headerScratch = new byte[needed]; + } + + if (pending is null) + { + _decoder.Decode(block, _headerScratch, static (_, _) => { }); + } + else + { + HttpClientResponse response = pending.Response ??= NewResponse(); + _decoder.Decode(block, _headerScratch, (name, value) => AddHeader(response, name, value)); + pending.Assembly.EndFieldSection(response); + } + + target.ClearHeaderBlock(); + return true; } + catch (HpackDecoder.HpackException) + { + // The dynamic tables have diverged; every later block on this connection would decode + // to nonsense, so the connection - not the stream - is what has to end. + GoAway(Http2Error.CompressionError); + return false; + } + } - var nameSpan = new ReadOnlySpan (name, (int)nameLen); - var valueSpan = new ReadOnlySpan (value, (int)valueLen); + private HttpClientResponse NewResponse() + { + var response = new HttpClientResponse(); + response.SetMaxArenaBytes(_maxResponseBytes); + return response; + } + private static void AddHeader(HttpClientResponse response, ReadOnlySpan name, ReadOnlySpan value) + { // ":status" is the status line's stand-in; everything else is an ordinary field. - if (nameSpan.SequenceEqual(":status"u8)) + if (name.SequenceEqual(":status"u8)) { int status = 0; - foreach (byte digit in valueSpan) + foreach (byte digit in value) { status = (status * 10) + (digit - (byte)'0'); } @@ -492,43 +704,62 @@ private static unsafe void CallbackHeader(void* user, int streamId, byte* name, return; } - (int Offset, int Length) nameRange = response.Append(nameSpan); - (int Offset, int Length) valueRange = response.Append(valueSpan); + (int Offset, int Length) nameRange = response.Append(name); + (int Offset, int Length) valueRange = response.Append(value); response.AddHeaderRange(nameRange, valueRange); } - [UnmanagedCallersOnly] - private static unsafe void CallbackEndHeaders(void* user, int streamId) + private void HandleData(in FrameHeader header, ReadOnlySpan payload) { - Http2ClientConnection connection = FromUser(user); - if (!connection._pending.TryGetValue(streamId, out PendingRequest? pending) || - pending.Response is not { } response) + ReadOnlySpan body = payload; + + if ((header.Flags & FrameFlags.Padded) != 0) { - return; + if (body.Length < 1) + { + GoAway(Http2Error.FrameSizeError); + return; + } + int padding = body[0]; + body = body[1..]; + if (padding > body.Length) + { + GoAway(Http2Error.ProtocolError); + return; + } + body = body[..^padding]; } - pending.Assembly.EndFieldSection(response); - } - - [UnmanagedCallersOnly] - private static unsafe void CallbackData(void* user, int streamId, byte* data, nuint dataLen) - { - Http2ClientConnection connection = FromUser(user); - if (connection._pending.TryGetValue(streamId, out PendingRequest? pending) && + if (_pending.TryGetValue(header.StreamId, out PendingRequest? pending) && pending.Response is { } response) { // Count what the arena ACCEPTED, not what arrived: past MaxResponseBytes the append is // refused and returns a zero-length range, and a body length that outran the arena - // would make Freeze slice out of bounds - inside this callback, which is fatal. - pending.Assembly.BodyLength += response.Append(new ReadOnlySpan (data, (int)dataLen)).Length; + // would make Freeze slice out of bounds. + pending.Assembly.BodyLength += response.Append(body).Length; + } + + // The whole payload counts against the window, padding included, so the peer's accounting + // and ours agree. Replenished immediately: this client buffers the response anyway, so + // holding the window back would only stall the origin. + if (payload.Length > 0) + { + WriteWindowUpdate(0, payload.Length); + if (header.StreamId != 0 && pending is not null) + { + WriteWindowUpdate(header.StreamId, payload.Length); + } + } + + if ((header.Flags & FrameFlags.EndStream) != 0 && pending is not null) + { + Complete(pending); } } - [UnmanagedCallersOnly] - private static unsafe void CallbackEndStream(void* user, int streamId) + private void Complete(PendingRequest pending) { - Http2ClientConnection connection = FromUser(user); - if (!connection._pending.Remove(streamId, out PendingRequest? pending)) + if (!_pending.Remove(pending.StreamId)) { return; } @@ -537,75 +768,306 @@ private static unsafe void CallbackEndStream(void* user, int streamId) response.SetBodyRange(pending.Assembly.BodyRange); response.Freeze(); - // Record only - resumed by CompleteFinishedRequests once nghttp2 unwinds. - connection._completedThisPass.Add((pending, response, null)); + // Record only - resumed by CompleteFinishedRequests once the parse unwinds. + _completedThisPass.Add((pending, response, null)); + } + + private void HandleSettings(in FrameHeader header, ReadOnlySpan payload) + { + if ((header.Flags & FrameFlags.Ack) != 0) + { + return; // our own SETTINGS acknowledged + } + + if (payload.Length % 6 != 0) + { + GoAway(Http2Error.FrameSizeError); + return; + } + + for (int offset = 0; offset + 6 <= payload.Length; offset += 6) + { + ushort id = BinaryPrimitives.ReadUInt16BigEndian(payload[offset..]); + uint value = BinaryPrimitives.ReadUInt32BigEndian(payload[(offset + 2)..]); + + switch (id) + { + case Http2Setting.InitialWindowSize: + if (value > int.MaxValue) + { + GoAway(Http2Error.FlowControlError); + return; + } + // Applies retroactively to every open stream, per RFC 9113 section 6.9.2. + int delta = (int)value - _peerInitialStreamWindow; + _peerInitialStreamWindow = (int)value; + foreach (PendingRequest stream in _pending.Values) + { + stream.SendWindow += delta; + } + break; + + case Http2Setting.MaxFrameSize: + if (value is < 16384 or > 16777215) + { + GoAway(Http2Error.ProtocolError); + return; + } + _peerMaxFrameSize = (int)value; + break; + } + } + + WriteSettingsAck(); + + // A raised window may have unparked a body mid-send. + PumpParkedBodies(); } - [UnmanagedCallersOnly] - private static unsafe void CallbackStreamError(void* user, int streamId, uint errorCode) + private void HandleWindowUpdate(in FrameHeader header, ReadOnlySpan payload) { - Http2ClientConnection connection = FromUser(user); + if (payload.Length != 4) + { + GoAway(Http2Error.FrameSizeError); + return; + } + + int increment = (int)(BinaryPrimitives.ReadUInt32BigEndian(payload) & 0x7FFFFFFF); + if (increment == 0) + { + GoAway(Http2Error.ProtocolError); + return; + } + + if (header.StreamId == 0) + { + _peerConnectionWindow += increment; + PumpParkedBodies(); // connection credit unparks every stream, not one + } + else if (_pending.TryGetValue(header.StreamId, out PendingRequest? pending)) + { + pending.SendWindow += increment; + PumpBody(pending); + } + } + + private void PumpParkedBodies() + { + foreach (PendingRequest pending in _pending.Values) + { + if (pending.BodyRemaining.Length > 0) + { + PumpBody(pending); + } + } + } + + private void HandlePing(in FrameHeader header, ReadOnlySpan payload) + { + if (payload.Length != 8) + { + GoAway(Http2Error.FrameSizeError); + return; + } + if ((header.Flags & FrameFlags.Ack) != 0) + { + return; + } + + WritePingAck(payload); + } + + private void HandleRstStream(in FrameHeader header, ReadOnlySpan payload) + { + uint error = payload.Length >= 4 ? BinaryPrimitives.ReadUInt32BigEndian(payload) : Http2Error.NoError; // REFUSED_STREAM means the peer never processed the request - it is retiring the connection // (GOAWAY, or a max-requests limit like nginx's keepalive_requests). RFC 9113 8.7 makes // retrying it explicitly safe, so it is surfaced as retryable and the connection retires. - bool refused = errorCode == RefusedStream; + bool refused = error == Http2Error.RefusedStream; if (refused) { - connection._retiring = true; + _retiring = true; + } + + if (_pending.Remove(header.StreamId, out PendingRequest? pending)) + { + // Record only, exactly like a completion: failing here would resume the waiter mid-parse, + // and a resumed caller retries through the pool, which prunes retiring connections - + // disposing this one while its own parser is still on the stack. + _completedThisPass.Add((pending, null, refused + ? new Http2StreamRefusedException($"stream {header.StreamId} refused; connection is retiring") + : new Http2ClientException($"stream {header.StreamId} reset (error {error})"))); } + } - if (connection._pending.Remove(streamId, out PendingRequest? pending)) + private void HandleGoAway(ReadOnlySpan payload) + { + _retiring = true; + + // Everything above last_stream_id was never processed, so RFC 9113 8.7 makes resending it + // safe whatever the method. Below it the origin may have acted on the request, so those + // streams are left in flight to finish or fail on their own. + int lastStreamId = payload.Length >= 4 + ? (int)(BinaryPrimitives.ReadUInt32BigEndian(payload) & 0x7FFFFFFF) + : 0; + + int[] refused = [.. _pending.Keys.Where(id => id > lastStreamId)]; + foreach (int streamId in refused) { - // Record only, exactly like EndStream. Failing here would resume the waiter INSIDE - // ih2_read/ih2_write (this callback fires during both - GOAWAY closes live streams in - // mem_recv and refuses queued ones in mem_send), and a resumed caller retries through - // the pool, which prunes retiring connections - Dispose would then run ih2_free while - // nghttp2 is still on this very stack. - connection._completedThisPass.Add((pending, null, refused - ? new Http2StreamRefusedException($"stream {streamId} refused; connection is retiring") - : new Http2ClientException($"stream {streamId} reset (error {errorCode})"))); + if (_pending.Remove(streamId, out PendingRequest? pending)) + { + _completedThisPass.Add((pending, null, new Http2StreamRefusedException( + $"stream {streamId} is past the GOAWAY last-stream-id; request was not processed"))); + } } } - /// HTTP/2 REFUSED_STREAM (RFC 9113 section 7).
- private const uint RefusedStream = 7; + // --- completion ----------------------------------------------------------------------------- - public unsafe void Dispose() + // Runs OUTSIDE the parse, so a resumed caller may safely submit again - or retry through the + // pool, which may dispose this very connection. + private void CompleteFinishedRequests() { - if (_disposed) + if (_completedThisPass.Count == 0) { return; } - _disposed = true; + + // Snapshot-and-clear first: a resumed caller can land new completions here, and the list + // must not be mutated while it is being walked. + var finished = _completedThisPass.ToArray(); + _completedThisPass.Clear(); + + foreach ((PendingRequest pending, HttpClientResponse? response, Exception? error) in finished) + { + if (error is not null) + { + pending.Dispose(); + pending.Fail(error); + continue; + } + + // The arena refused bytes for exceeding MaxResponseBytes. Parsing could only record + // that; failing the caller happens here, where a throw no longer unwinds the parser. + if (response!.Overflowed) + { + response.Dispose(); + pending.Dispose(); + pending.Fail(new Http2ClientException( + $"response exceeds MaxResponseBytes ({_maxResponseBytes})")); + continue; + } + + pending.Dispose(); + pending.Complete(response); + } + } + + private void FailAll(Exception error) + { + // Broken before anything resumes: an inline-resumed caller that immediately retries must + // see IsBroken and go to the pool, not submit onto this connection. _failed = true; + _ready.TrySetException(error); + + // Recorded but not yet flushed: those pendings already left _pending, so they would be + // missed below. A recorded response is a real completed exchange - deliver it. + CompleteFinishedRequests(); + + if (_pending.Count == 0) + { + return; + } + + // Snapshot-and-clear: resumes run inline and may re-enter (a retry submitting elsewhere + // still touches pool state), and _pending must not change under the enumeration. + PendingRequest[] pendings = [.. _pending.Values]; + _pending.Clear(); + foreach (PendingRequest pending in pendings) + { + pending.Dispose(); + pending.Fail(error); + } + } - if (_nghttp2Handle != 0) + public void Dispose() + { + if (_disposed) { - Nghttp2.ih2_free(_nghttp2Handle); - _nghttp2Handle = 0; + return; } - if (_self.IsAllocated) + _disposed = true; + _failed = true; + + _orphanBlock.Dispose(); + if (_inbound.Length > 0) { - _self.Free(); + ArrayPool.Shared.Return(_inbound); + _inbound = []; } + _inboundUsed = 0; + _transport.Dispose(); } /// One in-flight request. IValueTaskSource with asynchronous continuations OFF, so the /// caller resumes inline on the reactor thread.
- private sealed class PendingRequest : IValueTaskSource+ private sealed class PendingRequest : IValueTaskSource , IDisposable { private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = false, }; + private byte[] _block = []; + private int _blockUsed; + + public int StreamId; public HttpClientResponse? Response; public ResponseAssembly Assembly; + /// What the peer will still accept on this stream. Starts at its advertised default.
+ public int SendWindow = 65535; + + ///Request body not yet on the wire, held back by flow control.
+ public ReadOnlyMemoryBodyRemaining; + + /// END_STREAM arrived on a HEADERS whose block is still being continued.
+ public bool HeadersEndedStream; + public ValueTaskTask => new(this, _core.Version); + public ReadOnlySpan HeaderBlock => _block.AsSpan(0, _blockUsed); + + // A header block can span HEADERS + CONTINUATION frames, and HPACK cannot be decoded + // piecewise - the whole block has to be in hand first. + public void AppendHeaderBlock(ReadOnlySpan data) + { + if (_block.Length - _blockUsed < data.Length) + { + byte[] grown = ArrayPool .Shared.Rent(Math.Max(4096, (_blockUsed + data.Length) * 2)); + _block.AsSpan(0, _blockUsed).CopyTo(grown); + if (_block.Length > 0) + { + ArrayPool .Shared.Return(_block); + } + _block = grown; + } + data.CopyTo(_block.AsSpan(_blockUsed)); + _blockUsed += data.Length; + } + + public void ClearHeaderBlock() + { + if (_block.Length > 0) + { + ArrayPool .Shared.Return(_block); + _block = []; + } + _blockUsed = 0; + } + public void Complete(HttpClientResponse response) => _core.SetResult(response); public void Fail(Exception error) => _core.SetException(error); @@ -617,6 +1079,8 @@ private sealed class PendingRequest : IValueTaskSource public void OnCompleted(Action Nghttp2Connection - same shape, same request and response -/// surface - the wayioxide.http3 is forioxide.nghttp3 . Take this one when shipping -/// a native library is inconvenient; take nghttp2 when you want the reference implementation's -/// coverage of the protocol's darker corners. +/// The default HTTP/2 here, and the one the features land on: streamed responses, streamed +/// request bodies and non-blocking dispatch are all this side.ioxide.nghttp2 remains as +/// the battle-tested alternative - buffered only, and the reference implementation's coverage of +/// the protocol's darker corners. Measured on this rig it runs 1.35x-1.39x the binding as a +/// client; the same framing and HPACK driveioxide.httpclient , pointed the other way round. /// -/// Like its nghttp2 counterpart it speaks to anand knows nothing about -/// TLS: hand it a TcpConnectionDualPipe for h2c or aTlsConnectionDualPipe for h2 -/// over TLS, and the protocol code is identical either way. +/// It speaks to anand knows nothing about TLS: hand it a +/// TcpConnectionDualPipe for h2c or aTlsConnectionDualPipe for h2 over TLS, and the +/// protocol code is identical either way. /// ///Reactor thread only. public sealed partial class Http2Connection : IDisposable @@ -40,6 +41,16 @@ public sealed partial class Http2Connection : IDisposable private readonly Dictionary_streams = new(); private readonly List _ready = []; + // Readers whose parked ReadAsync has something to hand over. Collected during parsing and + // fired once it has unwound, so a resumed handler cannot re-enter the parser mid-frame. + private readonly List _bodyWakes = []; + + // The header block of a stream refused for exceeding MaxConcurrentStreams: decoded to keep + // HPACK in step with the peer, then thrown away. A block cannot interleave with another + // stream's frames, so one of these is enough. + private readonly PendingRequest _discardBlock = new(); + private int _discardingStream; + private bool _prefaceSeen; private bool _disposed; private bool _failed; @@ -91,12 +102,25 @@ public void Dispose() pending.Dispose(); } _ready.Clear(); + _bodyWakes.Clear(); if (_inbound.Length > 0) { ArrayPool .Shared.Return(_inbound); _inbound = []; } + + if (_queued.Length > 0) + { + ArrayPool .Shared.Return(_queued); + _queued = []; + } + _queuedUsed = 0; + + // A writer parked on its turn of the write pump has to wake into IsBroken, not hang: the + // flush that would have completed its turn is never coming. + _turnWaiter?.TrySetResult(); + _turnWaiter = null; } /// Serve until the peer goes away, answering each request with
@@ -122,8 +146,20 @@ public async Task RunBufferedAsync(Func. > if (received) { + // A streamed writer that finishes inside this window needs no write of its own: + // the flush below carries it out together with every other response the pass + // produced. That coalescing is the whole reason buffered h2 is fast, and there + // is no reason a streamed response cannot share it. + _passFlushPending = true; ParseAvailable(); await DispatchReadyAsync(handler); + + // Body chunks reach their handlers here, inside the pass: the WINDOW_UPDATEs a + // read stages then ride the same flush as everything else, so credit gets back + // to the peer without a write of its own. + FireBodyWakes(); + + _passFlushPending = false; await FlushAsync(); } @@ -141,6 +177,8 @@ public async Task RunBufferedAsync(Func > } finally { + // A writer parked on flow-control credit will never be woken by a dead connection. + ReleaseAllCreditWaiters(); Dispose(); } } @@ -198,16 +236,127 @@ private async ValueTask DispatchReadyAsync(Func inFlight; + try { Http2Request request = pending.Freeze(); - Http2Response response = await handler(request); - WriteResponse(pending.StreamId, response); + + if (TryDispatchStreamed(request, pending)) + { + continue; // the writer owns this stream, and retires it when done + } + + inFlight = handler(request); } - finally + catch { pending.Dispose(); + throw; + } + + // Answered synchronously, which nearly every handler does. Stay inline: this response + // is staged in time for the pass flush, so it still leaves with every other one, and + // there is no Task to allocate. + if (inFlight.IsCompletedSuccessfully) + { + try + { + WriteResponse(pending.StreamId, inFlight.Result); + } + finally + { + RetireStream(pending); + } + continue; + } + + // It parked - a database, an upstream, a disk. Awaiting here would hold every OTHER + // stream on this connection behind it, including responses already staged and ready to + // go, because they all share this one dispatch loop and one TCP connection. So it + // finishes on its own and writes its own bytes when it does. + _ = CompleteBufferedAsync(inFlight, pending); + } + } + + /// + /// The tail of a handler that parked. Nothing is awaiting this, so everything the dispatch loop + /// would have done afterwards has to happen here instead - retiring the request, and writing, + /// since the pass flush has long gone by. Forgetting that tail in the streamed path is what + /// leaked 20 GB. + ///
+ private async Task CompleteBufferedAsync(ValueTaskinFlight, PendingRequest pending) + { + try + { + Http2Response response = await inFlight; + WriteResponse(pending.StreamId, response); + } + catch (Exception exception) + { + // Nobody can observe this task, so an escaping exception would vanish silently and the + // peer would wait on a stream that is never coming. + Console.Error.WriteLine($"[ioxide.http2] request handler faulted: {exception.GetBaseException().Message}"); + + if (!IsBroken) + { + WriteResponse(pending.StreamId, new Http2Response { Status = 500 }); } } + finally + { + RetireStream(pending); + await MaybeFlushAsync(); + } + } + + /// + /// Done with a stream. The buffered path already took it out of
+ private void RetireStream(PendingRequest pending) + { + _streams.Remove(pending.StreamId); + pending.Dispose(); + } + + ///_streams when it became + /// ready; a streamed request is still in there, because DATA frames were arriving the whole + /// time the handler ran. + ///Return a consumed chunk's credit to the peer, on both windows it was charged to.
+ internal void CreditBody(int streamId, int length) + { + if (length <= 0 || IsBroken) + { + return; + } + + WriteWindowUpdate(0, length); + WriteWindowUpdate(streamId, length); + } + + ///A reader has something for a parked ReadAsync; wake it once the parser is done.
+ internal void NoteBodyWake(Http2BodyReader reader) + { + if (!_bodyWakes.Contains(reader)) + { + _bodyWakes.Add(reader); + } + } + + private void FireBodyWakes() + { + if (_bodyWakes.Count == 0) + { + return; + } + + // Snapshot-and-clear: a resumed handler reads again, which can land another wake here, and + // the list must not be mutated while it is being walked. + Http2BodyReader[] wakes = _bodyWakes.ToArray(); + _bodyWakes.Clear(); + + foreach (Http2BodyReader reader in wakes) + { + reader.FireIfReady(); + } } } diff --git a/src/protocols/ioxide.http2/Http2Options.cs b/src/protocols/ioxide.http2/Http2Options.cs index 2547894..f00f9fd 100644 --- a/src/protocols/ioxide.http2/Http2Options.cs +++ b/src/protocols/ioxide.http2/Http2Options.cs @@ -15,6 +15,34 @@ public sealed record Http2Options ///Flow-control window advertised per stream.
public int InitialWindowSize { get; init; } = 1 << 20; - ///Streams the peer may have open at once.
+ ///+ /// Streams the peer may have open at once. Advertised in SETTINGS and enforced: past + /// this, a new stream is refused with REFUSED_STREAM rather than allocated. It was advisory + /// once, which made "open a stream, reset it, repeat" (CVE-2023-44487) cost the server an + /// arena per cycle and the peer nothing. + ///
public int MaxConcurrentStreams { get; init; } = 1000; + + ///+ /// Ceiling on one request's header block, accumulated across HEADERS and every CONTINUATION + /// that follows it. + /// + ///
+ public int MaxHeaderListSize { get; init; } = 64 * 1024; + + ///bounds one frame; nothing bounds how many CONTINUATION frames a + /// peer may send, so without this a HEADERS that never sets END_HEADERS grows the block until + /// the process dies - the CONTINUATION flood. Exceeding it is a CONNECTION error, not a stream + /// one, because a block that stops being decoded desynchronises HPACK for everything after it. + /// + /// Dispatch each request as soon as its HEADERS are in, with the body arriving through + ///
+ public bool StreamRequestBodies { get; init; } } diff --git a/src/protocols/ioxide.http2/Http2Request.cs b/src/protocols/ioxide.http2/Http2Request.cs index 549c3a2..1bbe0fa 100644 --- a/src/protocols/ioxide.http2/Http2Request.cs +++ b/src/protocols/ioxide.http2/Http2Request.cs @@ -46,6 +46,13 @@ public bool TryGetHeader(ReadOnlySpaninstead of assembled into + /// . + /// + /// The trade is what memory is bound by. Buffered holds the whole body, which suits ordinary + /// requests and not hostile uploads; streamed holds one flow-control window, because credit is + /// only returned to the peer as the handler reads. stops applying + /// to the body when this is on - there is no arena for it to bound. + /// name, out ReadOnlyMemory value return false; } - /// Request body, empty when there was none.
+ ///Request body, empty when there was none - or when it is being streamed.
public ReadOnlyMemoryBody { get; internal set; } + + /// + /// The body as it arrives, set only when
+ public Http2BodyReader? BodyReader { get; internal set; } } diff --git a/src/protocols/ioxide.http2/Http2Response.cs b/src/protocols/ioxide.http2/Http2Response.cs index 55cf7a7..b0287e8 100644 --- a/src/protocols/ioxide.http2/Http2Response.cs +++ b/src/protocols/ioxide.http2/Http2Response.cs @@ -3,8 +3,8 @@ namespace ioxide.http2; ///is on; + /// null otherwise, when already holds it whole. The handler runs while the + /// body is still coming in, so reading it is what lets the peer send more. + /// /// One HTTP/2 response: status, headers, and an in-memory body - bytes throughout, mirroring ///
public sealed class Http2Response { diff --git a/src/protocols/ioxide.http2/Http2ResponseWriter.cs b/src/protocols/ioxide.http2/Http2ResponseWriter.cs new file mode 100644 index 0000000..6fb4589 --- /dev/null +++ b/src/protocols/ioxide.http2/Http2ResponseWriter.cs @@ -0,0 +1,219 @@ +using System.Buffers; + +namespace ioxide.http2; + +///. Header names must be ASCII (they're lowercased as they're packed); -/// values are raw octets. Everything is copied into nghttp2 synchronously at submit, so the -/// memories can be pooled, stackallocated behind, or static. +/// values are raw octets. Everything is framed into the connection's write buffer synchronously +/// when the handler returns, so the memories can be pooled, stackallocated behind, or static. /// +/// The write half of a STREAMED response: the handler pushes body bytes as it produces them and +/// each flush becomes a DATA frame, instead of the whole body being handed over at once. +/// +/// It is an
+///on purpose - that is the shape a serializer, a file +/// copy or a framework's response sink already writes into, so streaming through it needs no +/// adapter. +/// +/// The difference from the HTTP/3 writer is flow control. QUIC gives each stream its own +/// transport-level credit; HTTP/2 multiplexes every stream onto one TCP connection and tracks +/// TWO windows - one per stream, one for the connection - so a flush can be blocked by either. +/// waits for a WINDOW_UPDATE rather than failing, which is what makes a +/// response longer than the peer's window possible at all. The buffered path resets the stream +/// instead, because a whole body was always in hand and running out of credit could only mean the +/// peer had stopped reading entirely. +/// Reactor thread only, like everything else on the connection. +public sealed class Http2ResponseWriter : IBufferWriter+{ + private const int DefaultChunk = 16 * 1024; + + private readonly Http2Connection _connection; + private int _streamId; + + private byte[] _staging = []; + private int _staged; + + // Bytes THIS response has staged since its last real flush. Inside a pass a writer normally + // rides the pass flush, but past Http2Connection.CoalesceLimit it flushes for real anyway - + // the yield that keeps a producer looping without any await of its own from spinning the + // reactor. Per writer on purpose: a per-pass version of this bound split the pass write and + // cost a third of streamed throughput. + private int _sinceRealFlush; + + private bool _headersSent; + private bool _completed; + + internal Http2ResponseWriter(Http2Connection connection, int streamId) + { + _connection = connection; + _streamId = streamId; + } + + /// The stream this response belongs to.
+ public int StreamId => _streamId; + + ///True once the body has been finished and END_STREAM sent.
+ public bool IsCompleted => _completed; + + ///+ /// Send the response headers. Exactly once, before any body byte - HTTP/2 puts HEADERS ahead + /// of DATA and there is no correcting it later. + /// + /// No content-length is written: a streamed response does not know its length yet, and an + /// endless one never will. HTTP/2 needs none - each DATA frame carries its own, and + /// END_STREAM marks the end. + ///
+ public void WriteHeaders(Http2Response response) + { + ArgumentNullException.ThrowIfNull(response); + + if (_headersSent) + { + throw new InvalidOperationException("Response headers have already been written for this stream."); + } + if (!response.Body.IsEmpty) + { + throw new ArgumentException( + "A streamed response carries its body through the writer; leave Response.Body empty.", + nameof(response)); + } + + _headersSent = true; + _connection.SendStreamedHeaders(_streamId, response); + } + + ///+ public Span GetSpan(int sizeHint = 0) + { + EnsureStaging(sizeHint <= 0 ? 1 : sizeHint); + return _staging.AsSpan(_staged); + } + + /// + public Memory GetMemory(int sizeHint = 0) + { + EnsureStaging(sizeHint <= 0 ? 1 : sizeHint); + return _staging.AsMemory(_staged); + } + + /// + public void Advance(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (_staged + count > _staging.Length) + { + throw new InvalidOperationException("Advanced past the end of the span handed out by GetSpan."); + } + _staged += count; + } + + /// + /// Send everything staged so far as DATA frames, waiting on flow-control credit as needed. + /// That wait is the backpressure: a peer that stops reading stops the producer rather than + /// growing a queue behind it. + ///
+ public ValueTask FlushAsync() => FlushCore(endStream: false); + + ///+ /// Send what is left and mark END_STREAM. A handler that returns without calling this gets it + /// called for it - the peer is owed an end either way. + ///
+ public async ValueTask CompleteAsync() + { + if (_completed) + { + return; + } + + if (!_headersSent) + { + WriteHeaders(new Http2Response { Status = 500 }); + } + + await FlushCore(endStream: true); + _completed = true; + + if (_staging.Length > 0) + { + ArrayPool.Shared.Return(_staging); + _staging = []; + } + } + + private async ValueTask FlushCore(bool endStream) + { + if (!_headersSent) + { + throw new InvalidOperationException("Write the response headers before flushing a body chunk."); + } + + int sent = 0; + while (sent < _staged) + { + // Bounded by the frame size AND by both windows: exceeding either is a connection + // error the peer would be right to hang up over. + int credit = _connection.SendCredit(_streamId); + if (credit <= 0) + { + if (_connection.IsBroken) + { + return; + } + await _connection.WaitForSendCreditAsync(_streamId); + continue; + } + + int chunk = Math.Min(credit, _staged - sent); + bool last = endStream && sent + chunk == _staged; + + _connection.SendStreamedData(_streamId, _staging.AsSpan(sent, chunk), last); + sent += chunk; + } + + _staged = 0; + _sinceRealFlush += sent; + + if (endStream && sent == 0) + { + // Nothing left to send, but the stream still needs its end. + _connection.SendStreamedData(_streamId, ReadOnlySpan .Empty, endStream: true); + } + + // Inside a pass the pass flush carries these frames with every other response's, so the + // writer stays out of the way - unless this one response has already staged past the + // limit, where it must write for real to yield. Outside a pass the flush is always real. + if (_connection.InDispatchPass && _sinceRealFlush < Http2Connection.CoalesceLimit) + { + return; + } + + _sinceRealFlush = 0; + await _connection.FlushOutboundAsync(); + } + + private void EnsureStaging(int sizeHint) + { + int needed = _staged + sizeHint; + if (_staging.Length >= needed) + { + return; + } + + byte[] grown = ArrayPool .Shared.Rent(Math.Max(needed, DefaultChunk)); + if (_staged > 0) + { + _staging.AsSpan(0, _staged).CopyTo(grown); + } + if (_staging.Length > 0) + { + ArrayPool .Shared.Return(_staging); + } + _staging = grown; + } + + /// Take this writer for another stream, keeping its buffer.
+ internal void Reset(int streamId) + { + _streamId = streamId; + _staged = 0; + _sinceRealFlush = 0; + _headersSent = false; + _completed = false; + } +} diff --git a/src/protocols/ioxide.http2/ioxide.http2.csproj b/src/protocols/ioxide.http2/ioxide.http2.csproj index 1e01c2d..e41b632 100644 --- a/src/protocols/ioxide.http2/ioxide.http2.csproj +++ b/src/protocols/ioxide.http2/ioxide.http2.csproj @@ -8,9 +8,9 @@ioxide.http2 ioxide.http2 -0.4.169 +0.4.176 MDA2AV -Pure-C# HTTP/2 for the ioxide io_uring runtime: framing, HPACK (static and dynamic tables, Huffman) and flow control, with zero native code. A drop-in alternative to ioxide.nghttp2 for deployments that would rather not ship a native library - same connection shape, same request and response types. +Pure-C# HTTP/2 for the ioxide io_uring runtime: framing, HPACK (static and dynamic tables, Huffman) and flow control, with zero native code. Serves h2c with prior knowledge and h2 over TLS by ALPN, buffered or streamed in either direction, and the same framing drives ioxide.httpclient's HTTP/2 client. MIT https://mda2av.github.io/ioxide/ https://github.com/MDA2AV/ioxide @@ -24,4 +24,11 @@+ + + + diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj index 8a6538c..f7669fe 100644 --- a/src/protocols/ioxide.http3/ioxide.http3.csproj +++ b/src/protocols/ioxide.http3/ioxide.http3.csproj @@ -8,7 +8,7 @@+ ioxide.http3 ioxide.http3 -0.4.169 +0.4.176 MDA2AV Pure C# HTTP/3 for the ioxide io_uring runtime: frame parsing, QPACK (static table + Huffman) and request dispatch with zero native dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, drop-in alternative to ioxide.nghttp3. MIT diff --git a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Egress.cs b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Egress.cs index ac60c81..2814e2c 100644 --- a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Egress.cs +++ b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Egress.cs @@ -90,6 +90,12 @@ private static int WriteStatus(int status, Spandestination) /// Pull everything nghttp2 has queued into the write slab and flush once. Looping until the /// session reports nothing left is what keeps a batch of responses to a single send. /// + // One drain at a time. Both the read loop and a handler that finished late can start one, and a + // drain awaits a real flush - so without this they interleave writes out of the single _egress + // buffer, and a PipeWriter refuses a Write while a flush is outstanding anyway. + private bool _draining; + private bool _drainAgain; + private async ValueTask FlushEgressAsync() { if (_handle == 0) @@ -97,23 +103,45 @@ private async ValueTask FlushEgressAsync() return; } - bool staged = false; + if (_draining) + { + // A drain owns the pipe. Whatever was just submitted is sitting in nghttp2's own + // queue rather than in ours, so the loop below will pull it before it returns - which + // is why this needs no queue of its own, unlike the managed stack. + _drainAgain = true; + return; + } - while (true) + _draining = true; + try { - int produced = DrainOnce(); - if (produced <= 0) + do { - break; + _drainAgain = false; + + bool staged = false; + while (true) + { + int produced = DrainOnce(); + if (produced <= 0) + { + break; + } + + _pipe.Output.Write(_egress.AsSpan(0, produced)); + staged = true; + } + + if (staged) + { + await _pipe.Output.FlushAsync(); + } } - - _pipe.Output.Write(_egress.AsSpan(0, produced)); - staged = true; + while (_drainAgain && _handle != 0); } - - if (staged) + finally { - await _pipe.Output.FlushAsync(); + _draining = false; } } diff --git a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.RunBuffered.cs b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.RunBuffered.cs index 6b96ba1..4548ac9 100644 --- a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.RunBuffered.cs +++ b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.RunBuffered.cs @@ -88,18 +88,70 @@ private async ValueTask DispatchReadyAsync(Func inFlight; + try { - Nghttp2Request request = pending.Freeze(); - Nghttp2Response response = await handler(request); - SubmitResponse(pending.StreamId, response); + inFlight = handler(pending.Freeze()); } - finally + catch { - // The arena backs the request's memories, so it can only go back once the handler - // has returned and the response is submitted (which copies). pending.Dispose(); + throw; } + + // Answered synchronously, which nearly every handler does. Stay inline: the response is + // submitted in time for this pass's drain, so it still leaves with every other one, and + // there is no Task to allocate. + if (inFlight.IsCompletedSuccessfully) + { + try + { + SubmitResponse(pending.StreamId, inFlight.Result); + } + finally + { + // The arena backs the request's memories, so it can only go back once the + // handler has returned and the response is submitted (which copies). + pending.Dispose(); + } + continue; + } + + // It parked - a database, an upstream, a disk. Awaiting here would hold every OTHER + // stream on this connection behind it, including responses already submitted and + // waiting to go, because they all share this one dispatch loop and one TCP connection. + _ = CompleteAsync(inFlight, pending); + } + } + + /// + /// The tail of a handler that parked. Nothing awaits this, so everything the dispatch loop + /// would have done afterwards has to happen here: submitting, retiring the request, and + /// draining, since this pass's drain has long gone by. + ///
+ private async Task CompleteAsync(ValueTaskinFlight, PendingRequest pending) + { + try + { + SubmitResponse(pending.StreamId, await inFlight); + } + catch (Exception exception) + { + // Nobody can observe this task, so an escaping exception would vanish silently and the + // peer would wait on a stream that is never coming. + Console.Error.WriteLine( + $"[ioxide.nghttp2] request handler faulted: {exception.GetBaseException().Message}"); + + if (!IsBroken) + { + SubmitResponse(pending.StreamId, new Nghttp2Response { Status = 500 }); + } + } + finally + { + pending.Dispose(); + await FlushEgressAsync(); } } } diff --git a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj index fe1239f..cdd5b6b 100644 --- a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj +++ b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj @@ -8,7 +8,7 @@ ioxide.nghttp2 ioxide.nghttp2 -0.4.169 +0.4.176 MDA2AV HTTP/2 for the ioxide io_uring runtime: framing, HPACK and flow control from nghttp2, statically linked behind a small shim with no external dependencies beyond libc. Serves HTTP/2 over any TcpConnection - h2c with prior knowledge, or h2 over TLS via ALPN - and backs the HTTP/2 client in ioxide.httpclient from the same session code. nghttp2 is sans-I/O, so ioxide keeps the ring and the loop. MIT diff --git a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj index 70ffb18..8007bf7 100644 --- a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj +++ b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj @@ -8,7 +8,7 @@ioxide.nghttp3 ioxide.nghttp3 -0.4.169 +0.4.176 MDA2AV HTTP/3 layer for the ioxide io_uring runtime: nghttp3 (H3 + QPACK) bundled as a single self-contained native library with no external dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, no ioxide.ngtcp2 dependency. MIT diff --git a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj index 8feb361..16f7648 100644 --- a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj +++ b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj @@ -8,7 +8,7 @@ioxide.ngtcp2 ioxide.ngtcp2 -0.4.169 +0.4.176 MDA2AV QUIC engine for the ioxide io_uring runtime: ngtcp2 + picotls bundled as a single self-contained native library (only system dependency: libcrypto.so.3 / OpenSSL 3.x). Plugs into the reactor's QUIC transport via QuicConnection. Server side; engine bindings in progress. MIT diff --git a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj index 5c0295d..5ff4358 100644 --- a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj +++ b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj @@ -8,7 +8,7 @@ioxide.Kestrel ioxide.Kestrel -0.4.169 +0.4.176 MDA2AV ASP.NET Core Kestrel transport backed by the ioxide io_uring runtime: one reactor (ring) per core, SO_REUSEPORT load-balanced, with Kestrel's HTTP request loop pinned to the reactor thread. Drop-in via UseIoxide(). MIT diff --git a/tests/Ioxide.Tests.Chaos/H2ChaosTests.cs b/tests/Ioxide.Tests.Chaos/H2ChaosTests.cs index ff4ecc2..fe61279 100644 --- a/tests/Ioxide.Tests.Chaos/H2ChaosTests.cs +++ b/tests/Ioxide.Tests.Chaos/H2ChaosTests.cs @@ -11,11 +11,11 @@ namespace Ioxide.Tests; /// internal static class H2ChaosTests { - private static int StartH2c() => TestServer.Start(static async (_, conn) => + private static int StartH2c(Http2Options? options = null) => TestServer.Start(async (_, conn) => { try { - await new Http2Connection(conn).RunBufferedAsync(static _ => Http2Response.Text("ok")); + await new Http2Connection(conn, options).RunBufferedAsync(static _ => Http2Response.Text("ok")); } finally { @@ -116,5 +116,64 @@ public static void Register(Runner runner) // burst without losing the connection. Assert.True(client.AwaitResponse(streamId: 1), "server dropped a multiplexed request burst"); }); + + runner.Test("h2c: a CONTINUATION flood is cut off instead of growing without bound", () => + { + // MaxFrameSize bounds one frame; nothing bounds how MANY continuations follow a HEADERS + // that never sets END_HEADERS. Unbounded, the accumulated block grows until the process + // dies - the flood disclosed in April 2024. The block is capped now, and exceeding it is + // a CONNECTION error because a block that stops being decoded desynchronises HPACK. + int port = StartH2c(new Http2Options { MaxHeaderListSize = 16 * 1024 }); + + using (var client = new H2cClient(port)) + { + client.Open(); + client.RequestHeadersOnly(streamId: 1, endHeaders: false, endStream: false); + + byte[] filler = new byte[4096]; + byte seen = 0; + for (int i = 0; i < 64 && seen == 0; i++) // 256 KiB, far past the 16 KiB cap + { + try + { + client.WriteFrame(H2cClient.Continuation, flags: 0, streamId: 1, filler); + } + catch (IOException) + { + seen = 0xFF; // server closed on us, which is also a refusal + break; + } + seen = client.AwaitAnyOf([0x7], timeoutMs: 50); // GOAWAY + } + + Assert.True(seen != 0, "server accepted an unbounded CONTINUATION block"); + } + + AssertServes(port); // and the process is still there to serve the next connection + }); + + runner.Test("h2c: streams past MaxConcurrentStreams are refused, not allocated", () => + { + // The limit was advertised in SETTINGS and never enforced, so "open a stream, reset it, + // repeat" cost the server an arena per cycle and the peer nothing (CVE-2023-44487). + // Streams past the limit now get REFUSED_STREAM, which RFC 9113 8.7 makes safe to retry. + int port = StartH2c(new Http2Options { MaxConcurrentStreams = 4 }); + + using var client = new H2cClient(port); + client.Open(); + + // END_HEADERS but NOT END_STREAM: each stream stays open, so the limit is reached. + for (int i = 0; i < 4; i++) + { + client.RequestHeadersOnly(1 + (i * 2), endHeaders: true, endStream: false); + } + + client.RequestHeadersOnly(streamId: 101, endHeaders: true, endStream: false); + Assert.Equal(H2cClient.RstStream, client.AwaitAnyOf([H2cClient.RstStream], streamId: 101)); + + // Refusing must not have desynchronised HPACK - the block was still decoded - so a + // stream opened afterwards on the same connection still parses. + AssertServes(port); + }); } } diff --git a/tests/Ioxide.Tests.Chaos/H2cClient.cs b/tests/Ioxide.Tests.Chaos/H2cClient.cs index 56de645..617a637 100644 --- a/tests/Ioxide.Tests.Chaos/H2cClient.cs +++ b/tests/Ioxide.Tests.Chaos/H2cClient.cs @@ -18,6 +18,68 @@ public sealed class H2cClient : IDisposable private const byte Data = 0x0, Headers = 0x1, Settings = 0x4, GoAway = 0x7; private const byte EndStream = 0x1, EndHeaders = 0x4, Ack = 0x1; + public const byte RstStream = 0x3, Continuation = 0x9; + + ///+ /// The stream id of the FIRST response to come back, whichever it is. Ordering rather than a + /// stopwatch is what makes a head-of-line test deterministic: if dispatch waits for each + /// handler in turn, the slow stream answers first because it was dispatched first. + ///
+ public int AwaitFirstResponse(int timeoutMs = 8000) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (!TryReadFrame(out byte type, out byte flags, out int sid, out _)) + { + return -1; + } + if (type == Settings && (flags & Ack) == 0) + { + WriteFrame(Settings, Ack, 0, ReadOnlySpan.Empty); + } + else if (type == Headers) + { + return sid; + } + } + return -1; + } + + /// HEADERS that deliberately leaves the block OPEN, so CONTINUATION must follow.
+ public void RequestHeadersOnly(int streamId, bool endHeaders = true, bool endStream = true) + => WriteFrame(Headers, (byte)((endHeaders ? EndHeaders : 0) | (endStream ? EndStream : 0)), + streamId, Hpack("GET", "/")); + + ///+ /// Pump until the server sends one of
+ public byte AwaitAnyOf(ReadOnlySpan(0 = any stream), answering + /// SETTINGS as they arrive. Returns the frame type seen, or 0 on timeout or a closed connection. + /// wanted, int streamId = 0, int timeoutMs = 4000) + { + long deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (!TryReadFrame(out byte type, out byte flags, out int sid, out _)) + { + return 0; + } + if (type == Settings && (flags & Ack) == 0) + { + WriteFrame(Settings, Ack, 0, ReadOnlySpan .Empty); + continue; + } + foreach (byte want in wanted) + { + if (type == want && (streamId == 0 || sid == streamId)) + { + return type; + } + } + } + return 0; + } + private readonly TcpClient _tcp; private readonly NetworkStream _s; diff --git a/tests/Ioxide.Tests.Chaos/Ioxide.Tests.Chaos.csproj b/tests/Ioxide.Tests.Chaos/Ioxide.Tests.Chaos.csproj index c8d0d16..2e5d6b0 100644 --- a/tests/Ioxide.Tests.Chaos/Ioxide.Tests.Chaos.csproj +++ b/tests/Ioxide.Tests.Chaos/Ioxide.Tests.Chaos.csproj @@ -16,6 +16,7 @@ server is the one surface it does not, so it is referenced directly. --> + diff --git a/tests/Ioxide.Tests.Chaos/Nghttp2ChaosTests.cs b/tests/Ioxide.Tests.Chaos/Nghttp2ChaosTests.cs new file mode 100644 index 0000000..2843d34 --- /dev/null +++ b/tests/Ioxide.Tests.Chaos/Nghttp2ChaosTests.cs @@ -0,0 +1,164 @@ +using ioxide; +using ioxide.nghttp2; + +namespace Ioxide.Tests; + +/// +/// The same h2c assaults as
+internal static class Nghttp2ChaosTests +{ + private static int StartH2c() => TestServer.Start(static async (_, conn) => + { + try + { + await new Nghttp2Connection(conn).RunBufferedAsync( + static _ => new Nghttp2Response { Status = 200, Body = "ok"u8.ToArray() }); + } + finally + { + conn.DecRef(); + } + }); + + ///, pointed at the nghttp2 binding instead of +/// the managed server. It is a supported option again, so it is tested like one - and because the +/// wire is identical, the client harness is shared and the two can be compared directly. +/// +/// Two of these are the vectors the managed server had to be taught to defend against. Running +/// them here answers what "battle-tested" is actually worth: the reference implementation should +/// turn both away without being told to. +/// A server whose
+ private static int StartSlowH2c() => TestServer.Start(static async (_, conn) => + { + try + { + await new Nghttp2Connection(conn).RunBufferedAsync(static async request => + { + if (request.Path.Span.SequenceEqual("/slow"u8)) + { + await Task.Delay(1000); + } + return new Nghttp2Response { Status = 200, Body = "ok"u8.ToArray() }; + }); + } + finally + { + conn.DecRef(); + } + }); + + private static void AssertServes(int port) + { + using var client = new H2cClient(port); + client.Open(); + client.Request(streamId: 1); + Assert.True(client.AwaitResponse(streamId: 1), "server did not answer a well-formed h2c request"); + } + + public static void Register(Runner runner) + { + runner.Test("nghttp2: a well-formed request is answered", () => AssertServes(StartH2c())); + + runner.Test("nghttp2: a bad connection preface is rejected, server survives", () => + { + int port = StartH2c(); + + using (var bad = new H2cClient(port)) + { + bad.WriteRaw("NOT-AN-HTTP2-PREFACE\r\n\r\n"u8); + bad.WriteRaw(new byte[64]); + } + + AssertServes(port); + }); + + runner.Test("nghttp2: a frame larger than the max is rejected, server survives", () => + { + int port = StartH2c(); + + using (var bad = new H2cClient(port)) + { + bad.Open(); + // Declare 1 MiB of DATA and send none: past SETTINGS_MAX_FRAME_SIZE, and a lie. + bad.WriteFrameHeader(0x0, 0, 1, 1024 * 1024, ReadOnlySpan/slow handler parks for a second before answering..Empty); + } + + AssertServes(port); + }); + + runner.Test("nghttp2: unknown frame types are ignored, the request still answers", () => + { + int port = StartH2c(); + + using var client = new H2cClient(port); + client.Open(); + client.WriteFrame(0x2A, flags: 0, streamId: 0, "ignore me"u8); // no such frame type + client.Request(streamId: 1); + + Assert.True(client.AwaitResponse(streamId: 1), "an unknown frame type broke the connection"); + }); + + runner.Test("nghttp2: a frame truncated mid-payload is handled, server survives", () => + { + int port = StartH2c(); + + using (var bad = new H2cClient(port)) + { + bad.Open(); + bad.WriteFrameHeader(0x0, 0, 1, declaredLen: 256, actual: new byte[32]); + } + + AssertServes(port); + }); + + runner.Test("nghttp2: a CONTINUATION flood is refused, server survives", () => + { + // The managed server needed MaxHeaderListSize taught to it. nghttp2 has defended + // against this since the flood was disclosed, so this should pass with no configuring - + // which is precisely the argument for keeping the binding around. + int port = StartH2c(); + + using (var client = new H2cClient(port)) + { + client.Open(); + client.RequestHeadersOnly(streamId: 1, endHeaders: false, endStream: false); + + byte[] filler = new byte[4096]; + byte seen = 0; + for (int i = 0; i < 128 && seen == 0; i++) + { + try + { + client.WriteFrame(H2cClient.Continuation, flags: 0, streamId: 1, filler); + } + catch (IOException) + { + seen = 0xFF; // closed on us, which is a refusal + break; + } + seen = client.AwaitAnyOf([0x7], timeoutMs: 50); // GOAWAY + } + + Assert.True(seen != 0, "nghttp2 accepted an unbounded CONTINUATION block"); + } + + AssertServes(port); + }); + + runner.Test("nghttp2: a slow handler does not block another stream", () => + { + // The binding kept the blocking dispatch loop long after the managed stack lost it: + // DispatchReadyAsync awaited each handler in turn, so /fast could not answer until + // /slow had. Asserting on ORDER rather than elapsed time keeps this deterministic - + // with a blocking loop the first response is always the stream dispatched first. + int port = StartSlowH2c(); + + using var client = new H2cClient(port); + client.Open(); + client.Request(streamId: 1, path: "/slow"); + client.Request(streamId: 3, path: "/fast"); + + Assert.Equal(3, client.AwaitFirstResponse()); + }); + } +} diff --git a/tests/Ioxide.Tests.Chaos/Program.cs b/tests/Ioxide.Tests.Chaos/Program.cs index de3994a..b67bddb 100644 --- a/tests/Ioxide.Tests.Chaos/Program.cs +++ b/tests/Ioxide.Tests.Chaos/Program.cs @@ -20,6 +20,7 @@ private static int Main() TcpChaosTests.Register(runner); TlsChaosTests.Register(runner); H2ChaosTests.Register(runner); + Nghttp2ChaosTests.Register(runner); QuicChaosTests.Register(runner); H3ChaosTests.Register(runner); diff --git a/tests/Ioxide.Tests.Http/Http2ClientTests.cs b/tests/Ioxide.Tests.Http/Http2ClientTests.cs index 3e58313..44834d6 100644 --- a/tests/Ioxide.Tests.Http/Http2ClientTests.cs +++ b/tests/Ioxide.Tests.Http/Http2ClientTests.cs @@ -39,9 +39,9 @@ public static void Register(Runner runner) Assert.Equal("200|1024", body); // the sidecar's 1 KiB object }, skip: noSidecar); - // Regression guard: request bodies were silently dropped by the shim (NGHTTP2_DATA_FLAG_NO_COPY - // with a no-op send_data callback made nghttp2 account each DATA frame as sent without ever - // emitting it). Every earlier test was a GET, so nothing caught it. + // Regression guard: request bodies were once silently dropped - accounted for as sent + // without a DATA frame ever leaving. Every other test here is a GET, so nothing else on + // this connection would notice. runner.Test("httpclient h2: POST body actually reaches the origin", () => { int driver = TestServer.Start(PostDriverHandler, onStart: reactor => @@ -88,12 +88,11 @@ public static void Register(Runner runner) runner.Test("httpclient h2: a trailered response survives and keeps its body", () => { - // nghttp2 reports TRAILERS as HCAT_HEADERS - the same category as the real response - // after a 1xx - so begin_headers fires a SECOND time at the end of a trailered stream. - // While that callback replaced the response, the assembled one was discarded with - // BodyStart/BodyLength still describing its arena, and end_stream then sliced those - // offsets out of the fresh, near-empty one: a large body threw - // ArgumentOutOfRangeException from inside [UnmanagedCallersOnly] and killed the + // Trailers are a SECOND field section on a stream that already has one, which is the + // same shape as the real response arriving after a 1xx. While the second section + // replaced the response, the assembled one was discarded with BodyStart/BodyLength + // still describing its arena, and end-of-stream then sliced those offsets out of the + // fresh, near-empty one: a large body threw ArgumentOutOfRangeException and killed the // process, a small one came back as silent garbage with status 0. // // The 20 KB object is deliberate. It cannot fit the trailer section's arena, so a @@ -109,6 +108,190 @@ public static void Register(Runner runner) (_, string second) = Client.Get(driver, "/big.html", timeoutMs: 20_000); Assert.Equal("200|20000", second); }, skip: noTrailerSidecar); + + // The two paths below need an origin that reports back what it RECEIVED, which nginx has no + // way to do - so they run against ioxide's own HTTP/2 server. Both exercise client code the + // sidecar tests never reach, because a 1 KiB GET fits in one window and one frame. + + runner.Test("httpclient h2: a body past the flow-control window arrives whole", () => + { + // 1 MiB against a 65535-byte connection window: the body cannot go out in one pass, so + // the client has to send what it has credit for, park, and resume on each WINDOW_UPDATE + // the origin sends back. Getting this wrong either truncates the body or blows the + // window and earns a FLOW_CONTROL_ERROR - the origin echoes the length, so both show. + const int BodyBytes = 1024 * 1024; + + int origin = StartEchoOrigin(); + int driver = TestServer.Start(PostSizeDriver(BodyBytes), onStart: reactor => + Http2ClientPool.Start(reactor, OriginOptions(origin))); + + (int status, string body) = Client.Get(driver, "/echo", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.Equal($"200|{BodyBytes}", body); + }); + + runner.Test("h2 server: a streamed request body is read as it arrives", () => + { + // Same 1 MiB upload, but the origin never holds it: StreamRequestBodies dispatches at + // the headers and hands the handler a reader. Window credit goes back only as chunks + // are read, so if that crediting were wrong the upload would stall at the first window + // and this would time out rather than come back short. + const int BodyBytes = 1024 * 1024; + + int origin = StartStreamedOrigin(); + int driver = TestServer.Start(PostSizeDriver(BodyBytes), onStart: reactor => + Http2ClientPool.Start(reactor, OriginOptions(origin))); + + (int status, string body) = Client.Get(driver, "/echo", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.Equal($"200|{BodyBytes}", body); + }); + + runner.Test("httpclient h2: a header block past the frame size continues", () => + { + // 40 headers of ~512 bytes overflows the 16 KiB maximum frame size, so the field + // section has to leave as HEADERS + CONTINUATION. The block cannot be split anywhere + // else on the connection either - HPACK is one stream, and a decoder needs the pieces + // contiguous - so a mistake here desynchronises the table rather than failing cleanly. + int origin = StartEchoOrigin(); + int driver = TestServer.Start(ManyHeadersDriver(count: 40, valueBytes: 512), onStart: reactor => + Http2ClientPool.Start(reactor, OriginOptions(origin))); + + (int status, string body) = Client.Get(driver, "/headers", timeoutMs: 30_000); + Assert.Equal(200, status); + Assert.Equal("200|40", body); + }); + } + + private static Http2ClientOptions OriginOptions(int port) => new() + { + Host = "127.0.0.1", + Port = (ushort)port, + PoolSize = 1, + }; + + /// + /// An ioxide HTTP/2 origin that answers with what it received: the body length for a request + /// that carried one, otherwise the number of ordinary header fields. + ///
+ private static int StartEchoOrigin() => TestServer.Start(async (_, connection) => + { + try + { + await new ioxide.http2.Http2Connection(connection).RunBufferedAsync(request => + new ioxide.http2.Http2Response + { + Status = 200, + Body = Encoding.ASCII.GetBytes( + (request.Body.Length > 0 ? request.Body.Length : request.Headers.Count).ToString()), + }); + } + finally + { + connection.DecRef(); + } + }); + + ///+ /// The same origin with the body STREAMED: it counts the bytes it is handed and never keeps + /// them, so the answer proves the whole body arrived without any of it being held. + ///
+ private static int StartStreamedOrigin() => TestServer.Start(async (_, connection) => + { + try + { + var options = new ioxide.http2.Http2Options { StreamRequestBodies = true }; + await new ioxide.http2.Http2Connection(connection, options).RunBufferedAsync(async request => + { + int total = 0; + if (request.BodyReader is { } reader) + { + while (true) + { + ReadOnlyMemorychunk = await reader.ReadAsync(); + if (chunk.IsEmpty) + { + break; + } + total += chunk.Length; + } + } + + return new ioxide.http2.Http2Response + { + Status = 200, + Body = Encoding.ASCII.GetBytes(total.ToString()), + }; + }); + } + finally + { + connection.DecRef(); + } + }); + + private static Func PostSizeDriver(int bodyBytes) + => (reactor, connection) => DriveOnce(reactor, connection, upstream => + { + byte[] payload = new byte[bodyBytes]; + payload.AsSpan().Fill((byte)'z'); + return upstream.PostAsync("/echo"u8.ToArray(), payload); + }); + + private static Func ManyHeadersDriver(int count, int valueBytes) + => (reactor, connection) => DriveOnce(reactor, connection, upstream => + { + var request = new HttpClientRequest(HttpMethods.Get, "/headers"); + for (int i = 0; i < count; i++) + { + request.Headers.Add( + Encoding.ASCII.GetBytes($"x-filler-{i:D3}"), + Encoding.ASCII.GetBytes(new string('v', valueBytes))); + } + return upstream.SendAsync(request); + }); + + // One request per inbound connection, answering with "status|body" so the assertion reads the + // origin's own account of what arrived. + private static async Task DriveOnce(Reactor reactor, TcpConnection connection, + Func > exchange) + { + try + { + Http2ClientPool upstream = reactor.GetService ()!; + + while (true) + { + RecvSnapshot snapshot = await connection.ReadAsync(); + if (snapshot.IsClosed) + { + return; + } + Wire.ReadPath(connection, snapshot); + + string detail; + int status; + try + { + using HttpClientResponse response = await exchange(upstream); + status = response.Status; + detail = Encoding.ASCII.GetString(response.Body.Span); + } + catch (Exception e) + { + status = 599; + detail = e.Message; + } + + Wire.Write(connection, 200, $"{status}|{detail}"); + await connection.FlushAsync(); + connection.ResetRead(); + } + } + finally + { + connection.DecRef(); + } } // Sends a POST with a 4 KiB body and reports the status, so a dropped body shows up as a diff --git a/tests/Ioxide.Tests.Http/Ioxide.Tests.Http.csproj b/tests/Ioxide.Tests.Http/Ioxide.Tests.Http.csproj index 0233ecf..7a09723 100644 --- a/tests/Ioxide.Tests.Http/Ioxide.Tests.Http.csproj +++ b/tests/Ioxide.Tests.Http/Ioxide.Tests.Http.csproj @@ -13,6 +13,7 @@ diff --git a/tests/Ioxide.Tests.Unit/Http2OutputQueueTests.cs b/tests/Ioxide.Tests.Unit/Http2OutputQueueTests.cs new file mode 100644 index 0000000..92c6891 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/Http2OutputQueueTests.cs @@ -0,0 +1,329 @@ +using System.Buffers.Binary; +using System.IO.Pipelines; +using ioxide.http2; + +namespace Ioxide.Tests; + +/// + +/// The HTTP/2 write queue: staging and flushing take turns on the connection's pipe, so a handler +/// that completes while a transport flush is in flight queues its frames and they leave - together +/// with every other completion from that window - on the single flush after it. +/// +/// The fake transport here enforces the same contract as the real TcpConnection: any write while a +/// flush is in flight throws, and so does a second flush. That is the constraint the queue exists +/// to solve, so a test that passed against a lenient fake would prove nothing. +///
+internal static class Http2OutputQueueTests +{ + public static void Register(Runner runner) + { + runner.Test("h2 queue: a pass's responses still leave on one flush", () => + { + using var client = new StrictClient(); + Task run = client.Connection.RunBufferedAsync(_ => Http2Response.Text("hi")); + + client.ReleaseFlush(); // server SETTINGS + client.SendRequests(1, 3); + + // One pass: SETTINGS ack plus both responses, staged together, flushed once. + Assert.Equal(1, client.PendingFlushes); + byte[] pass = client.ReleaseFlush(); + + List frames = Frame.Walk(pass); + Assert.True(frames.Any(f => f.Type == 0x4 && (f.Flags & 0x1) != 0), "settings ack in pass flush"); + AssertResponse(frames, streamId: 1); + AssertResponse(frames, streamId: 3); + + client.Close(run); + }); + + runner.Test("h2 queue: completions during a flush share the next one", () => + { + using var client = new StrictClient(); + + var parked = new Dictionary>(); + Task run = client.Connection.RunBufferedAsync(request => + { + var tcs = new TaskCompletionSource (); + parked[request.StreamId] = tcs; + return new ValueTask (tcs.Task); + }); + + client.ReleaseFlush(); // server SETTINGS + client.SendRequests(1, 3); + + // The pass flushed only the SETTINGS ack; both handlers are parked. Complete them + // while that flush is still in flight - exactly where the old code threw + // "Cannot write while flush is in progress" and killed the connection. + Assert.Equal(1, client.PendingFlushes); + parked[1].SetResult(Http2Response.Text("first")); + parked[3].SetResult(Http2Response.Text("second")); + + Assert.Equal(1, client.PendingFlushes); // both queued; nothing new in flight + client.ReleaseFlush(); // the ack + + // ONE flush now carries both responses - the write the queue exists to coalesce. + Assert.Equal(1, client.PendingFlushes); + List frames = Frame.Walk(client.ReleaseFlush()); + AssertResponse(frames, streamId: 1); + AssertResponse(frames, streamId: 3); + + client.Close(run); + }); + + runner.Test("h2 queue: a streamed body resumed outside the pass keeps frame order", () => + { + using var client = new StrictClient(); + + var gate = new TaskCompletionSource(); + Task run = client.Connection.RunAsync(async (_, writer) => + { + writer.WriteHeaders(new Http2Response { Status = 200 }); + Push(writer, "live"); + await writer.FlushAsync(); // inside the pass: rides the pass flush + await gate.Task; // park past the pass, like a slow upstream + Push(writer, "wire"); + await writer.FlushAsync(); // outside: a real transport write + }); + + client.ReleaseFlush(); // server SETTINGS + client.SendRequests(1); + + List pass = Frame.Walk(client.ReleaseFlush()); + Assert.True(pass.Any(f => f.Type == 0x1 && f.StreamId == 1), "HEADERS rode the pass flush"); + Assert.True(pass.Any(f => f is { Type: 0x0, StreamId: 1, Length: 4, EndStream: false }), + "first chunk rode the pass flush, stream open"); + + gate.SetResult(); // resume: chunk, then END_STREAM on complete + List chunk = Frame.Walk(client.ReleaseFlush()); + Assert.True(chunk.Any(f => f is { Type: 0x0, StreamId: 1, Length: 4, EndStream: false }), + "second chunk flushed on resume"); + + List fin = Frame.Walk(client.ReleaseFlush()); + Assert.True(fin.Any(f => f is { Type: 0x0, StreamId: 1, Length: 0, EndStream: true }), + "completion sent END_STREAM"); + + client.Close(run); + }); + + runner.Test("h2 queue: a faulted flush wakes parked writers instead of stranding them", () => + { + using var client = new StrictClient(); + + var parked = new Dictionary >(); + Task run = client.Connection.RunBufferedAsync(request => + { + var tcs = new TaskCompletionSource (); + parked[request.StreamId] = tcs; + return new ValueTask (tcs.Task); + }); + + client.ReleaseFlush(); // server SETTINGS + client.SendRequests(1); + + // The completion queues behind the in-flight ack flush and parks on its turn. Then the + // transport dies. The waiter must wake and the connection must finish, not hang. + parked[1].SetResult(Http2Response.Text("never sent")); + client.FaultFlush(); + + Assert.True(run.Wait(5_000), "connection wound down after the transport fault"); + }); + } + + private static void Push(Http2ResponseWriter writer, string text) + { + Span span = writer.GetSpan(text.Length); + for (int i = 0; i < text.Length; i++) + { + span[i] = (byte)text[i]; + } + writer.Advance(text.Length); + } + + private static void AssertResponse(List frames, int streamId) + { + Assert.True(frames.Any(f => f.Type == 0x1 && f.StreamId == streamId), + $"HEADERS for stream {streamId}"); + Assert.True(frames.Any(f => f is { Type: 0x0, EndStream: true } && f.StreamId == streamId), + $"DATA with END_STREAM for stream {streamId}"); + } + + private readonly record struct Frame(int Length, byte Type, byte Flags, int StreamId) + { + public bool EndStream => (Flags & 0x1) != 0; + + public static List Walk(ReadOnlySpan wire) + { + var frames = new List(); + int at = 0; + while (at + 9 <= wire.Length) + { + int length = (wire[at] << 16) | (wire[at + 1] << 8) | wire[at + 2]; + byte type = wire[at + 3]; + byte flags = wire[at + 4]; + int stream = (int)(BinaryPrimitives.ReadUInt32BigEndian(wire[(at + 5)..]) & 0x7FFFFFFF); + frames.Add(new Frame(length, type, flags, stream)); + at += 9 + length; + } + Assert.Equal(wire.Length, at); // no torn frame: every byte accounted for + return frames; + } + } + + /// + /// An HTTP/2 client the test steps by hand: an inline input pipe to feed frames, and a strict + /// output writer whose flushes complete only when the test releases them. + ///
+ private sealed class StrictClient : IDuplexPipe, IDisposable + { + private readonly Pipe _input = new(new PipeOptions( + readerScheduler: PipeScheduler.Inline, + writerScheduler: PipeScheduler.Inline, + useSynchronizationContext: false)); + + private readonly StrictWriter _writer = new(); + + public StrictClient() => Connection = new Http2Connection(this); + + public Http2Connection Connection { get; } + + public PipeReader Input => _input.Reader; + public PipeWriter Output => _writer; + + public int PendingFlushes => _writer.PendingFlushes; + public byte[] ReleaseFlush() => _writer.ReleaseFlush(); + public void FaultFlush() => _writer.FaultFlush(); + + ///Preface, an empty SETTINGS, then one indexed-HPACK GET per stream id.
+ public void SendRequests(params int[] streamIds) + { + var bytes = new List("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray()); + bytes.AddRange(FrameHeaderBytes(0, 0x4, 0, 0)); + foreach (int streamId in streamIds) + { + // 0x82 :method GET, 0x86 :scheme http, 0x84 :path / - static table only, so the + // request needs no HPACK encoder of its own. + bytes.AddRange(FrameHeaderBytes(3, 0x1, 0x5, streamId)); + bytes.AddRange([0x82, 0x86, 0x84]); + } + Feed(bytes.ToArray()); + } + + public void Feed(byte[] bytes) + => _input.Writer.WriteAsync(bytes).GetAwaiter().GetResult(); + + /// End the input and wait out the connection's run task.
+ public void Close(Task run) + { + _input.Writer.Complete(); + Assert.True(run.Wait(5_000), "connection wound down"); + } + + public void Dispose() => Connection.Dispose(); + + private static byte[] FrameHeaderBytes(int length, byte type, byte flags, int streamId) => + [ + (byte)(length >> 16), (byte)(length >> 8), (byte)length, + type, flags, + (byte)(streamId >> 24), (byte)(streamId >> 16), (byte)(streamId >> 8), (byte)streamId, + ]; + } + + ///+ /// The real transport's write contract, distilled: writes throw while a flush is in flight, a + /// second flush throws, and a flush completes only when the reactor - here, the test - says so. + ///
+ private sealed class StrictWriter : PipeWriter + { + private readonly List_staged = []; + private readonly Queue<(TaskCompletionSource Signal, byte[] Payload)> _inFlight = new(); + private byte[] _scratch = new byte[4096]; + private bool _flushing; + + public int PendingFlushes => _inFlight.Count; + + public override Memory GetMemory(int sizeHint = 0) + { + ThrowIfFlushing(); + EnsureScratch(sizeHint); + return _scratch; + } + + public override Span GetSpan(int sizeHint = 0) + { + ThrowIfFlushing(); + EnsureScratch(sizeHint); + return _scratch; + } + + public override void Advance(int bytes) + { + ThrowIfFlushing(); + _staged.AddRange(_scratch.AsSpan(0, bytes)); + } + + public override ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + if (_flushing) + { + throw new InvalidOperationException("FlushAsync already in progress."); + } + if (_staged.Count == 0) + { + return new ValueTask (new FlushResult(isCanceled: false, isCompleted: false)); + } + + _flushing = true; + var signal = new TaskCompletionSource (); + _inFlight.Enqueue((signal, _staged.ToArray())); + _staged.Clear(); + return new ValueTask (signal.Task); + } + + /// Complete the oldest in-flight flush and hand back the bytes it carried.
+ public byte[] ReleaseFlush() + { + Assert.True(_inFlight.Count > 0, "a flush was expected to be in flight"); + (TaskCompletionSourcesignal, byte[] payload) = _inFlight.Dequeue(); + + // Writable again BEFORE the signal: the queue drain runs inline on SetResult and its + // first act is to write. + _flushing = false; + signal.SetResult(new FlushResult(isCanceled: false, isCompleted: false)); + return payload; + } + + /// Fail the oldest in-flight flush, as a torn-down transport would.
+ public void FaultFlush() + { + Assert.True(_inFlight.Count > 0, "a flush was expected to be in flight"); + (TaskCompletionSourcesignal, _) = _inFlight.Dequeue(); + _flushing = false; + signal.SetException(new IOException("transport torn down")); + } + + public override void Complete(Exception? exception = null) + { + } + + public override void CancelPendingFlush() + { + } + + private void ThrowIfFlushing() + { + if (_flushing) + { + throw new InvalidOperationException("Cannot write while flush is in progress."); + } + } + + private void EnsureScratch(int sizeHint) + { + if (_scratch.Length < sizeHint) + { + _scratch = new byte[sizeHint]; + } + } + } +} diff --git a/tests/Ioxide.Tests.Unit/Http2StreamedRequestTests.cs b/tests/Ioxide.Tests.Unit/Http2StreamedRequestTests.cs new file mode 100644 index 0000000..f9a5e72 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/Http2StreamedRequestTests.cs @@ -0,0 +1,254 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Pipelines; +using ioxide.http2; + +namespace Ioxide.Tests; + +/// +/// Streamed request bodies: the handler runs while the body is still arriving, and flow-control +/// credit goes back to the peer only as it READS. +/// +/// That last part is the entire feature, and it is the part an end-to-end test cannot see - an +/// upload succeeds either way. What differs is what bounds memory: crediting on arrival lets a peer +/// send as fast as it likes, and the bytes pile up behind a slow handler. So these tests watch the +/// WINDOW_UPDATE frames rather than the body. +///
+internal static class Http2StreamedRequestTests +{ + public static void Register(Runner runner) + { + runner.Test("h2 streamed request: credit is returned on read, not on arrival", () => + { + var gate = new TaskCompletionSource(); + var chunks = new List(); + + using var peer = new Peer(new Http2Options { StreamRequestBodies = true }); + Task run = peer.Connection.RunBufferedAsync(async request => + { + await gate.Task; // hold the body unread, like a slow consumer + + while (true) + { + ReadOnlyMemory chunk = await request.BodyReader!.ReadAsync(); + if (chunk.IsEmpty) + { + break; + } + chunks.Add(chunk.Length); + } + + return Http2Response.Text("done"); + }); + + peer.OpenRequest(streamId: 1, endStream: false); + peer.SendData(streamId: 1, bytes: 400, endStream: false); + peer.SendData(streamId: 1, bytes: 600, endStream: true); + + // The handler is parked before its first read. The bytes are in - and the peer has been + // told nothing, so it may not send more. + Assert.Equal(0, peer.CreditFor(streamId: 1)); + Assert.Equal(0, peer.CreditFor(streamId: 0)); + + gate.SetResult(); + peer.Pump(); + + // Read, and only now does the window open - on the stream AND on the connection, which + // is the part HTTP/3 does not have to do. + Assert.Equal(1000, peer.CreditFor(streamId: 1)); + Assert.Equal(1000, peer.CreditFor(streamId: 0)); + Assert.Equal(2, chunks.Count); + Assert.Equal(400, chunks[0]); + Assert.Equal(600, chunks[1]); + + peer.Close(run); + }); + + runner.Test("h2 streamed request: a request with no body reads empty at once", () => + { + bool sawEmpty = false; + + using var peer = new Peer(new Http2Options { StreamRequestBodies = true }); + Task run = peer.Connection.RunBufferedAsync(async request => + { + sawEmpty = (await request.BodyReader!.ReadAsync()).IsEmpty; + return Http2Response.Text("done"); + }); + + // END_STREAM on the HEADERS: there is no body coming, so the reader has to end rather + // than park forever waiting for a DATA frame that cannot arrive. + peer.OpenRequest(streamId: 1, endStream: true); + peer.Pump(); + + Assert.True(sawEmpty, "a bodyless request should read empty immediately"); + peer.Close(run); + }); + + runner.Test("h2 streamed request: buffered dispatch still assembles the body", () => + { + int seen = -1; + + using var peer = new Peer(new Http2Options()); // streaming OFF - the default + Task run = peer.Connection.RunBufferedAsync(request => + { + seen = request.Body.Length; + Assert.True(request.BodyReader is null, "buffered dispatch hands over no reader"); + return Http2Response.Text("done"); + }); + + peer.OpenRequest(streamId: 1, endStream: false); + peer.SendData(streamId: 1, bytes: 400, endStream: false); + peer.SendData(streamId: 1, bytes: 600, endStream: true); + peer.Pump(); + + // The other half of the trade: the whole body is in hand before the handler runs, and + // the window was credited as it arrived rather than as it was read. + Assert.Equal(1000, seen); + Assert.Equal(1000, peer.CreditFor(streamId: 1)); + + peer.Close(run); + }); + } + + /// + /// A peer driven by hand: frames in through an inline pipe, everything the server wrote back + /// captured for inspection. + ///
+ private sealed class Peer : IDuplexPipe, IDisposable + { + private readonly Pipe _input = new(new PipeOptions( + readerScheduler: PipeScheduler.Inline, + writerScheduler: PipeScheduler.Inline, + useSynchronizationContext: false)); + + private readonly CaptureWriter _output = new(); + + public Peer(Http2Options options) => Connection = new Http2Connection(this, options); + + public Http2Connection Connection { get; } + + public PipeReader Input => _input.Reader; + public PipeWriter Output => _output; + + ///Total WINDOW_UPDATE credit the server has handed back for a stream (0 = connection).
+ public int CreditFor(int streamId) + { + int total = 0; + ReadOnlySpanwire = _output.Written; + int at = 0; + + while (at + 9 <= wire.Length) + { + int length = (wire[at] << 16) | (wire[at + 1] << 8) | wire[at + 2]; + byte type = wire[at + 3]; + int stream = (int)(BinaryPrimitives.ReadUInt32BigEndian(wire[(at + 5)..]) & 0x7FFFFFFF); + + if (type == 0x8 && stream == streamId) // WINDOW_UPDATE + { + total += (int)(BinaryPrimitives.ReadUInt32BigEndian(wire[(at + 9)..]) & 0x7FFFFFFF); + } + at += 9 + length; + } + + return total; + } + + /// Preface, an empty SETTINGS, then one indexed-HPACK POST that opens a stream.
+ public void OpenRequest(int streamId, bool endStream) + { + var bytes = new List("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray()); + bytes.AddRange(Header(0, 0x4, 0, 0)); + + // 0x83 :method POST, 0x86 :scheme http, 0x84 :path / - static table only, so this + // needs no HPACK encoder of its own. + byte flags = (byte)(0x4 | (endStream ? 0x1 : 0)); + bytes.AddRange(Header(3, 0x1, flags, streamId)); + bytes.AddRange([0x83, 0x86, 0x84]); + Feed(bytes.ToArray()); + } + + public void SendData(int streamId, int bytes, bool endStream) + { + var frame = new List (Header(bytes, 0x0, (byte)(endStream ? 0x1 : 0), streamId)); + frame.AddRange(Enumerable.Repeat((byte)'z', bytes)); + Feed(frame.ToArray()); + } + + /// Let the connection loop run whatever the last feed made possible.
+ public void Pump() => Feed([]); + + public void Close(Task run) + { + _input.Writer.Complete(); + Assert.True(run.Wait(5_000), "connection wound down"); + } + + public void Dispose() => Connection.Dispose(); + + private void Feed(byte[] bytes) + { + if (bytes.Length > 0) + { + _input.Writer.WriteAsync(bytes).GetAwaiter().GetResult(); + } + else + { + _input.Writer.FlushAsync().GetAwaiter().GetResult(); + } + } + + private static byte[] Header(int length, byte type, byte flags, int streamId) => + [ + (byte)(length >> 16), (byte)(length >> 8), (byte)length, + type, flags, + (byte)(streamId >> 24), (byte)(streamId >> 16), (byte)(streamId >> 8), (byte)streamId, + ]; + } + + ///Keeps every byte the server wrote, so the test can walk the frames afterwards.
+ private sealed class CaptureWriter : PipeWriter + { + private readonly List_written = []; + private byte[] _scratch = new byte[4096]; + private int _pending; + + public ReadOnlySpan Written => System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_written); + + public override Memory GetMemory(int sizeHint = 0) + { + Grow(sizeHint); + return _scratch.AsMemory(_pending); + } + + public override Span GetSpan(int sizeHint = 0) + { + Grow(sizeHint); + return _scratch.AsSpan(_pending); + } + + public override void Advance(int bytes) => _pending += bytes; + + public override ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + _written.AddRange(_scratch.AsSpan(0, _pending)); + _pending = 0; + return new ValueTask (new FlushResult(isCanceled: false, isCompleted: false)); + } + + public override void Complete(Exception? exception = null) + { + } + + public override void CancelPendingFlush() + { + } + + private void Grow(int sizeHint) + { + if (_scratch.Length - _pending < Math.Max(sizeHint, 1)) + { + Array.Resize(ref _scratch, Math.Max(_scratch.Length * 2, _pending + Math.Max(sizeHint, 4096))); + } + } + } +} diff --git a/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj b/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj index 4cb4715..55a63eb 100644 --- a/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj +++ b/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj @@ -13,6 +13,7 @@ diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index ae158df..c80a935 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -16,6 +16,8 @@ private static int Main() MessageTests.Register(runner); ResponseAssemblyTests.Register(runner); ResponseCapTests.Register(runner); + Http2OutputQueueTests.Register(runner); + Http2StreamedRequestTests.Register(runner); return runner.Summary(); } +