diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj
index 060f94df..57ee02b5 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.186
+ 0.4.187
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/ioxide.httpclient.csproj b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
index 5db00d78..d96b701f 100644
--- a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
+++ b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
@@ -8,7 +8,7 @@
ioxide.httpclient
ioxide.httpclient
- 0.4.186
+ 0.4.187
MDA2AV
The ring-native HTTP/1.1 client for the ioxide io_uring runtime - the upstream leg between a proxy and an origin. Connections are opened on the reactor thread that will use them, so a request never crosses a thread on its way out or back, and every response resumes the awaiting handler inline on its own reactor. Includes client-side TLS (SNI, ALPN, certificate verification and client certificates for mutual TLS) for https:// origins. Depends on ioxide core alone: no protocol package, no native asset.
MIT
diff --git a/src/clients/ioxide.pg/ioxide.pg.csproj b/src/clients/ioxide.pg/ioxide.pg.csproj
index fe87ab44..a35bc1b2 100644
--- a/src/clients/ioxide.pg/ioxide.pg.csproj
+++ b/src/clients/ioxide.pg/ioxide.pg.csproj
@@ -8,7 +8,7 @@
ioxide.pg
ioxide.pg
- 0.4.186
+ 0.4.187
MDA2AV
Postgres driver for the ioxide io_uring runtime: pooled ring-native connections per reactor, ring-native connect and handshake, inline completion resume.
MIT
diff --git a/src/clients/ioxide.redis/ioxide.redis.csproj b/src/clients/ioxide.redis/ioxide.redis.csproj
index 219d57a4..04070ce1 100644
--- a/src/clients/ioxide.redis/ioxide.redis.csproj
+++ b/src/clients/ioxide.redis/ioxide.redis.csproj
@@ -8,7 +8,7 @@
ioxide.redis
ioxide.redis
- 0.4.186
+ 0.4.187
MDA2AV
Redis client for the ioxide io_uring runtime: pooled ring-native connections per reactor, full RESP2 protocol, a generic command API plus typed helpers (strings, keys, hashes, lists, sets, sorted sets, pub/sub, transactions, scripting), and pipelining. Inline completion resume.
MIT
diff --git a/src/ioxide/ioxide.csproj b/src/ioxide/ioxide.csproj
index d8c9819b..835ae532 100644
--- a/src/ioxide/ioxide.csproj
+++ b/src/ioxide/ioxide.csproj
@@ -8,7 +8,7 @@
ioxide
ioxide
- 0.4.186
+ 0.4.187
MDA2AV
A shared-nothing io_uring runtime for .NET: one ring per reactor thread, inline completions, zero native dependencies. The engine - reactor, connection, and the IRingHost client seam. Includes TLS termination: the OpenSSL handshake driven over the ring, then kernel TLS (kTLS) transmit offload, so handlers keep writing plaintext. TLS needs OpenSSL 3 and the Linux tls module; nothing else does, and neither is loaded unless you use it.
MIT
diff --git a/src/protocols/ioxide.http2/ioxide.http2.csproj b/src/protocols/ioxide.http2/ioxide.http2.csproj
index d6aa827b..d4781400 100644
--- a/src/protocols/ioxide.http2/ioxide.http2.csproj
+++ b/src/protocols/ioxide.http2/ioxide.http2.csproj
@@ -8,7 +8,7 @@
ioxide.http2
ioxide.http2
- 0.4.186
+ 0.4.187
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. Serves h2c with prior knowledge and h2 over TLS by ALPN, buffered or streamed in either direction.
MIT
diff --git a/src/protocols/ioxide.http3/Qpack.cs b/src/protocols/ioxide.http3/Qpack.cs
index f8a1a7be..902646a7 100644
--- a/src/protocols/ioxide.http3/Qpack.cs
+++ b/src/protocols/ioxide.http3/Qpack.cs
@@ -217,6 +217,176 @@ private static readonly (int Status, byte Index)[] IndexedStatuses =
];
/// Encode a response's field section (prefix + :status + headers) into a pooled buffer.
+ ///
+ /// The static table indexed for encoding: distinct names, each with the entries sharing it.
+ ///
+ ///
+ /// Grouped rather than flat so a lookup rejects most candidates on length alone. Built once from
+ /// the 99 fixed entries.
+ ///
+ private static readonly (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[][] StaticByLength = BuildStaticNames();
+
+ /// Longest field name in the static table, so a longer name skips the lookup entirely.
+ private static readonly int LongestStaticName = StaticByLength.Length - 1;
+
+ private static (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[][] BuildStaticNames()
+ {
+ var byName = new List<(byte[] Name, int NameIndex, List<(byte[] Value, int Index)> Values)>();
+
+ for (int i = 0; i < QpackStatic.Table.Length; i++)
+ {
+ (byte[] name, byte[] value) = QpackStatic.Table[i];
+
+ int at = -1;
+ for (int j = 0; j < byName.Count; j++)
+ {
+ if (byName[j].Name.AsSpan().SequenceEqual(name))
+ {
+ at = j;
+ break;
+ }
+ }
+
+ if (at < 0)
+ {
+ byName.Add((name, i, [(value, i)]));
+ }
+ else
+ {
+ byName[at].Values.Add((value, i));
+ }
+ }
+
+ // Bucketed by name length. A per-header linear scan of every distinct name is far too
+ // expensive at this request rate - it cost about 6% of throughput - and length alone
+ // narrows 50-odd candidates down to one or two.
+ int longest = 0;
+
+ foreach ((byte[] name, int _, List<(byte[] Value, int Index)> _) in byName)
+ {
+ longest = Math.Max(longest, name.Length);
+ }
+
+ var buckets = new List<(byte[], int, (byte[], int)[])>[longest + 1];
+
+ foreach ((byte[] name, int nameIndex, List<(byte[] Value, int Index)> values) in byName)
+ {
+ buckets[name.Length] ??= [];
+ buckets[name.Length].Add((name, nameIndex, values.ToArray()));
+ }
+
+ var result = new (byte[], int, (byte[], int)[])[longest + 1][];
+
+ for (int i = 0; i <= longest; i++)
+ {
+ result[i] = buckets[i]?.ToArray() ?? [];
+ }
+
+ return result;
+ }
+
+ ///
+ /// Finds a header in the static table: an exact name and value match, or failing that a name.
+ ///
+ ///
+ /// Names match case-insensitively, so a caller holding HTTP's conventional capitalisation still
+ /// resolves - and then never writes the name at all. Values match exactly, because a field value
+ /// is case-sensitive.
+ ///
+ private static bool TryFindStatic(ReadOnlySpan name, ReadOnlySpan value, out int exact, out int nameIndex)
+ {
+ exact = -1;
+ nameIndex = -1;
+
+ if (name.Length > LongestStaticName)
+ {
+ return false;
+ }
+
+ foreach ((byte[] candidate, int candidateIndex, (byte[] Value, int Index)[] values) in StaticByLength[name.Length])
+ {
+ if (!EqualsIgnoreCase(candidate, name))
+ {
+ continue;
+ }
+
+ nameIndex = candidateIndex;
+
+ foreach ((byte[] entryValue, int entryIndex) in values)
+ {
+ if (entryValue.AsSpan().SequenceEqual(value))
+ {
+ exact = entryIndex;
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool EqualsIgnoreCase(ReadOnlySpan lowercase, ReadOnlySpan other)
+ {
+ for (int i = 0; i < lowercase.Length; i++)
+ {
+ byte c = other[i];
+
+ if (c is >= (byte)'A' and <= (byte)'Z')
+ {
+ c |= 0x20;
+ }
+
+ if (c != lowercase[i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Writes one header, preferring the static table over spelling the name out.
+ ///
+ ///
+ /// Three tiers, cheapest first: name and value both in the table cost a single byte; a known
+ /// name costs an index plus the literal value; anything else falls back to the full literal.
+ /// Unlike the dynamic table this needs nothing from the peer, so it applies to every client.
+ ///
+ private static int WriteHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value)
+ {
+ if (TryFindStatic(name, value, out int exact, out int nameIndex))
+ {
+ if (exact >= 0)
+ {
+ // Indexed Field Line: 1 T=1 index(6+).
+ return WriteInt(buf, 0xC0, 6, exact);
+ }
+
+ // Literal With Name Reference: 01 N=0 T=1 nameindex(4+), then H=0 value(7+).
+ int written = WriteInt(buf, 0x50, 4, nameIndex);
+ written += WriteInt(buf[written..], 0x00, 7, value.Length);
+ value.CopyTo(buf[written..]);
+
+ return written + value.Length;
+ }
+
+ // Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+).
+ int w = WriteInt(buf, 0x20, 3, name.Length);
+
+ foreach (byte b in name)
+ {
+ buf[w++] = b is >= (byte)'A' and <= (byte)'Z' ? (byte)(b | 0x20) : b;
+ }
+
+ w += WriteInt(buf[w..], 0x00, 7, value.Length);
+ value.CopyTo(buf[w..]);
+
+ return w + value.Length;
+ }
+
public static byte[] EncodeResponseFields(Http3Response response, out int written)
{
int cap = 2 + 8;
@@ -258,16 +428,7 @@ public static byte[] EncodeResponseFields(Http3Response response, out int writte
foreach ((ReadOnlyMemory nameM, ReadOnlyMemory valueM) in response.Headers)
{
- // Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+).
- ReadOnlySpan name = nameM.Span;
- w += WriteInt(buf.AsSpan(w), 0x20, 3, name.Length);
- foreach (byte b in name)
- {
- buf[w++] = b is >= (byte)'A' and <= (byte)'Z' ? (byte)(b | 0x20) : b;
- }
- w += WriteInt(buf.AsSpan(w), 0x00, 7, valueM.Length);
- valueM.Span.CopyTo(buf.AsSpan(w));
- w += valueM.Length;
+ w += WriteHeader(buf.AsSpan(w), nameM.Span, valueM.Span);
}
written = w;
diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj
index 51df4233..e00b5828 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.186
+ 0.4.187
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
@@ -24,4 +24,11 @@
+
+
+
+
+
diff --git a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj
index 7179437f..43ee29ca 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.186
+ 0.4.187
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. 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 86ad994d..4d015a96 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.186
+ 0.4.187
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 1be34637..2f182a1e 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.186
+ 0.4.187
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 b55d1287..4aa1af96 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.186
+ 0.4.187
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.Unit/Ioxide.Tests.Unit.csproj b/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj
index 55a63eb8..5a8ef679 100644
--- a/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj
+++ b/tests/Ioxide.Tests.Unit/Ioxide.Tests.Unit.csproj
@@ -14,6 +14,7 @@
+
diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs
index c63f6e31..617b21bd 100644
--- a/tests/Ioxide.Tests.Unit/Program.cs
+++ b/tests/Ioxide.Tests.Unit/Program.cs
@@ -16,6 +16,7 @@ private static int Main()
ResponseCapTests.Register(runner);
Http2OutputQueueTests.Register(runner);
Http2StreamedRequestTests.Register(runner);
+ QpackStaticEncodeTests.Register(runner);
return runner.Summary();
}
diff --git a/tests/Ioxide.Tests.Unit/QpackStaticEncodeTests.cs b/tests/Ioxide.Tests.Unit/QpackStaticEncodeTests.cs
new file mode 100644
index 00000000..4df3632a
--- /dev/null
+++ b/tests/Ioxide.Tests.Unit/QpackStaticEncodeTests.cs
@@ -0,0 +1,169 @@
+using System.Text;
+
+using ioxide.http3;
+
+namespace Ioxide.Tests;
+
+///
+/// Response headers encoded against the QPACK static table. The encoder previously used the table
+/// for :status alone and spelled every other field name out, so a response repeated names the peer
+/// already had by index.
+///
+/// Checked by decoding what comes out rather than by asserting on bytes: an index that is off by one
+/// still produces a well-formed field section, and only a round trip catches that it names the wrong
+/// header.
+///
+internal static class QpackStaticEncodeTests
+{
+ public static void Register(Runner runner)
+ {
+ runner.Test("qpack: a name and value both in the table cost one byte", () =>
+ {
+ (string Name, string Value, int Index)[] cases =
+ [
+ ("content-type", "text/plain", 53),
+ ("content-type", "text/html; charset=utf-8", 52),
+ ("content-type", "application/json", 46),
+ ("accept-ranges", "bytes", 32),
+ ("cache-control", "no-cache", 39),
+ ("content-encoding", "gzip", 43),
+ ("vary", "origin", 60),
+ ];
+
+ foreach ((string name, string value, int index) in cases)
+ {
+ byte[] encoded = Encode(out int written, (name, value));
+
+ // Indexed Field Line: 1 T=1 index(6+), the whole header in one byte.
+ Assert.Equal((byte)(0xc0 | index), encoded[written - 1]);
+ }
+ });
+
+ runner.Test("qpack: a known name with an unknown value references the name only", () =>
+ {
+ (string Name, string Value)[] cases =
+ [
+ ("date", "Thu, 14 Aug 2026 13:00:00 GMT"),
+ ("location", "/elsewhere"),
+ ("etag", "\"abc123\""),
+ ("last-modified", "Thu, 14 Aug 2026 00:00:00 GMT"),
+ ("server", "ioxide"),
+ ("set-cookie", "a=b"),
+ ("content-type", "application/x-custom"),
+ ];
+
+ foreach ((string name, string value) in cases)
+ {
+ byte[] encoded = Encode(out int written, (name, value));
+ string wire = Encoding.ASCII.GetString(encoded, 0, written);
+
+ // The name must not reach the wire at all - that is the entire point.
+ Assert.True(!wire.Contains(name), $"name {name} should not appear on the wire");
+ Assert.True(wire.Contains(value), $"value {value} should appear on the wire");
+
+ AssertRoundTrips(encoded, written, name, value);
+ }
+ });
+
+ runner.Test("qpack: a capitalised name still resolves against the table", () =>
+ {
+ (string Name, string Value)[] cases =
+ [
+ ("Content-Type", "text/plain"),
+ ("DATE", "Thu, 14 Aug 2026 13:00:00 GMT"),
+ ("Cache-Control", "no-cache"),
+ ];
+
+ foreach ((string name, string value) in cases)
+ {
+ byte[] encoded = Encode(out int written, (name, value));
+ string wire = Encoding.ASCII.GetString(encoded, 0, written);
+
+ Assert.True(!wire.Contains(name.ToLowerInvariant()), $"{name} should resolve, not be written");
+
+ AssertRoundTrips(encoded, written, name.ToLowerInvariant(), value);
+ }
+ });
+
+ runner.Test("qpack: a name outside the table is written out, lowercased", () =>
+ {
+ // HTTP/3 treats a capitalised field name as malformed, so the literal fallback has to
+ // lowercase whatever it writes.
+ (string Name, string Value)[] cases =
+ [
+ ("x-custom-header", "value"),
+ ("X-Custom-Header", "value"),
+ ("X-Request-ID", "r1"),
+ ];
+
+ foreach ((string name, string value) in cases)
+ {
+ byte[] encoded = Encode(out int written, (name, value));
+ string wire = Encoding.ASCII.GetString(encoded, 0, written);
+
+ Assert.True(wire.Contains(name.ToLowerInvariant()), $"{name} should be written lowercased");
+
+ AssertRoundTrips(encoded, written, name.ToLowerInvariant(), value);
+ }
+ });
+
+ runner.Test("qpack: a value is matched case-sensitively", () =>
+ {
+ // Field values are case-sensitive, so TEXT/PLAIN is not entry 53.
+ byte[] encoded = Encode(out int written, ("content-type", "TEXT/PLAIN"));
+
+ Assert.True(Encoding.ASCII.GetString(encoded, 0, written).Contains("TEXT/PLAIN"),
+ "a value differing only in case must not resolve to a static entry");
+
+ AssertRoundTrips(encoded, written, "content-type", "TEXT/PLAIN");
+ });
+
+ runner.Test("qpack: a response ioxide actually sends shrinks", () =>
+ {
+ // What Http3Response.Text produces today. The value carries a space after the semicolon,
+ // so it misses entry 54 ("text/plain;charset=utf-8") and takes the name reference.
+ byte[] encoded = Encode(out int written, ("content-type", "text/plain; charset=utf-8"));
+
+ // Prefix 2 + indexed :status 1 + name ref 2 + value length 1 + 25 value bytes. The name
+ // reference costs two bytes rather than one because content-type is index 44, past what
+ // the 4-bit prefix holds.
+ Assert.Equal(31, written);
+
+ // Spelling the name out instead costs 2 + 12 for it, so 43 in total.
+ Assert.True(written < 43, "the name should no longer be on the wire");
+ });
+ }
+
+ private static byte[] Encode(out int written, params (string Name, string Value)[] headers)
+ {
+ var response = new Http3Response { Status = 200 };
+
+ foreach ((string name, string value) in headers)
+ {
+ response.Headers.Add((Encoding.ASCII.GetBytes(name), Encoding.ASCII.GetBytes(value)));
+ }
+
+ return Qpack.EncodeResponseFields(response, out written);
+ }
+
+ private static void AssertRoundTrips(byte[] encoded, int written, string name, string value)
+ {
+ var request = new Http3Request();
+
+ Assert.True(Qpack.TryDecodeFieldSection(encoded.AsSpan(0, written), request), "the section should decode");
+
+ // Decoded fields are ranges into an arena until this materialises them.
+ request.Freeze();
+
+ foreach ((ReadOnlyMemory Name, ReadOnlyMemory Value) field in request.Headers)
+ {
+ if (Encoding.ASCII.GetString(field.Name.Span) == name)
+ {
+ Assert.Equal(value, Encoding.ASCII.GetString(field.Value.Span));
+ return;
+ }
+ }
+
+ Assert.True(false, $"decoded section did not contain {name}");
+ }
+}