diff --git a/src/Glyph3/Glyph3.csproj b/src/Glyph3/Glyph3.csproj
index 52adf65..475cfeb 100644
--- a/src/Glyph3/Glyph3.csproj
+++ b/src/Glyph3/Glyph3.csproj
@@ -12,9 +12,9 @@
13.0
Glyph3
- 0.11.652.0
- 0.11.652.0
- 0.11.652
+ 0.12.0.0
+ 0.12.0.0
+ 0.12.0
dotnet-web-stack
A transport-agnostic HTTP/3 connection for .NET: frame parsing, QPACK (static table and Huffman) and request dispatch, in pure managed C# with no native dependencies. Glyph3 does no I/O - it takes stream bytes in and hands stream bytes back - so it runs over System.Net.Quic, over io_uring, or over a pair of in-memory queues in a test. Buffered and streamed request bodies, and responses written through an IBufferWriter.
MIT
diff --git a/src/Glyph3/Qpack/Qpack.cs b/src/Glyph3/Qpack/Qpack.cs
index e0971ad..01b026e 100644
--- a/src/Glyph3/Qpack/Qpack.cs
+++ b/src/Glyph3/Qpack/Qpack.cs
@@ -3,11 +3,11 @@
namespace Glyph3;
///
-/// QPACK (RFC 9204) without the dynamic table: our SETTINGS advertise capacity 0, so a conforming
-/// peer may only send static-table references and literals - which reduces the decoder to the
-/// static table, prefixed integers, and Huffman. The encoder mirrors that: indexed :status where
-/// the table has the code, literal name references otherwise, literal names for everything else,
-/// no Huffman on output (legal, and keeps the writer trivial).
+/// QPACK (RFC 9204). The decoder resolves static references, prefixed integers and Huffman, plus
+/// dynamic references when a table is configured. The encoder prefers the static table for every
+/// field, not just :status: an entry matching both name and value costs one byte, a known name
+/// costs an index plus the literal value, and only an unknown name is spelled out. No Huffman on
+/// output - legal, and it keeps the writer trivial.
///
internal static class Qpack
{
@@ -405,7 +405,7 @@ private static byte[] EncodeStaticOnlyResponseFields(Http3Response response, out
foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in response.Headers)
{
- w += WriteLiteralHeader(buf.AsSpan(w), name.Span, value.Span);
+ w += WriteHeader(buf.AsSpan(w), name.Span, value.Span);
}
written = w;
@@ -445,6 +445,141 @@ private static int WriteStatus(Span buf, int status)
return w + dlen;
}
+
+ ///
+ /// The static table indexed for encoding: distinct names, each with the entries that share it.
+ ///
+ ///
+ /// Grouped rather than flat so a lookup compares one candidate per distinct name and rejects
+ /// most on length alone. Built once; the table is 99 fixed entries.
+ ///
+ private static readonly (byte[] Name, int NameIndex, (byte[] Value, int Index)[] Values)[] StaticNames = BuildStaticNames();
+
+ 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));
+ }
+ }
+
+ var result = new (byte[], int, (byte[], int)[])[byName.Count];
+
+ for (int i = 0; i < byName.Count; i++)
+ {
+ result[i] = (byName[i].Name, byName[i].NameIndex, byName[i].Values.ToArray());
+ }
+
+ return result;
+ }
+
+ ///
+ /// Finds a header in the static table: an exact name and value match, or failing that a name.
+ ///
+ ///
+ /// Names are matched case-insensitively, so a caller holding HTTP's conventional capitalisation
+ /// still resolves - and then never writes the name at all, which is the point. Values are
+ /// matched 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;
+
+ foreach ((byte[] candidate, int candidateIndex, (byte[] Value, int Index)[] values) in StaticNames)
+ {
+ if (candidate.Length != name.Length || !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: both name and value in the table costs a single byte; a known
+ /// name costs an index plus the literal value; anything else falls back to the full literal.
+ /// This works against every client, unlike the dynamic table, which stays unused unless the
+ /// peer advertised capacity for one.
+ ///
+ 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;
+ }
+
+ return WriteLiteralHeader(buf, name, value);
+ }
+
/// Literal With Literal Name: 001 N=0 H=0 namelen(3+), lowercased name, H=0 value(7+).
private static int WriteLiteralHeader(Span buf, ReadOnlySpan name, ReadOnlySpan value)
{
@@ -526,7 +661,7 @@ internal static byte[] EncodeResponseFields(Http3Response response, QpackEncoder
continue;
}
- w += WriteLiteralHeader(buf.AsSpan(w), nameM.Span, valueM.Span);
+ w += WriteHeader(buf.AsSpan(w), nameM.Span, valueM.Span);
}
if (count > 0)
diff --git a/tests/Glyph3.Tests/QpackStaticEncodeTests.cs b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs
new file mode 100644
index 0000000..208c748
--- /dev/null
+++ b/tests/Glyph3.Tests/QpackStaticEncodeTests.cs
@@ -0,0 +1,128 @@
+using System.Text;
+
+using Xunit;
+
+namespace Glyph3.Tests;
+
+///
+/// Response headers encoded against the static table. Checked by decoding what comes out, so an
+/// index that is off by one fails here rather than on a peer.
+///
+public class QpackStaticEncodeTests
+{
+ [Theory]
+ [InlineData("content-type", "text/plain", 53)]
+ [InlineData("content-type", "text/html; charset=utf-8", 52)]
+ [InlineData("content-type", "application/json", 46)]
+ [InlineData("accept-ranges", "bytes", 32)]
+ [InlineData("cache-control", "no-cache", 39)]
+ [InlineData("content-encoding", "gzip", 43)]
+ [InlineData("vary", "origin", 60)]
+ public void ANameAndValueBothInTheTableCostOneByte(string name, string value, int index)
+ {
+ byte[] encoded = Encode(name, value, out int written);
+
+ // Prefix is two bytes, then :status, then this header as 1 T=1 index(6+).
+ Assert.Equal((byte)(0xc0 | index), encoded[written - 1]);
+ }
+
+ [Theory]
+ [InlineData("date", "Wed, 13 Aug 2026 22:45:39 GMT")]
+ [InlineData("location", "/elsewhere")]
+ [InlineData("etag", "\"abc123\"")]
+ [InlineData("last-modified", "Wed, 13 Aug 2026 00:00:00 GMT")]
+ [InlineData("server", "GenHTTP/11.0.0.0")]
+ [InlineData("set-cookie", "a=b")]
+ [InlineData("content-type", "application/x-custom")]
+ public void AKnownNameWithAnUnknownValueReferencesTheNameOnly(string name, string value)
+ {
+ byte[] encoded = Encode(name, value, out int written);
+
+ // The name must not appear on the wire at all - that is the entire point.
+ Assert.DoesNotContain(name, Encoding.ASCII.GetString(encoded, 0, written));
+ Assert.Contains(value, Encoding.ASCII.GetString(encoded, 0, written));
+
+ AssertRoundTrips(encoded, written, name, value);
+ }
+
+ [Theory]
+ [InlineData("Content-Type", "text/plain")]
+ [InlineData("DATE", "Wed, 13 Aug 2026 22:45:39 GMT")]
+ [InlineData("Cache-Control", "no-cache")]
+ public void ACapitalisedNameStillResolves(string name, string value)
+ {
+ // Callers hold HTTP's conventional capitalisation; matching case-insensitively means the
+ // name is never written, so there is nothing to lowercase.
+ byte[] encoded = Encode(name, value, out int written);
+
+ Assert.DoesNotContain(name.ToLowerInvariant(), Encoding.ASCII.GetString(encoded, 0, written));
+
+ AssertRoundTrips(encoded, written, name.ToLowerInvariant(), value);
+ }
+
+ [Theory]
+ [InlineData("x-custom-header", "value")]
+ [InlineData("x-request-id", "abc")]
+ public void AnUnknownNameIsStillWrittenOut(string name, string value)
+ {
+ byte[] encoded = Encode(name, value, out int written);
+
+ Assert.Contains(name, Encoding.ASCII.GetString(encoded, 0, written));
+
+ AssertRoundTrips(encoded, written, name, value);
+ }
+
+ [Fact]
+ public void AValueIsMatchedCaseSensitively()
+ {
+ // Field values are case-sensitive, so TEXT/PLAIN is not entry 53 and must be written out.
+ byte[] encoded = Encode("content-type", "TEXT/PLAIN", out int written);
+
+ Assert.Contains("TEXT/PLAIN", Encoding.ASCII.GetString(encoded, 0, written));
+
+ AssertRoundTrips(encoded, written, "content-type", "TEXT/PLAIN");
+ }
+
+ [Fact]
+ public void TheStaticTableShrinksATypicalResponse()
+ {
+ var response = new Http3Response { Status = 200 };
+ Add(response, "content-type", "text/plain");
+ Add(response, "accept-ranges", "bytes");
+ Add(response, "vary", "origin");
+
+ byte[] encoded = Qpack.EncodeResponseFields(response, out int written);
+
+ // Two prefix bytes, an indexed :status, and one byte per header.
+ Assert.Equal(6, written);
+
+ Assert.True(encoded.Length >= written);
+ }
+
+ private static byte[] Encode(string name, string value, out int written)
+ {
+ var response = new Http3Response { Status = 200 };
+
+ Add(response, name, value);
+
+ return Qpack.EncodeResponseFields(response, out written);
+ }
+
+ private static void Add(Http3Response response, string name, string value)
+ => response.Headers.Add((Encoding.ASCII.GetBytes(name), Encoding.ASCII.GetBytes(value)));
+
+ 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));
+
+ // Decoded fields are ranges into an arena until this materialises them.
+ request.Freeze();
+
+ (ReadOnlyMemory Name, ReadOnlyMemory Value) field =
+ Assert.Single(request.Headers, h => Encoding.ASCII.GetString(h.Name.Span) == name);
+
+ Assert.Equal(value, Encoding.ASCII.GetString(field.Value.Span));
+ }
+}