diff --git a/AGENTS.md b/AGENTS.md
index 736d8a7e..92f4c0c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -277,14 +277,14 @@ libpng/libjpeg/libwebp on Linux. It is also the only engine project with
`AllowUnsafeBlocks=true`. Every other engine module under
`src/Starling.{Common,Url,Net,Html,Dom,Css,Layout,Paint,Js,Bindings,Mcp,Telemetry,Engine}/`
stays **pure managed** — no P/Invoke, no native dependencies beyond what the
-.NET BCL ships. **TLS path: BouncyCastle.** `Starling.Net` uses
-`BouncyCastle.Cryptography` (pure-managed, no P/Invoke) for TLS 1.3 via
-`BcTlsTransport`. The `wp:M3-06e` SslStream migration was rolled back in
-`939f3a5 fix ssl crash` (2026-05-14) after a macOS TLS 1.3 issue surfaced in
-integration; re-attempting SslStream — or formally re-blessing BouncyCastle as
-the long-term path — is a tracked open item in `wp:M3-06-native-interop-pivot`'s
-handoff log. The interop-seam policy is still satisfied either way, because
-BouncyCastle adds no native dependency. CI greps the engine-project allowlist
+.NET BCL ships. **One carve-out: `Starling.Net`.** As of 2026-07-07 it runs on
+`System.Net.Http.HttpClient` over `SocketsHttpHandler` (transport, TLS, HTTP/1.1,
+HTTP/2), which reaches native crypto through the BCL. The BouncyCastle TLS client
+was deleted. `Starling.Net` still writes no P/Invoke of its own, so it stays off
+the interop grep, but it is no longer strictly pure-managed at runtime — the
+"managed-first" rule now means "no P/Invoke in our code," not "no native code
+anywhere below us." Cert trust stays ours: `HttpClient` chains to the bundled
+CCADB root store via a custom `ConnectCallback`, not the OS store. CI greps the engine-project allowlist
(every engine project *except* the Codecs interop project); the lint job fails
if you regress it. The GUI shell (`src/Starling.Gui`, Avalonia 12) and the Aspire
AppHost/ServiceDefaults projects are exempt — they link against Avalonia desktop
diff --git a/bench/Starling.Bench/H1ResponseBench.cs b/bench/Starling.Bench/H1ResponseBench.cs
deleted file mode 100644
index d3119173..00000000
--- a/bench/Starling.Bench/H1ResponseBench.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using System.Text;
-using BenchmarkDotNet.Attributes;
-using Starling.Net.Http.H1;
-
-namespace Starling.Bench;
-
-// HTTP/1.1 response parsing — every fetch path hits this. The keep-alive
-// connection pool (wp:M2-07c) calls `H1ResponseParser` per response, so any
-// regression here multiplies across the subresource graph. Body is read off
-// an in-memory `MemoryStream`, so the bench is socket-free.
-[MemoryDiagnoser]
-public class H1ResponseBench
-{
- private byte[] _smallBody = null!; // Content-Length, ~1 KB
- private byte[] _largeBody = null!; // Content-Length, ~64 KB
- private byte[] _chunkedBody = null!; // chunked, ~16 KB across 8 chunks
-
- [GlobalSetup]
- public void Setup()
- {
- _smallBody = BuildContentLengthResponse(payloadBytes: 1024);
- _largeBody = BuildContentLengthResponse(payloadBytes: 64 * 1024);
- _chunkedBody = BuildChunkedResponse(chunks: 8, perChunk: 2048);
- }
-
- [Benchmark]
- public int ContentLength_1KB()
- {
- var stream = new MemoryStream(_smallBody);
- var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult();
- return result.Value.Body.Length;
- }
-
- [Benchmark]
- public int ContentLength_64KB()
- {
- var stream = new MemoryStream(_largeBody);
- var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult();
- return result.Value.Body.Length;
- }
-
- [Benchmark]
- public int Chunked_16KB_8Chunks()
- {
- var stream = new MemoryStream(_chunkedBody);
- var result = new H1ResponseParser().ParseAsync(stream, default).GetAwaiter().GetResult();
- return result.Value.Body.Length;
- }
-
- private static byte[] BuildContentLengthResponse(int payloadBytes)
- {
- var head = $"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {payloadBytes}\r\nConnection: keep-alive\r\n\r\n";
- var bytes = new byte[Encoding.ASCII.GetByteCount(head) + payloadBytes];
- var off = Encoding.ASCII.GetBytes(head, bytes);
- for (var i = 0; i < payloadBytes; i++)
- {
- bytes[off + i] = (byte)('a' + (i % 26));
- }
-
- return bytes;
- }
-
- private static byte[] BuildChunkedResponse(int chunks, int perChunk)
- {
- var sb = new StringBuilder();
- sb.Append("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n");
- for (var c = 0; c < chunks; c++)
- {
- sb.Append(perChunk.ToString("X", System.Globalization.CultureInfo.InvariantCulture));
- sb.Append("\r\n");
- for (var i = 0; i < perChunk; i++)
- {
- sb.Append((char)('a' + (i % 26)));
- }
-
- sb.Append("\r\n");
- }
- sb.Append("0\r\n\r\n");
- return Encoding.ASCII.GetBytes(sb.ToString());
- }
-}
diff --git a/browser-plan/00_INDEX.md b/browser-plan/00_INDEX.md
index d7d58477..14fc2f41 100644
--- a/browser-plan/00_INDEX.md
+++ b/browser-plan/00_INDEX.md
@@ -27,7 +27,7 @@
| UI | Avalonia 12 (stable 12.0.x, released Apr 2026; targets .NET 10 directly; .NET 8+ only) | user |
| Rasterization | `SixLabors.ImageSharp` 3.x + `SixLabors.ImageSharp.Drawing` 2.x + `SixLabors.Fonts` 2.x | user |
| JS engine | The Starling JS engine, written from scratch in C#. No third-party JS engine dependencies. | user |
-| Networking | Hand-written from `System.Net.Sockets` up. No `HttpClient`, no `SslStream`. | user |
+| Networking | `System.Net.Http.HttpClient` over a configured `SocketsHttpHandler`, wrapped by the `StarlingHttpClient` facade. Browser policy (redirects, cookies, cert trust) stays in Starling; the transport (HTTP/1.1, HTTP/2, TLS) is the BCL's. Reversed the earlier hand-rolled-from-`Sockets` decision on 2026-07-07; see `03_NETWORKING.md`. | user |
| Process model | Single-process for v1. Ladybird-style multi-process sandboxing deferred to v2. | this plan |
| Cross-platform | Windows + macOS + Linux from day one. No platform branches without an `OPEN QUESTION`. | user |
| Threading | Single-threaded UI + event loop. Worker pools for parsing/networking/JS. Details in `01_ARCHITECTURE.md`. | this plan |
diff --git a/browser-plan/03_NETWORKING.md b/browser-plan/03_NETWORKING.md
index b923bb72..1e2ab1c3 100644
--- a/browser-plan/03_NETWORKING.md
+++ b/browser-plan/03_NETWORKING.md
@@ -1,5 +1,21 @@
# 03 — Networking
+> **Status (2026-07-07): switched to `HttpClient`.** The hand-rolled transport —
+> UDP DNS, the raw `Socket` dialer, the BouncyCastle TLS client, the HTTP/1.1
+> parser, the HTTP/2 + HPACK stack, the connection pool, and the body decoders —
+> was deleted. `StarlingHttpClient` is now a thin browser-policy wrapper over
+> `System.Net.Http.HttpClient` on a configured `SocketsHttpHandler`. This
+> reverses the "no `HttpClient`, no `SslStream`" rule that the rest of this doc
+> was written under. What Starling still owns: redirects (the engine follows
+> them; `AllowAutoRedirect` is off), cookies (our `CookieJar`, not
+> `CookieContainer`; `UseCookies` is off), and cert trust (the bundled CCADB
+> root store via a `ConnectCallback` that also captures the leaf for the lock
+> UI). What the BCL now owns: TCP, TLS, HTTP/1.1, HTTP/2, decompression, and
+> pooling — plus native crypto under the hood, which is why the engine is no
+> longer strictly pure-managed (see `AGENTS.md` interop policy). The sections
+> below describe the retired design and are kept for history; trust this banner
+> where they disagree.
+
## Scope
**In:** URL parsing, DNS, TCP, TLS 1.3 (via `SslStream`), HTTP/1.1, HTTP/2 + HPACK, cookies, content decoding (gzip/brotli/deflate), HTTP cache, fetch primitives. Public seam for the engine.
@@ -42,11 +58,13 @@ its clean bill on the CI grep.
> `wp:M3-06-native-interop-pivot`'s handoff log. See `AGENTS.md` §"Interop
> policy" for the current authoritative statement.
-**No `HttpClient`.** We do not use `System.Net.Http.HttpClient` — the HTTP/1.1
-stack (and the planned HTTP/2 stack) is hand-rolled. That is the whole point of
-this doc: the engine owns connection pooling, cookies, caching, redirects, and
-cert trust, none of which `HttpClient` lets us control to browser spec. The ban
-on `HttpClient` is unchanged.
+**`HttpClient` (as of 2026-07-07).** Superseded by the status banner at the top.
+We now use `System.Net.Http.HttpClient` over `SocketsHttpHandler`. The pieces a
+browser must control — redirects, cookies, and cert trust — are kept above the
+transport by turning off the handler's automatic redirect and cookie handling
+and by validating certificates against the bundled root store in a custom
+`ConnectCallback`. Everything else (pooling, HTTP/1.1, HTTP/2, decompression) is
+the handler's job.
What we *do* use:
- `System.Net.Sockets.Socket` (raw TCP / UDP, fully managed).
@@ -470,5 +488,5 @@ Used by [10_WEB_APIS.md#fetch](10_WEB_APIS.md#fetch).
- [ ] Gzip and Brotli-encoded bodies decode byte-identical to non-encoded servers.
- [ ] Connection pool reuses a TCP connection across two sequential HTTPS requests to the same origin.
- [ ] All of the above pass on Windows, macOS, Linux in CI.
-- [ ] `grep -rn 'System.Net.Http\|HttpClient' src/Starling.Net/` is empty (the `HttpClient` ban stands; `SslStream` is now the sanctioned TLS path).
-- [ ] `grep -rn 'DllImport\|LibraryImport' src/Starling.Net/` is empty — `Starling.Net` is not a designated interop project.
+- [ ] `Starling.Net` builds against `System.Net.Http.HttpClient` — the earlier `HttpClient` ban is lifted (see the status banner). TLS is the BCL's (`SslStream` under `SocketsHttpHandler`), not BouncyCastle.
+- [ ] `grep -rn 'DllImport\|LibraryImport' src/Starling.Net/` is still empty — `Starling.Net` writes no P/Invoke itself, even though the `HttpClient` it now calls uses native crypto internally.
diff --git a/src/Starling.Net/Dns/DnsCache.cs b/src/Starling.Net/Dns/DnsCache.cs
deleted file mode 100644
index 4ef5e9b9..00000000
--- a/src/Starling.Net/Dns/DnsCache.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-namespace Starling.Net.Dns;
-
-///
-/// TTL-aware LRU cache for DNS lookup results. Thread-safe via a single
-/// internal lock; reads and writes are O(1) on average.
-///
-public sealed class DnsCache
-{
- private readonly int _maxEntries;
- private readonly Dictionary _entries;
- private readonly LinkedList _lru = new();
- private readonly object _gate = new();
- private readonly Func _now;
-
- public DnsCache(int maxEntries = 256, Func? now = null)
- {
- if (maxEntries < 1)
- {
- throw new ArgumentOutOfRangeException(nameof(maxEntries));
- }
-
- _maxEntries = maxEntries;
- _entries = new(StringComparer.OrdinalIgnoreCase);
- _now = now ?? (() => DateTimeOffset.UtcNow);
- }
-
- public bool TryGet(string hostname, out DnsResult result)
- {
- lock (_gate)
- {
- if (_entries.TryGetValue(hostname, out var entry) && entry.ExpiresAt > _now())
- {
- _lru.Remove(entry.Node);
- _lru.AddFirst(entry.Node);
- result = entry.Result;
- return true;
- }
- // Expired or absent.
- if (_entries.Remove(hostname, out var stale))
- {
- _lru.Remove(stale.Node);
- }
-
- result = default!;
- return false;
- }
- }
-
- public void Put(string hostname, DnsResult result)
- {
- lock (_gate)
- {
- if (_entries.TryGetValue(hostname, out var existing))
- {
- _lru.Remove(existing.Node);
- }
- var node = _lru.AddFirst(hostname);
- _entries[hostname] = new Entry(result, _now() + result.Ttl, node);
- while (_entries.Count > _maxEntries)
- {
- var oldest = _lru.Last;
- if (oldest is null)
- {
- break;
- }
-
- _entries.Remove(oldest.Value);
- _lru.RemoveLast();
- }
- }
- }
-
- public int Count
- {
- get
- {
- lock (_gate)
- {
- return _entries.Count;
- }
- }
- }
-
- private readonly record struct Entry(
- DnsResult Result, DateTimeOffset ExpiresAt, LinkedListNode Node);
-}
diff --git a/src/Starling.Net/Dns/DnsMessage.cs b/src/Starling.Net/Dns/DnsMessage.cs
deleted file mode 100644
index d5fcbf4f..00000000
--- a/src/Starling.Net/Dns/DnsMessage.cs
+++ /dev/null
@@ -1,284 +0,0 @@
-using System.Buffers.Binary;
-using System.Text;
-
-namespace Starling.Net.Dns;
-
-///
-/// DNS message wire format per RFC 1035 §4.1. Encoder + decoder.
-///
-///
-/// Implemented as a static utility so the resolver, the cache, and the test
-/// suite can all manipulate raw packets without dragging in the resolver's
-/// async / socket dependencies. The decoder is forgiving: malformed records
-/// trigger rather than producing partial state.
-///
-public static class DnsMessage
-{
- public enum QType : ushort { A = 1, NS = 2, CNAME = 5, AAAA = 28 }
- public enum QClass : ushort { IN = 1 }
-
- public enum RCode : byte
- {
- NoError = 0,
- FormatError = 1,
- ServerFailure = 2,
- NameError = 3, // NXDOMAIN
- NotImplemented = 4,
- Refused = 5,
- }
-
- public readonly record struct Header(
- ushort Id, bool Qr, byte Opcode, bool Aa, bool Tc, bool Rd, bool Ra,
- RCode Rcode, ushort QdCount, ushort AnCount, ushort NsCount, ushort ArCount);
-
- public readonly record struct Question(string Name, QType Type, QClass Class);
-
- public abstract record Answer(string Name, QType Type, QClass Class, uint Ttl);
- public sealed record AAnswer(string Name, QType Type, QClass Class, uint Ttl,
- byte[] IPv4) : Answer(Name, Type, Class, Ttl);
- public sealed record AaaaAnswer(string Name, QType Type, QClass Class, uint Ttl,
- byte[] IPv6) : Answer(Name, Type, Class, Ttl);
- public sealed record CNameAnswer(string Name, QType Type, QClass Class, uint Ttl,
- string Target) : Answer(Name, Type, Class, Ttl);
- public sealed record OtherAnswer(string Name, QType Type, QClass Class, uint Ttl,
- byte[] RData) : Answer(Name, Type, Class, Ttl);
-
- // -----------------------------------------------------------------------
- // Encoder
- // -----------------------------------------------------------------------
-
- ///
- /// Build a standard recursion-desired query for a single (name, qtype, qclass).
- ///
- public static byte[] BuildQuery(ushort id, string name, QType qtype, QClass qclass = QClass.IN)
- {
- var nameBytes = EncodeName(name);
- var len = 12 + nameBytes.Length + 4;
- var buf = new byte[len];
-
- // Header — id, flags, counts.
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), id);
- // Flags: standard query, RD=1.
- buf[2] = 0b0000_0001;
- buf[3] = 0;
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 1); // QDCOUNT
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 0); // ANCOUNT
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(8, 2), 0); // NSCOUNT
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(10, 2), 0); // ARCOUNT
-
- nameBytes.CopyTo(buf, 12);
- var off = 12 + nameBytes.Length;
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(off, 2), (ushort)qtype);
- BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(off + 2, 2), (ushort)qclass);
- return buf;
- }
-
- ///
- /// Encode a domain name into the labels-with-length-bytes form. The trailing
- /// zero-length root label is included.
- ///
- public static byte[] EncodeName(string name)
- {
- if (string.IsNullOrEmpty(name))
- {
- return [0];
- }
- // Strip trailing dot for consistency.
- if (name[^1] == '.')
- {
- name = name[..^1];
- }
-
- var labels = name.Split('.');
- var totalLen = 1; // trailing root null
- foreach (var label in labels)
- {
- if (label.Length == 0)
- {
- throw new FormatException("Empty label in DNS name.");
- }
-
- if (label.Length > 63)
- {
- throw new FormatException($"Label '{label}' exceeds 63 chars.");
- }
-
- totalLen += 1 + label.Length;
- }
- if (totalLen > 255)
- {
- throw new FormatException($"Encoded name '{name}' exceeds 255 bytes.");
- }
-
- var buf = new byte[totalLen];
- var o = 0;
- foreach (var label in labels)
- {
- buf[o++] = (byte)label.Length;
- foreach (var ch in label)
- {
- if (ch >= 0x80)
- {
- throw new FormatException(
- "Non-ASCII labels require IDNA Punycode conversion, which is not implemented yet.");
- }
-
- buf[o++] = (byte)ch;
- }
- }
- buf[o] = 0; // root
- return buf;
- }
-
- // -----------------------------------------------------------------------
- // Decoder
- // -----------------------------------------------------------------------
-
- ///
- /// Parse a full DNS response into header + question + answer sections.
- ///
- public static (Header Header, List Questions, List Answers)
- Parse(ReadOnlySpan packet)
- {
- if (packet.Length < 12)
- {
- throw new FormatException("Packet shorter than DNS header.");
- }
-
- var id = BinaryPrimitives.ReadUInt16BigEndian(packet[..2]);
- var f1 = packet[2];
- var f2 = packet[3];
- var header = new Header(
- Id: id,
- Qr: (f1 & 0x80) != 0,
- Opcode: (byte)((f1 >> 3) & 0xF),
- Aa: (f1 & 0x04) != 0,
- Tc: (f1 & 0x02) != 0,
- Rd: (f1 & 0x01) != 0,
- Ra: (f2 & 0x80) != 0,
- Rcode: (RCode)(f2 & 0xF),
- QdCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(4, 2)),
- AnCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(6, 2)),
- NsCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(8, 2)),
- ArCount: BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(10, 2)));
-
- var off = 12;
- var questions = new List(header.QdCount);
- for (var i = 0; i < header.QdCount; i++)
- {
- var (qname, qoff) = DecodeName(packet, off);
- off = qoff;
- if (off + 4 > packet.Length)
- {
- throw new FormatException("Truncated question.");
- }
-
- var qtype = (QType)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off, 2));
- var qclass = (QClass)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 2, 2));
- off += 4;
- questions.Add(new Question(qname, qtype, qclass));
- }
-
- var answers = new List(header.AnCount);
- for (var i = 0; i < header.AnCount; i++)
- {
- var (aname, anoff) = DecodeName(packet, off);
- off = anoff;
- if (off + 10 > packet.Length)
- {
- throw new FormatException("Truncated answer.");
- }
-
- var atype = (QType)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off, 2));
- var aclass = (QClass)BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 2, 2));
- var ttl = BinaryPrimitives.ReadUInt32BigEndian(packet.Slice(off + 4, 4));
- var rdlen = BinaryPrimitives.ReadUInt16BigEndian(packet.Slice(off + 8, 2));
- off += 10;
- if (off + rdlen > packet.Length)
- {
- throw new FormatException("Truncated rdata.");
- }
-
- Answer ans = atype switch
- {
- QType.A when rdlen == 4 =>
- new AAnswer(aname, atype, aclass, ttl, packet.Slice(off, 4).ToArray()),
- QType.AAAA when rdlen == 16 =>
- new AaaaAnswer(aname, atype, aclass, ttl, packet.Slice(off, 16).ToArray()),
- QType.CNAME =>
- new CNameAnswer(aname, atype, aclass, ttl, DecodeName(packet, off).Name),
- _ =>
- new OtherAnswer(aname, atype, aclass, ttl, packet.Slice(off, rdlen).ToArray()),
- };
- answers.Add(ans);
- off += rdlen;
- }
-
- return (header, questions, answers);
- }
-
- ///
- /// Decode a (possibly compressed) name starting at .
- /// Returns the dotted name plus the offset immediately after the name.
- /// Follows compression pointers per RFC 1035 §4.1.4.
- ///
- public static (string Name, int NextOffset) DecodeName(
- ReadOnlySpan packet, int start)
- {
- var sb = new StringBuilder();
- var off = start;
- int? endOffset = null;
- var hops = 0;
- while (off < packet.Length)
- {
- var lenByte = packet[off];
- if (lenByte == 0)
- {
- off++;
- endOffset ??= off;
- return (sb.ToString().TrimEnd('.'), endOffset.Value);
- }
- if ((lenByte & 0xC0) == 0xC0)
- {
- // Pointer: high 2 bits set; next 14 bits = offset.
- if (off + 1 >= packet.Length)
- {
- throw new FormatException("Truncated pointer.");
- }
-
- var ptr = ((lenByte & 0x3F) << 8) | packet[off + 1];
- if (++hops > 32)
- {
- throw new FormatException("DNS name compression loop.");
- }
-
- endOffset ??= off + 2;
- off = ptr;
- continue;
- }
- if ((lenByte & 0xC0) != 0)
- {
- throw new FormatException($"Reserved label type 0x{lenByte:X2}.");
- }
-
- off++;
- if (off + lenByte > packet.Length)
- {
- throw new FormatException("Label past end.");
- }
-
- if (sb.Length > 0)
- {
- sb.Append('.');
- }
-
- for (var i = 0; i < lenByte; i++)
- {
- sb.Append((char)packet[off + i]);
- }
-
- off += lenByte;
- }
- throw new FormatException("Unterminated name.");
- }
-}
diff --git a/src/Starling.Net/Dns/DnsResolver.cs b/src/Starling.Net/Dns/DnsResolver.cs
deleted file mode 100644
index 91321c3a..00000000
--- a/src/Starling.Net/Dns/DnsResolver.cs
+++ /dev/null
@@ -1,160 +0,0 @@
-using System.Net;
-using Starling.Common;
-
-namespace Starling.Net.Dns;
-
-///
-/// Pure-managed DNS resolver. Public API: .
-///
-///
-///
-/// Short-circuits for the names a resolver shouldn't have to ask the network
-/// about — localhost → 127.0.0.1 + ::1; numeric IPv4 dotted
-/// quad → that address. Otherwise queries the supplied
-/// , parses the response, follows CNAMEs locally,
-/// and caches successful results with their TTL.
-///
-///
-/// Cache is bounded by both TTL and entry count (default 256). Resolving the
-/// same name within TTL returns immediately. Failed lookups are NOT cached
-/// (negative caching deferred).
-///
-///
-public sealed class DnsResolver
-{
- private readonly IDnsTransport _transport;
- private readonly DnsCache _cache;
- private readonly Func _newId;
-
- public DnsResolver(IDnsTransport transport)
- : this(transport, new DnsCache(maxEntries: 256), DefaultIdGenerator) { }
-
- /// Constructor used by tests to seed a deterministic id sequence.
- public DnsResolver(IDnsTransport transport, DnsCache cache, Func newId)
- {
- _transport = transport ?? throw new ArgumentNullException(nameof(transport));
- _cache = cache ?? throw new ArgumentNullException(nameof(cache));
- _newId = newId ?? throw new ArgumentNullException(nameof(newId));
- }
-
- private static readonly Random _idRng = Random.Shared;
- private static ushort DefaultIdGenerator() => (ushort)_idRng.Next(0, 0x10000);
-
- public async Task> ResolveAsync(
- string hostname, CancellationToken ct = default)
- {
- if (string.IsNullOrWhiteSpace(hostname))
- {
- return Result.Err(DnsError.EmptyHostname);
- }
-
- hostname = hostname.Trim().TrimEnd('.').ToLowerInvariant();
-
- // Short-circuit: localhost.
- if (hostname == "localhost")
- {
- return Result.Ok(DnsResult.LoopbackFor(hostname));
- }
-
- // Short-circuit: numeric IPv4 dotted quad.
- if (IPAddress.TryParse(hostname, out var literal))
- {
- return Result.Ok(new DnsResult(hostname, [literal], TimeSpan.FromHours(1)));
- }
-
- // Cache.
- if (_cache.TryGet(hostname, out var cached))
- {
- return Result.Ok(cached);
- }
-
- // Query A + AAAA in parallel.
- var aTask = QueryAsync(hostname, DnsMessage.QType.A, ct);
- var aaaaTask = QueryAsync(hostname, DnsMessage.QType.AAAA, ct);
- await Task.WhenAll(aTask, aaaaTask).ConfigureAwait(false);
-
- var ips = new List();
- var minTtl = uint.MaxValue;
-
- foreach (var task in new[] { aTask, aaaaTask })
- {
- var result = task.Result;
- if (result.Header.Rcode == DnsMessage.RCode.NameError)
- {
- continue;
- }
-
- if (result.Header.Rcode != DnsMessage.RCode.NoError)
- {
- continue;
- }
-
- foreach (var a in result.Answers)
- {
- if (a is DnsMessage.AAnswer av4)
- {
- ips.Add(new IPAddress(av4.IPv4));
- minTtl = Math.Min(minTtl, av4.Ttl);
- }
- else if (a is DnsMessage.AaaaAnswer av6)
- {
- ips.Add(new IPAddress(av6.IPv6));
- minTtl = Math.Min(minTtl, av6.Ttl);
- }
- // CNAME / Other: ignored for v1 — the recursive resolver
- // upstream has already chased the chain and embedded the A/AAAA.
- }
- }
-
- if (ips.Count == 0)
- {
- return Result.Err(DnsError.NoRecords);
- }
-
- var ttl = TimeSpan.FromSeconds(minTtl == uint.MaxValue ? 60 : minTtl);
- var result_ = new DnsResult(hostname, ips, ttl);
- _cache.Put(hostname, result_);
- return Result.Ok(result_);
- }
-
- private async Task<(DnsMessage.Header Header, List Answers)>
- QueryAsync(string hostname, DnsMessage.QType qtype, CancellationToken ct)
- {
- var id = _newId();
- var packet = DnsMessage.BuildQuery(id, hostname, qtype);
- byte[] response;
- try
- {
- response = await _transport.SendAsync(packet, ct).ConfigureAwait(false);
- }
- catch
- {
- return (default, []);
- }
- try
- {
- var (header, _, answers) = DnsMessage.Parse(response);
- return (header, answers);
- }
- catch (FormatException)
- {
- return (default, []);
- }
- }
-}
-
-public enum DnsError
-{
- EmptyHostname,
- NoRecords,
- Timeout,
- TransportFailure,
-}
-
-public sealed record DnsResult(string Hostname, IReadOnlyList Addresses, TimeSpan Ttl)
-{
- public static DnsResult LoopbackFor(string hostname) => new(
- hostname,
- [IPAddress.Loopback, IPAddress.IPv6Loopback],
- TimeSpan.FromHours(1));
-}
diff --git a/src/Starling.Net/Dns/IDnsTransport.cs b/src/Starling.Net/Dns/IDnsTransport.cs
deleted file mode 100644
index 62ed702c..00000000
--- a/src/Starling.Net/Dns/IDnsTransport.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace Starling.Net.Dns;
-
-///
-/// Transport seam for the DNS resolver. Sends a query packet, returns the
-/// response. The production implementation is ;
-/// tests substitute a fake to drive specific canned responses.
-///
-public interface IDnsTransport
-{
- Task SendAsync(byte[] queryPacket, CancellationToken ct);
-}
diff --git a/src/Starling.Net/Dns/UdpDnsTransport.cs b/src/Starling.Net/Dns/UdpDnsTransport.cs
deleted file mode 100644
index 70fec061..00000000
--- a/src/Starling.Net/Dns/UdpDnsTransport.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-
-namespace Starling.Net.Dns;
-
-///
-/// Real UDP transport for DNS queries. Sends to a configured resolver IP:port
-/// (default 8.8.8.8:53 — Google Public DNS). Pure managed via
-/// per Rule 0.
-///
-///
-/// Single round-trip; no retransmit, no fallback to TCP. Adequate for v1
-/// hostnames whose responses fit in 512 bytes (typical A/AAAA records).
-/// Larger responses (DNSSEC, many records) will require TC=1 handling +
-/// TCP fallback per RFC 1035 §4.2.2 — deferred to a follow-up.
-///
-public sealed class UdpDnsTransport : IDnsTransport
-{
- public IPEndPoint Resolver { get; }
- public TimeSpan Timeout { get; }
-
- public UdpDnsTransport()
- : this(new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53), TimeSpan.FromSeconds(5)) { }
-
- public UdpDnsTransport(IPEndPoint resolver, TimeSpan timeout)
- {
- Resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
- Timeout = timeout;
- }
-
- public async Task SendAsync(byte[] queryPacket, CancellationToken ct)
- {
- using var client = new UdpClient(AddressFamily.InterNetwork);
- // Bound timeout — if the resolver doesn't answer, we surface the
- // cancellation as OperationCanceledException to the caller.
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(Timeout);
-
- await client.SendAsync(queryPacket, queryPacket.Length, Resolver)
- .WaitAsync(cts.Token).ConfigureAwait(false);
- var result = await client.ReceiveAsync(cts.Token).ConfigureAwait(false);
- return result.Buffer;
- }
-}
diff --git a/src/Starling.Net/Http/ConnectionPool.cs b/src/Starling.Net/Http/ConnectionPool.cs
deleted file mode 100644
index 86e03477..00000000
--- a/src/Starling.Net/Http/ConnectionPool.cs
+++ /dev/null
@@ -1,286 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace Starling.Net.Http;
-
-internal static partial class ConnectionPoolLog
-{
- [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during pool discard")]
- public static partial void DiscardFailed(ILogger logger, Exception ex);
-}
-
-///
-/// Per-origin pool of idle, kept-alive HTTP/1.1 transports. Keyed on
-/// (scheme/host/port). LRU-bounded, with an idle
-/// timeout that prunes long-quiet connections before they would be killed
-/// by upstream NATs or server-side reapers.
-///
-///
-/// Sizing rationale: 6 idle connections per origin matches the classic
-/// HTTP/1.1 browser concurrency cap (Chrome / Firefox 6, Safari 6).
-/// Idle timeout default of 60s is the value Chromium has used since the
-/// early Blink era; most production HTTP/1.1 servers (nginx, apache, IIS)
-/// have a server-side keep-alive timeout in the 5–120s range, and 60s sits
-/// comfortably under the upper bound while letting bursty page loads reuse
-/// connections.
-///
-/// Eviction policy: oldest by last-used time. When the pool is full and a
-/// new release arrives, the oldest is dequeued and disposed before the new
-/// one is pushed in.
-///
-///
-/// Thread safety: , ,
-/// and are all safe
-/// to call concurrently. Each origin owns its own queue under a per-origin
-/// lock so requests against different origins don't contend.
-///
-///
-public sealed class ConnectionPool : IAsyncDisposable
-{
- /// Default per-origin idle capacity (HTTP/1.1 browser cap).
- public const int DefaultMaxPerOrigin = 6;
-
- /// Default idle timeout matching Chromium's historical default.
- public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromSeconds(60);
-
- private readonly Dictionary> _byOrigin = [];
- private readonly object _gate = new();
- private readonly ILogger _log;
- private bool _disposed;
-
- public int MaxPerOrigin { get; }
- public TimeSpan IdleTimeout { get; }
-
- public ConnectionPool() : this(DefaultMaxPerOrigin, DefaultIdleTimeout) { }
-
- public ConnectionPool(int maxPerOrigin, TimeSpan idleTimeout, ILogger? log = null)
- {
- if (maxPerOrigin < 1)
- {
- throw new ArgumentOutOfRangeException(
- nameof(maxPerOrigin), "Pool capacity must be at least 1.");
- }
-
- if (idleTimeout <= TimeSpan.Zero)
- {
- throw new ArgumentOutOfRangeException(
- nameof(idleTimeout), "Idle timeout must be positive.");
- }
-
- MaxPerOrigin = maxPerOrigin;
- IdleTimeout = idleTimeout;
- _log = log ?? NullLogger.Instance;
- }
-
- ///
- /// Snapshot of the idle-transport count across all origins. Mainly for
- /// tests; not used on the request path.
- ///
- public int IdleCount
- {
- get
- {
- lock (_gate)
- {
- var n = 0;
- foreach (var q in _byOrigin.Values)
- {
- n += q.Count;
- }
-
- return n;
- }
- }
- }
-
- ///
- /// Idle count for one specific origin. Useful for tests asserting on
- /// per-host pool occupancy.
- ///
- public int IdleCountFor(OriginKey origin)
- {
- lock (_gate)
- {
- return _byOrigin.TryGetValue(origin, out var q) ? q.Count : 0;
- }
- }
-
- ///
- /// Attempt to acquire an idle transport for the given origin. Returns the
- /// most-recently-used (MRU) transport — newer connections are likely to
- /// still be open at the server.
- ///
- public IHttpTransport? TryAcquire(OriginKey origin)
- {
- lock (_gate)
- {
- if (_disposed)
- {
- return null;
- }
-
- if (!_byOrigin.TryGetValue(origin, out var q) || q.Count == 0)
- {
- return null;
- }
-
- // MRU: take from the tail. Discard any that have since closed
- // (e.g., peer FIN we haven't noticed yet) and try the next.
- while (q.Count > 0)
- {
- var node = q.Last!;
- q.RemoveLast();
- if (node.Value.Transport.IsOpen)
- {
- return node.Value.Transport;
- }
-
- // Stale: dispose and keep looking.
- _ = DiscardAsync(node.Value.Transport, _log);
- }
- return null;
- }
- }
-
- ///
- /// Return a kept-alive transport to the pool. Caller must ensure the
- /// response body was fully consumed and both sides agreed to keep the
- /// connection alive. If the pool is at capacity for the origin, the
- /// oldest entry is evicted and disposed (LRU).
- ///
- public async ValueTask ReleaseAsync(IHttpTransport transport)
- {
- ArgumentNullException.ThrowIfNull(transport);
-
- IHttpTransport? evicted = null;
- lock (_gate)
- {
- if (_disposed || !transport.IsOpen)
- {
- // Caller asked to release a broken transport, or the pool was
- // disposed while we were holding the connection. Just close it.
- }
- else
- {
- var q = GetQueue(transport.Origin);
- if (q.Count >= MaxPerOrigin)
- {
- // LRU eviction: drop the oldest entry to make room.
- evicted = q.First!.Value.Transport;
- q.RemoveFirst();
- }
- q.AddLast(new Entry(transport, DateTimeOffset.UtcNow));
- transport = null!; // consumed by the pool; do not dispose below
- }
- }
-
- if (transport is not null)
- {
- await DiscardAsync(transport, _log).ConfigureAwait(false);
- }
-
- if (evicted is not null)
- {
- await DiscardAsync(evicted, _log).ConfigureAwait(false);
- }
- }
-
- ///
- /// Drop and dispose any pooled transport whose last-used timestamp is
- /// older than minus . When
- /// is null, is
- /// used.
- ///
- /// Count of transports drained.
- public async ValueTask DrainExpiredAsync(DateTimeOffset? now = null)
- {
- var threshold = (now ?? DateTimeOffset.UtcNow) - IdleTimeout;
- List? expired = null;
-
- lock (_gate)
- {
- foreach (var (_, q) in _byOrigin)
- {
- // Entries are appended in arrival order, which is also
- // last-used order — keep popping from the front while stale.
- while (q.First is { } first && first.Value.LastUsed <= threshold)
- {
- (expired ??= []).Add(first.Value.Transport);
- q.RemoveFirst();
- }
- }
- }
-
- if (expired is null)
- {
- return 0;
- }
-
- foreach (var t in expired)
- {
- await DiscardAsync(t, _log).ConfigureAwait(false);
- }
-
- return expired.Count;
- }
-
- ///
- /// Drop and dispose every pooled transport. The pool itself stays usable
- /// (e.g. for test scenarios that drain between assertions); call
- /// to additionally mark it as no longer
- /// accepting new releases.
- ///
- public async ValueTask DisposeAllAsync()
- {
- List? toClose = null;
- lock (_gate)
- {
- foreach (var (_, q) in _byOrigin)
- {
- foreach (var entry in q)
- {
- (toClose ??= []).Add(entry.Transport);
- }
-
- q.Clear();
- }
- _byOrigin.Clear();
- }
- if (toClose is null)
- {
- return;
- }
-
- foreach (var t in toClose)
- {
- await DiscardAsync(t, _log).ConfigureAwait(false);
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- await DisposeAllAsync().ConfigureAwait(false);
- lock (_gate)
- {
- _disposed = true;
- }
- }
-
- private LinkedList GetQueue(OriginKey origin)
- {
- if (!_byOrigin.TryGetValue(origin, out var q))
- {
- q = new LinkedList();
- _byOrigin[origin] = q;
- }
- return q;
- }
-
- private static async ValueTask DiscardAsync(IHttpTransport transport, ILogger log)
- {
- try { await transport.DisposeAsync().ConfigureAwait(false); }
- catch (Exception ex) { ConnectionPoolLog.DiscardFailed(log, ex); /* a stale socket may throw on shutdown; pooling doesn't care */ }
- }
-
- private readonly record struct Entry(IHttpTransport Transport, DateTimeOffset LastUsed);
-}
diff --git a/src/Starling.Net/Http/Decoding/BodyDecoder.cs b/src/Starling.Net/Http/Decoding/BodyDecoder.cs
deleted file mode 100644
index 2067f5e9..00000000
--- a/src/Starling.Net/Http/Decoding/BodyDecoder.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-using System.IO.Compression;
-
-namespace Starling.Net.Http.Decoding;
-
-///
-/// Applies HTTP Content-Encoding decoding stages to a buffered body.
-///
-///
-/// Per RFC 9110 §8.4, encodings in Content-Encoding are listed in the
-/// order they were applied. To recover the identity representation we apply
-/// the inverse codings in reverse order — i.e. the last listed coding is
-/// peeled first.
-///
-public static class BodyDecoder
-{
- ///
- /// Decode through every stage named in
- /// . Unknown or empty encoding tokens
- /// are rejected.
- ///
- public static byte[] Decode(ReadOnlyMemory body, IReadOnlyList contentEncodings)
- {
- ArgumentNullException.ThrowIfNull(contentEncodings);
-
- if (contentEncodings.Count == 0)
- {
- return body.ToArray();
- }
-
- var current = body.ToArray();
- for (var i = contentEncodings.Count - 1; i >= 0; i--)
- {
- current = DecodeStage(current, contentEncodings[i]);
- }
- return current;
- }
-
- ///
- /// Parse a comma-separated Content-Encoding header value into a
- /// list of lowercase coding tokens. Whitespace and empty entries are
- /// stripped. identity is filtered out (it's a no-op coding).
- ///
- public static IReadOnlyList ParseEncodings(string? headerValue)
- {
- if (string.IsNullOrWhiteSpace(headerValue))
- {
- return Array.Empty();
- }
-
- var parts = headerValue.Split(',');
- List? result = null;
- foreach (var raw in parts)
- {
- var token = raw.Trim().ToLowerInvariant();
- if (token.Length == 0 || token == "identity")
- {
- continue;
- }
-
- (result ??= []).Add(token);
- }
- return result ?? (IReadOnlyList)Array.Empty();
- }
-
- private static byte[] DecodeStage(byte[] input, string encoding)
- {
- var token = (encoding ?? string.Empty).Trim().ToLowerInvariant();
- return token switch
- {
- "gzip" or "x-gzip" => Decompress(input, raw => new GZipStream(raw, CompressionMode.Decompress)),
- "br" => Decompress(input, raw => new BrotliStream(raw, CompressionMode.Decompress)),
- "deflate" => DecodeDeflate(input),
- "identity" or "" => input,
- _ => throw new NotSupportedException($"Content-Encoding '{encoding}' is not supported."),
- };
- }
-
- private static byte[] Decompress(byte[] input, Func wrap)
- {
- using var raw = new MemoryStream(input, writable: false);
- using var decompressor = wrap(raw);
- using var output = new MemoryStream();
- decompressor.CopyTo(output);
- return output.ToArray();
- }
-
- ///
- /// "deflate" in HTTP is historically zlib-wrapped DEFLATE (RFC 1950),
- /// but real servers send raw DEFLATE about as often. Try zlib first; if
- /// the header fails, fall back to raw .
- ///
- private static byte[] DecodeDeflate(byte[] input)
- {
- try
- {
- return Decompress(input, raw => new ZLibStream(raw, CompressionMode.Decompress));
- }
- catch (InvalidDataException)
- {
- return Decompress(input, raw => new DeflateStream(raw, CompressionMode.Decompress));
- }
- }
-}
diff --git a/src/Starling.Net/Http/Decoding/ChunkedReader.cs b/src/Starling.Net/Http/Decoding/ChunkedReader.cs
deleted file mode 100644
index 6c5d15ba..00000000
--- a/src/Starling.Net/Http/Decoding/ChunkedReader.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System.Globalization;
-using System.Text;
-
-namespace Starling.Net.Http.Decoding;
-
-///
-/// Decoder for HTTP/1.1 Transfer-Encoding: chunked framing per
-/// RFC 9112 §7.1. Drains an until the
-/// terminating zero-sized chunk (and any trailers) is consumed.
-///
-internal static class ChunkedReader
-{
- private const int MaxChunkSizeLineLength = 1024;
- private const int MaxTrailerLineLength = 8 * 1024;
-
- public static async ValueTask ReadAllAsync(
- InboundBuffer source, int maxBodyBytes, CancellationToken ct)
- {
- ArgumentNullException.ThrowIfNull(source);
- if (maxBodyBytes < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(maxBodyBytes));
- }
-
- using var ms = new MemoryStream();
-
- while (true)
- {
- var sizeLine = await source.TakeLineAsync(MaxChunkSizeLineLength, ct).ConfigureAwait(false)
- ?? throw new InvalidDataException("Unexpected EOF before chunk size line.");
-
- var size = ParseChunkSize(sizeLine);
-
- if (size == 0)
- {
- // Drain trailer section: zero or more header lines followed by an empty line.
- while (true)
- {
- var trailer = await source.TakeLineAsync(MaxTrailerLineLength, ct).ConfigureAwait(false)
- ?? throw new InvalidDataException("Unexpected EOF inside chunked trailers.");
- if (trailer.Length == 0)
- {
- break;
- }
- // v1: trailers ignored.
- }
- break;
- }
-
- if (ms.Length + size > maxBodyBytes)
- {
- throw new InvalidDataException("Chunked body exceeded cap.");
- }
-
- var chunk = await source.ReadExactAsync(size, ct).ConfigureAwait(false);
- ms.Write(chunk);
-
- var crlf = await source.TakeLineAsync(2, ct).ConfigureAwait(false)
- ?? throw new InvalidDataException("Unexpected EOF after chunk data.");
- if (crlf.Length != 0)
- {
- throw new InvalidDataException("Expected CRLF terminator after chunk data.");
- }
- }
-
- return ms.ToArray();
- }
-
- ///
- /// Parse a chunk-size line per §7.1.1. Form: 1*HEXDIG [chunk-ext].
- /// We accept hex digits in either case and ignore everything from the
- /// first ';' onwards (chunk extensions).
- ///
- internal static int ParseChunkSize(byte[] line)
- {
- ArgumentNullException.ThrowIfNull(line);
- if (line.Length == 0)
- {
- throw new InvalidDataException("Chunk-size line was empty.");
- }
-
- var end = line.Length;
- for (var i = 0; i < line.Length; i++)
- {
- if (line[i] == (byte)';' || line[i] == (byte)' ' || line[i] == (byte)'\t')
- {
- end = i;
- break;
- }
- }
-
- if (end == 0)
- {
- throw new InvalidDataException("Chunk-size line had no hex digits.");
- }
-
- var asAscii = Encoding.ASCII.GetString(line, 0, end);
- if (!int.TryParse(asAscii, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var size)
- || size < 0)
- {
- throw new InvalidDataException($"Chunk-size '{asAscii}' is not a valid hex integer.");
- }
- return size;
- }
-}
diff --git a/src/Starling.Net/Http/Decoding/InboundBuffer.cs b/src/Starling.Net/Http/Decoding/InboundBuffer.cs
deleted file mode 100644
index 0e18c737..00000000
--- a/src/Starling.Net/Http/Decoding/InboundBuffer.cs
+++ /dev/null
@@ -1,233 +0,0 @@
-namespace Starling.Net.Http.Decoding;
-
-///
-/// Pull-buffered reader over a . Used by the response parser
-/// to scan for CRLF-terminated lines and then drain a known number of body
-/// bytes (or read to EOF) without re-issuing tiny reads against the network.
-///
-///
-/// The buffer grows on demand up to the caller-bounded line/header/body caps.
-/// All public read methods are async; cancellation propagates from the
-/// underlying stream.
-///
-internal sealed class InboundBuffer
-{
- private const int InitialCapacity = 8 * 1024;
- private readonly Stream _stream;
- private byte[] _buf;
- private int _start;
- private int _end;
-
- public InboundBuffer(Stream stream)
- {
- _stream = stream ?? throw new ArgumentNullException(nameof(stream));
- _buf = new byte[InitialCapacity];
- }
-
- public bool Eof { get; private set; }
- public int BufferedCount => _end - _start;
-
- public ReadOnlySpan Peek() => _buf.AsSpan(_start, _end - _start);
-
- public void Consume(int count)
- {
- if (count < 0 || count > BufferedCount)
- {
- throw new ArgumentOutOfRangeException(nameof(count));
- }
-
- _start += count;
- if (_start == _end) { _start = 0; _end = 0; }
- }
-
- ///
- /// Read more bytes from the underlying stream into the buffer. Returns
- /// false when the stream has signalled EOF.
- ///
- public async ValueTask ReadMoreAsync(CancellationToken ct)
- {
- if (Eof)
- {
- return false;
- }
-
- EnsureCapacityForMore();
- var n = await _stream.ReadAsync(_buf.AsMemory(_end, _buf.Length - _end), ct)
- .ConfigureAwait(false);
- if (n == 0) { Eof = true; return false; }
- _end += n;
- return true;
- }
-
- ///
- /// Locate a "\r\n" sequence inside the currently buffered region. Returns
- /// the relative offset of the '\r' or -1 when no CRLF is present yet.
- ///
- public int IndexOfCrLf()
- {
- var span = Peek();
- for (var i = 0; i + 1 < span.Length; i++)
- {
- if (span[i] == 0x0D && span[i + 1] == 0x0A)
- {
- return i;
- }
- }
- return -1;
- }
-
- ///
- /// Locate a "\r\n\r\n" sequence inside the currently buffered region.
- /// Returns the relative offset of the first '\r' or -1.
- ///
- public int IndexOfDoubleCrLf()
- {
- var span = Peek();
- for (var i = 0; i + 3 < span.Length; i++)
- {
- if (span[i] == 0x0D && span[i + 1] == 0x0A
- && span[i + 2] == 0x0D && span[i + 3] == 0x0A)
- {
- return i;
- }
- }
- return -1;
- }
-
- ///
- /// Read a CRLF-terminated line. The returned span is invalidated by the
- /// next mutating call (read/consume); copy if you need to retain it.
- ///
- public async ValueTask ReadLineAsync(int maxLineLength, CancellationToken ct)
- {
- while (true)
- {
- var idx = IndexOfCrLf();
- if (idx >= 0)
- {
- return true;
- }
-
- if (BufferedCount > maxLineLength)
- {
- return false;
- }
-
- if (!await ReadMoreAsync(ct).ConfigureAwait(false))
- {
- return false;
- }
- }
- }
-
- ///
- /// Take a CRLF-terminated line as a copied byte array (without the CRLF).
- /// Returns null on EOF before a full line was assembled.
- ///
- public async ValueTask TakeLineAsync(int maxLineLength, CancellationToken ct)
- {
- if (!await ReadLineAsync(maxLineLength, ct).ConfigureAwait(false))
- {
- // Either EOF or oversized line. Distinguish via Eof flag — caller may want to retry.
- return null;
- }
- var idx = IndexOfCrLf();
- var line = Peek().Slice(0, idx).ToArray();
- Consume(idx + 2);
- return line;
- }
-
- ///
- /// Read exactly bytes (consuming buffered bytes
- /// first, then reading from the stream). Returns the result as a new array.
- ///
- public async ValueTask ReadExactAsync(int count, CancellationToken ct)
- {
- if (count < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(count));
- }
-
- var result = new byte[count];
- var written = 0;
-
- if (BufferedCount > 0)
- {
- var take = Math.Min(BufferedCount, count);
- _buf.AsSpan(_start, take).CopyTo(result.AsSpan(0, take));
- Consume(take);
- written = take;
- }
-
- while (written < count)
- {
- var n = await _stream
- .ReadAsync(result.AsMemory(written, count - written), ct)
- .ConfigureAwait(false);
- if (n == 0)
- {
- Eof = true;
- throw new EndOfStreamException(
- $"Stream ended after {written} of {count} expected bytes.");
- }
- written += n;
- }
- return result;
- }
-
- ///
- /// Read everything remaining (buffered + stream) until EOF, capped at
- /// . Throws if the cap is exceeded.
- ///
- public async ValueTask ReadToEndAsync(int maxBytes, CancellationToken ct)
- {
- using var ms = new MemoryStream();
- if (BufferedCount > 0)
- {
- if (BufferedCount > maxBytes)
- {
- throw new InvalidDataException("Body exceeded cap.");
- }
-
- ms.Write(_buf, _start, BufferedCount);
- Consume(BufferedCount);
- }
-
- var temp = new byte[8 * 1024];
- while (true)
- {
- if (ms.Length > maxBytes)
- {
- throw new InvalidDataException("Body exceeded cap.");
- }
-
- var n = await _stream.ReadAsync(temp, ct).ConfigureAwait(false);
- if (n == 0) { Eof = true; break; }
- if (ms.Length + n > maxBytes)
- {
- throw new InvalidDataException("Body exceeded cap.");
- }
-
- ms.Write(temp, 0, n);
- }
- return ms.ToArray();
- }
-
- private void EnsureCapacityForMore()
- {
- if (_start > 0 && _end - _start < _buf.Length / 2)
- {
- Buffer.BlockCopy(_buf, _start, _buf, 0, _end - _start);
- _end -= _start;
- _start = 0;
- }
- if (_end == _buf.Length)
- {
- var grown = new byte[_buf.Length * 2];
- Buffer.BlockCopy(_buf, _start, grown, 0, _end - _start);
- _end -= _start;
- _start = 0;
- _buf = grown;
- }
- }
-}
diff --git a/src/Starling.Net/Http/H1/H1RequestWriter.cs b/src/Starling.Net/Http/H1/H1RequestWriter.cs
deleted file mode 100644
index cdc871af..00000000
--- a/src/Starling.Net/Http/H1/H1RequestWriter.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-using System.Buffers;
-using System.Globalization;
-using System.Text;
-using StarlingUrl = global::Starling.Url.Url;
-
-namespace Starling.Net.Http.H1;
-
-///
-/// Serializes a into a wire-format HTTP/1.1 message
-/// (request-line + headers + body) and writes it to a .
-///
-///
-/// The writer fills in the spec-required headers (Host, User-Agent, Accept,
-/// Accept-Encoding, Connection, Content-Length) only when the caller has not
-/// already supplied them. Header names from
-/// always win — this lets callers force a header that conflicts with our
-/// defaults (e.g. Accept-Encoding: identity for testing).
-///
-public sealed class H1RequestWriter
-{
- public string UserAgent { get; init; } = "Starling/0.1 (https://github.com/anthropic-starling)";
-
- public string AcceptHeader { get; init; } =
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
-
- public string AcceptEncodingHeader { get; init; } = "gzip, br, deflate";
-
- public string ConnectionHeader { get; init; } = "keep-alive";
-
- public async ValueTask WriteAsync(HttpRequest req, Stream output, CancellationToken ct)
- {
- ArgumentNullException.ThrowIfNull(req);
- ArgumentNullException.ThrowIfNull(output);
-
- var rent = ArrayPool.Shared.Rent(4096);
- try
- {
- var bytes = Serialize(req, rent);
- await output.WriteAsync(bytes, ct).ConfigureAwait(false);
-
- if (!req.Body.IsEmpty)
- {
- await output.WriteAsync(req.Body, ct).ConfigureAwait(false);
- }
-
- await output.FlushAsync(ct).ConfigureAwait(false);
- }
- finally
- {
- ArrayPool.Shared.Return(rent);
- }
- }
-
- ///
- /// Test-friendly synchronous serialization. Returns the wire bytes for the
- /// request-line + header block (without the body) so unit tests can assert
- /// the textual form without standing up a stream.
- ///
- public byte[] SerializeHead(HttpRequest req)
- {
- ArgumentNullException.ThrowIfNull(req);
- var ms = new MemoryStream();
- var head = Serialize(req, scratch: null);
- ms.Write(head.Span);
- return ms.ToArray();
- }
-
- private ReadOnlyMemory Serialize(HttpRequest req, byte[]? scratch)
- {
- var sb = new StringBuilder(512);
-
- sb.Append(req.Method).Append(' ')
- .Append(BuildRequestTarget(req.Url)).Append(' ')
- .Append("HTTP/1.1\r\n");
-
- AppendDefaultHeader(sb, req.Headers, "Host", BuildHostHeader(req.Url));
- AppendDefaultHeader(sb, req.Headers, "User-Agent", UserAgent);
- AppendDefaultHeader(sb, req.Headers, "Accept", AcceptHeader);
- AppendDefaultHeader(sb, req.Headers, "Accept-Encoding", AcceptEncodingHeader);
- AppendDefaultHeader(sb, req.Headers, "Connection", ConnectionHeader);
-
- // Emit Content-Length when there is a body, OR when the method is one
- // that carries a request body (POST/PUT/PATCH) even if that body is
- // empty. An empty-body POST with no Content-Length is rejected by many
- // servers with 411 Length Required — XHR/fetch always send "0" here, so
- // we must too (e.g. McMaster's token-authorization POST).
- if ((!req.Body.IsEmpty || MethodCarriesBody(req.Method))
- && !req.Headers.Contains("Content-Length")
- && !req.Headers.Contains("Transfer-Encoding"))
- {
- sb.Append("Content-Length: ")
- .Append(req.Body.Length.ToString(CultureInfo.InvariantCulture))
- .Append("\r\n");
- }
-
- foreach (var kv in req.Headers)
- {
- sb.Append(kv.Key).Append(": ").Append(kv.Value).Append("\r\n");
- }
-
- sb.Append("\r\n");
-
- var s = sb.ToString();
- var len = Encoding.ASCII.GetByteCount(s);
- if (scratch is null || scratch.Length < len)
- {
- scratch = new byte[len];
- }
-
- var written = Encoding.ASCII.GetBytes(s, scratch);
- return new ReadOnlyMemory(scratch, 0, written);
- }
-
- /// True for request methods that carry a body (so an empty body
- /// still warrants Content-Length: 0). GET/HEAD/OPTIONS/etc. do not.
- internal static bool MethodCarriesBody(string method) =>
- method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
- method.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
- method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
-
- private static void AppendDefaultHeader(StringBuilder sb, HttpHeaders user, string name, string value)
- {
- if (user.Contains(name))
- {
- return;
- }
-
- sb.Append(name).Append(": ").Append(value).Append("\r\n");
- }
-
- ///
- /// Build the request-target per RFC 9112 §3.2. For origin-form (the only
- /// form we emit for direct connections, not proxies) that is the path
- /// plus the query, with the path defaulting to "/" if empty.
- ///
- internal static string BuildRequestTarget(StarlingUrl url)
- {
- var path = string.IsNullOrEmpty(url.Path) ? "/" : url.Path;
- if (!path.StartsWith('/'))
- {
- path = "/" + path;
- }
-
- return url.Query is { Length: > 0 } q ? path + "?" + q : path;
- }
-
- ///
- /// Build the Host header per RFC 9112 §3.2 / §7.2. Includes the explicit
- /// port if the URL specifies one that differs from the scheme default.
- ///
- internal static string BuildHostHeader(StarlingUrl url)
- {
- if (string.IsNullOrEmpty(url.Host))
- {
- throw new ArgumentException("URL has no host — cannot build Host header.", nameof(url));
- }
-
- var defaultPort = url.DefaultPort;
- if (url.Port is int p && p != defaultPort)
- {
- return url.Host + ":" + p.ToString(CultureInfo.InvariantCulture);
- }
-
- return url.Host;
- }
-}
diff --git a/src/Starling.Net/Http/H1/H1ResponseParser.cs b/src/Starling.Net/Http/H1/H1ResponseParser.cs
deleted file mode 100644
index cfa3e82b..00000000
--- a/src/Starling.Net/Http/H1/H1ResponseParser.cs
+++ /dev/null
@@ -1,345 +0,0 @@
-using System.Globalization;
-using System.Text;
-using Starling.Common;
-using Starling.Net.Http.Decoding;
-
-namespace Starling.Net.Http.H1;
-
-///
-/// Parser for an HTTP/1.1 response message off a byte-oriented
-/// . Returns a fully buffered
-/// with body framing (Content-Length / chunked / EOF) and any
-/// Content-Encoding stack removed.
-///
-///
-/// State machine — RFC 9112 §3:
-/// status-line → header-section → body
-/// Body framing decided per §6.3:
-/// 1. Transfer-Encoding: chunked takes priority over Content-Length.
-/// 2. Content-Length: N if present.
-/// 3. Otherwise read until EOF (legacy HTTP/1.0 close-delimited).
-/// We do not attempt to handle 1xx informational responses except
-/// to discard their head and re-enter the status-line state; v1 wires the
-/// HTTP layer to a TLS transport that we don't drive in 100-Continue mode.
-///
-public sealed class H1ResponseParser
-{
- /// Cap on the size of the status-line + header block.
- public int MaxHeaderBlockBytes { get; init; } = 64 * 1024;
-
- /// Cap on the decoded body (post Content-Encoding).
- public int MaxBodyBytes { get; init; } = 32 * 1024 * 1024;
-
- /// Cap on a single header line.
- public int MaxHeaderLineBytes { get; init; } = 16 * 1024;
-
- public async Task> ParseAsync(
- Stream input, CancellationToken ct)
- {
- ArgumentNullException.ThrowIfNull(input);
-
- var buf = new InboundBuffer(input);
-
- // 1. Discard 1xx informational responses (e.g. 100 Continue / 103 Early Hints).
- Headline headline;
- HttpHeaders headers;
- while (true)
- {
- var headBlock = await ReadHeaderBlockAsync(buf, ct).ConfigureAwait(false);
- if (headBlock.IsErr)
- {
- return Result.Err(headBlock.Error);
- }
-
- var parsed = ParseHeadBlock(headBlock.Value);
- if (parsed.IsErr)
- {
- return Result.Err(parsed.Error);
- }
-
- headline = parsed.Value.Headline;
- headers = parsed.Value.Headers;
- if (headline.StatusCode is < 100 or >= 200)
- {
- break;
- }
- // 1xx — keep reading.
- }
-
- // 2. Body framing.
- byte[] rawBody;
- try
- {
- rawBody = await ReadBodyAsync(buf, headline, headers, ct).ConfigureAwait(false);
- }
- catch (InvalidDataException ex) when (ex.Message.Contains("exceeded", StringComparison.Ordinal))
- {
- return Result.Err(HttpError.BodyTooLarge);
- }
- catch (InvalidDataException)
- {
- return Result.Err(HttpError.BadChunkedFraming);
- }
- catch (EndOfStreamException)
- {
- return Result.Err(HttpError.UnexpectedEof);
- }
-
- // 3. Content-Encoding.
- byte[] decoded;
- try
- {
- var encodings = BodyDecoder.ParseEncodings(headers.GetFirst("Content-Encoding"));
- decoded = encodings.Count == 0 ? rawBody : BodyDecoder.Decode(rawBody, encodings);
- }
- catch (NotSupportedException)
- {
- return Result.Err(HttpError.UnsupportedEncoding);
- }
- catch (InvalidDataException)
- {
- return Result.Err(HttpError.DecodeFailed);
- }
-
- return Result.Ok(
- new HttpResponse(
- headline.HttpVersion,
- headline.StatusCode,
- headline.ReasonPhrase,
- headers,
- decoded));
- }
-
- private async Task> ReadHeaderBlockAsync(
- InboundBuffer buf, CancellationToken ct)
- {
- while (true)
- {
- var idx = buf.IndexOfDoubleCrLf();
- if (idx >= 0)
- {
- var headBytes = buf.Peek().Slice(0, idx + 2).ToArray(); // include the first CRLF; not the empty line
- buf.Consume(idx + 4);
- return Result.Ok(headBytes);
- }
- if (buf.BufferedCount > MaxHeaderBlockBytes)
- {
- return Result.Err(HttpError.HeadersTooLarge);
- }
-
- if (!await buf.ReadMoreAsync(ct).ConfigureAwait(false))
- {
- if (buf.BufferedCount == 0)
- {
- return Result.Err(HttpError.UnexpectedEof);
- }
-
- return Result.Err(HttpError.UnexpectedEof);
- }
- }
- }
-
- private Result<(Headline Headline, HttpHeaders Headers), HttpError> ParseHeadBlock(byte[] head)
- {
- var text = Encoding.ASCII.GetString(head);
- // We included the trailing CRLF before the empty line, so the last
- // newline is the one ending the final header line.
- var lines = text.Split("\r\n");
- if (lines.Length == 0 || string.IsNullOrEmpty(lines[0]))
- {
- return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadStatusLine);
- }
-
- var headlineResult = ParseStatusLine(lines[0]);
- if (headlineResult.IsErr)
- {
- return Result<(Headline, HttpHeaders), HttpError>.Err(headlineResult.Error);
- }
-
- var headers = new HttpHeaders();
- for (var i = 1; i < lines.Length; i++)
- {
- var line = lines[i];
- if (line.Length == 0)
- {
- continue;
- }
-
- if (line[0] is ' ' or '\t')
- {
- // RFC 7230 §3.2.4: line folding is deprecated and MUST be rejected.
- return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader);
- }
-
- var colon = line.IndexOf(':', StringComparison.Ordinal);
- if (colon <= 0)
- {
- return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader);
- }
-
- var name = line[..colon];
- var value = line[(colon + 1)..].Trim(' ', '\t');
-
- try { headers.Add(name, value); }
- catch (ArgumentException) { return Result<(Headline, HttpHeaders), HttpError>.Err(HttpError.BadHeader); }
- }
-
- return Result<(Headline, HttpHeaders), HttpError>.Ok((headlineResult.Value, headers));
- }
-
- private static Result ParseStatusLine(string line)
- {
- // status-line = HTTP-version SP status-code SP [ reason-phrase ]
- var firstSp = line.IndexOf(' ', StringComparison.Ordinal);
- if (firstSp <= 0)
- {
- return Result.Err(HttpError.BadStatusLine);
- }
-
- var version = line[..firstSp];
- if (!version.StartsWith("HTTP/", StringComparison.Ordinal))
- {
- return Result.Err(HttpError.BadStatusLine);
- }
-
- var secondSp = line.IndexOf(' ', firstSp + 1);
- var codeStr = secondSp < 0 ? line[(firstSp + 1)..] : line[(firstSp + 1)..secondSp];
- if (!int.TryParse(codeStr, NumberStyles.None, CultureInfo.InvariantCulture, out var code)
- || code is < 100 or > 599)
- {
- return Result.Err(HttpError.BadStatusLine);
- }
-
- var reason = secondSp < 0 ? string.Empty : line[(secondSp + 1)..];
-
- return Result.Ok(new Headline(version, code, reason));
- }
-
- private async Task ReadBodyAsync(
- InboundBuffer buf,
- Headline headline,
- HttpHeaders headers,
- CancellationToken ct)
- {
- // 204/304 and HEAD responses must have an empty body, but we don't
- // know the request method here. RFC 9112 §6.3 step 1 covers status —
- // the rest is the caller's responsibility.
- if (headline.StatusCode is 204 or 304)
- {
- return Array.Empty();
- }
-
- var te = headers.GetFirst("Transfer-Encoding");
- if (te is not null && ContainsToken(te, "chunked"))
- {
- return await ChunkedReader.ReadAllAsync(buf, MaxBodyBytes, ct).ConfigureAwait(false);
- }
-
- var clText = headers.GetFirst("Content-Length");
- if (clText is not null)
- {
- if (!long.TryParse(clText, NumberStyles.None, CultureInfo.InvariantCulture, out var cl) || cl < 0)
- {
- throw new InvalidDataException("Bad Content-Length");
- }
-
- if (cl > MaxBodyBytes)
- {
- throw new InvalidDataException("Content-Length exceeded cap.");
- }
-
- return cl == 0 ? Array.Empty() : await buf.ReadExactAsync((int)cl, ct).ConfigureAwait(false);
- }
-
- return await buf.ReadToEndAsync(MaxBodyBytes, ct).ConfigureAwait(false);
- }
-
- private static bool ContainsToken(string headerValue, string token)
- {
- foreach (var raw in headerValue.Split(','))
- {
- if (string.Equals(raw.Trim(), token, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
- return false;
- }
-
- ///
- /// Decide whether indicated the server is
- /// willing to keep the underlying transport open for another request on
- /// the same origin. Used by the connection pool to gate
- /// ReleaseAsync on a clean response.
- ///
- ///
- /// RFC 9112 §9.3: HTTP/1.1 connections are keep-alive by default unless
- /// a Connection: close option is present. HTTP/1.0 connections are
- /// close-by-default unless the legacy Connection: keep-alive
- /// signal is present (RFC 7230 §A.1.2). A response framed by
- /// connection-close (no Content-Length / Transfer-Encoding on a non-empty
- /// body) cannot be pooled even if the headers say keep-alive — the
- /// caller is responsible for checking framing.
- ///
- public static bool IndicatesKeepAlive(HttpResponse response)
- {
- ArgumentNullException.ThrowIfNull(response);
-
- var connection = response.Headers.GetFirst("Connection");
- var isHttp11 = string.Equals(response.HttpVersion, "HTTP/1.1", StringComparison.Ordinal);
- var isHttp10 = string.Equals(response.HttpVersion, "HTTP/1.0", StringComparison.Ordinal);
-
- if (connection is not null)
- {
- if (ContainsToken(connection, "close"))
- {
- return false;
- }
-
- if (ContainsToken(connection, "keep-alive"))
- {
- return true;
- }
- }
-
- // No explicit signal: HTTP/1.1 default keep-alive; HTTP/1.0 default close.
- if (isHttp11)
- {
- return true;
- }
-
- if (isHttp10)
- {
- return false;
- }
- // Anything unrecognised (e.g. a malformed version we still parsed) —
- // be conservative and close.
- return false;
- }
-
- ///
- /// True when the response has a well-defined body length (Content-Length
- /// or chunked Transfer-Encoding, or a status that mandates an empty body).
- /// Connection-close framing (no length, non-empty body) is not poolable
- /// because the parser had to read to EOF to know where the body ended.
- ///
- public static bool HasDefiniteBodyFraming(HttpResponse response)
- {
- ArgumentNullException.ThrowIfNull(response);
-
- if (response.StatusCode is 204 or 304)
- {
- return true;
- }
-
- var te = response.Headers.GetFirst("Transfer-Encoding");
- if (te is not null && ContainsToken(te, "chunked"))
- {
- return true;
- }
-
- return response.Headers.GetFirst("Content-Length") is not null;
- }
-
- private readonly record struct Headline(string HttpVersion, int StatusCode, string ReasonPhrase);
-}
diff --git a/src/Starling.Net/Http/H2/AsyncSignal.cs b/src/Starling.Net/Http/H2/AsyncSignal.cs
deleted file mode 100644
index 182f273f..00000000
--- a/src/Starling.Net/Http/H2/AsyncSignal.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-namespace Starling.Net.Http.H2;
-
-///
-/// Edge-triggered async notification: waiters capture
-/// before re-checking their condition, so a that races the
-/// check is never lost. Used to wake stream senders when flow-control windows
-/// grow or a concurrency slot frees up.
-///
-///
-/// Correct usage:
-///
-/// while (true)
-/// {
-/// var wait = signal.WaitAsync();
-/// if (ConditionMet()) break;
-/// await wait;
-/// }
-///
-///
-internal sealed class AsyncSignal
-{
- private readonly object _gate = new();
- private TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
-
- public Task WaitAsync()
- {
- lock (_gate)
- {
- return _tcs.Task;
- }
- }
-
- /// Wake all current waiters and arm a fresh signal for future waits.
- public void Pulse()
- {
- lock (_gate)
- {
- _tcs.TrySetResult();
- _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- }
- }
-}
diff --git a/src/Starling.Net/Http/H2/H2Connection.cs b/src/Starling.Net/Http/H2/H2Connection.cs
deleted file mode 100644
index da901cb0..00000000
--- a/src/Starling.Net/Http/H2/H2Connection.cs
+++ /dev/null
@@ -1,972 +0,0 @@
-using System.Globalization;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Starling.Common;
-using Starling.Common.Diagnostics;
-using Starling.Net.Http.Decoding;
-using Starling.Net.Http.H2.Hpack;
-using StarlingUrl = global::Starling.Url.Url;
-
-namespace Starling.Net.Http.H2;
-
-internal static partial class H2ConnectionLog
-{
- [LoggerMessage(Level = LogLevel.Warning, Message = "h2 connection error {ErrorCode}: {ErrorMessage}")]
- public static partial void ConnectionError(ILogger logger, string errorCode, string errorMessage);
-
- [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during reader-loop teardown")]
- public static partial void TransportDisposeFailed(ILogger logger, Exception ex);
-
- [LoggerMessage(Level = LogLevel.Debug, Message = "best-effort control frame failed on dying connection")]
- public static partial void SafeWriteFailed(ILogger logger, Exception ex);
-
- [LoggerMessage(Level = LogLevel.Debug, Message = "reader task threw during DisposeAsync cleanup")]
- public static partial void ReaderTaskCleanupFailed(ILogger logger, Exception ex);
-}
-
-///
-/// A single HTTP/2 connection multiplexing many request/response streams over
-/// one TLS transport (RFC 9113). One reader loop demultiplexes inbound frames
-/// to per-stream state; outbound frames are serialized by the
-/// . Owns the underlying
-/// and tears it down when the connection closes.
-///
-internal sealed class H2Connection : IAsyncDisposable
-{
- // Our advertised receive settings. A generous stream/connection receive
- // window lets servers send a sizeable first burst before our first
- // WINDOW_UPDATE; we then replenish per DATA frame to keep windows topped up.
- private const int OurInitialWindowSize = 8 * 1024 * 1024;
- private const int OurMaxFrameSize = H2Protocol.DefaultMaxFrameSize;
-
- // Request header defaults, matching H1RequestWriter so both paths look
- // identical to a server.
- private const string DefaultUserAgent = "Starling/0.1 (https://github.com/anthropic-starling)";
- private const string DefaultAccept =
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
- private const string DefaultAcceptEncoding = "gzip, br, deflate";
-
- private readonly IHttpTransport _transport;
- private readonly H2FrameReader _reader;
- private readonly H2FrameWriter _writer;
- private readonly HpackEncoder _encoder = new();
- private readonly HpackDecoder _decoder = new(H2Protocol.DefaultHeaderTableSize);
- private readonly ILogger _log;
- private readonly Action? _onClosed;
-
- private readonly object _lock = new();
- private readonly Dictionary _streams = [];
- private readonly AsyncSignal _windowSignal = new();
- private readonly SemaphoreSlim _openLock = new(1, 1);
-
- // Cancelled when the connection is torn down. Drives the reader loop and the
- // best-effort control-frame writes that aren't bound to a caller's request,
- // so they stop promptly instead of running against a dying transport. The
- // token is captured up front so it stays usable after the source is disposed.
- private readonly CancellationTokenSource _lifetime = new();
- private readonly CancellationToken _lifetimeToken;
-
- // Peer settings governing what we send.
- private int _peerInitialWindowSize = H2Protocol.DefaultInitialWindowSize;
- private int _peerMaxFrameSize = H2Protocol.DefaultMaxFrameSize;
- private long _peerMaxConcurrentStreams = long.MaxValue;
- private int _connSendWindow = H2Protocol.DefaultInitialWindowSize;
-
- private int _nextStreamId = 1;
- private int _activeStreams;
- private bool _goAwayReceived;
- private bool _closed;
- private int _transportDisposed;
-
- // Header-block assembly across HEADERS + CONTINUATION frames.
- private readonly MemoryStream _headerFragments = new();
- private int _headerStreamId; // 0 == not currently assembling
- private bool _headerEndStream;
-
- private Task _readerTask = Task.CompletedTask;
-
- public OriginKey Origin { get; }
-
- /// The verified leaf certificate of the underlying TLS transport, if any.
- public Tls.CertificateSummary? PeerCertificate => _transport.PeerCertificate;
-
- private H2Connection(IHttpTransport transport, OriginKey origin, ILogger? log, Action? onClosed)
- {
- _transport = transport;
- Origin = origin;
- _log = log ?? NullLogger.Instance;
- _onClosed = onClosed;
- _lifetimeToken = _lifetime.Token;
- _reader = new H2FrameReader(transport.Stream, OurMaxFrameSize);
- _writer = new H2FrameWriter(transport.Stream);
- }
-
- ///
- /// Perform the connection preface, exchange SETTINGS, raise the connection
- /// receive window, and start the reader loop. The returned connection is
- /// ready to accept calls immediately (RFC 9113 §3.4
- /// permits sending requests right after our preface, before the server's
- /// SETTINGS arrive).
- ///
- public static async Task StartAsync(
- IHttpTransport transport, OriginKey origin, ILogger? log = null,
- Action? onClosed = null, CancellationToken ct = default)
- {
- var conn = new H2Connection(transport, origin, log, onClosed);
- await conn._writer.WritePrefaceAndSettingsAsync(
- [
- (H2SettingId.EnablePush, 0),
- (H2SettingId.InitialWindowSize, OurInitialWindowSize),
- (H2SettingId.MaxFrameSize, OurMaxFrameSize),
- (H2SettingId.HeaderTableSize, H2Protocol.DefaultHeaderTableSize),
- ], ct).ConfigureAwait(false);
-
- // Raise the connection-level receive window from its 65535 default.
- await conn._writer.WriteWindowUpdateAsync(0, OurInitialWindowSize - H2Protocol.DefaultInitialWindowSize, ct)
- .ConfigureAwait(false);
-
- // The CancellationToken.None here only governs whether Task.Run starts the
- // delegate; the loop's own cancellation rides on the connection lifetime token.
- conn._readerTask = Task.Run(() => conn.ReaderLoopAsync(conn._lifetimeToken), CancellationToken.None);
- StarlingTelemetry.Counter("net.h2.connections_opened", 1);
- return conn;
- }
-
- /// True while new streams can still be opened on this connection.
- public bool IsUsable
- {
- get
- {
- lock (_lock)
- {
- return !_closed && !_goAwayReceived && _nextStreamId > 0 && _nextStreamId < int.MaxValue;
- }
- }
- }
-
- ///
- /// Open a stream, send the request, and await the assembled response.
- /// Returns a retryable when the
- /// connection went away before this request could be processed, so the
- /// caller can re-dial.
- ///
- public async Task> SendAsync(
- HttpRequest request, StarlingUrl url, CancellationToken ct)
- {
- H2Stream stream;
- var fields = BuildRequestFields(request, url);
- var block = _encoder.Encode(fields);
- var hasBody = !request.Body.IsEmpty;
-
- await _openLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- // Reserve a concurrency slot and allocate a monotonically increasing
- // stream id. Holding _openLock across the HEADERS write guarantees
- // ids reach the wire in increasing order (RFC 9113 §5.1.1).
- while (true)
- {
- var wait = _windowSignal.WaitAsync();
- lock (_lock)
- {
- if (_closed || _goAwayReceived)
- {
- return Result.Err(NetworkError.TransportFailure);
- }
-
- if (_nextStreamId <= 0 || _nextStreamId >= int.MaxValue)
- {
- return Result.Err(NetworkError.TransportFailure);
- }
-
- if (_activeStreams < _peerMaxConcurrentStreams)
- {
- stream = new H2Stream(_nextStreamId) { SendWindow = _peerInitialWindowSize };
- _streams[_nextStreamId] = stream;
- _nextStreamId += 2;
- _activeStreams++;
- break;
- }
- }
- await wait.ConfigureAwait(false);
- }
-
- StarlingTelemetry.Counter("net.h2.requests", 1);
- await _writer.WriteHeadersAsync(stream.Id, block, endStream: !hasBody, _peerMaxFrameSize, ct)
- .ConfigureAwait(false);
- }
- finally
- {
- _openLock.Release();
- }
-
- if (hasBody)
- {
- await SendBodyAsync(stream, request.Body, ct).ConfigureAwait(false);
- }
-
- try
- {
- return await stream.Completion.Task.WaitAsync(ct).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // The caller's token fired, so cancel the stream at the peer and drop
- // our local state. We use the connection lifetime token (not the
- // already-cancelled caller token) so the RST_STREAM still goes out
- // while the connection is alive.
- await SafeWriteAsync(() => _writer.WriteRstStreamAsync(stream.Id, H2ErrorCode.Cancel, _lifetimeToken))
- .ConfigureAwait(false);
- RemoveStream(stream.Id);
- throw;
- }
- }
-
- private async Task SendBodyAsync(H2Stream stream, ReadOnlyMemory body, CancellationToken ct)
- {
- var remaining = body;
- while (!remaining.IsEmpty)
- {
- int budget;
- while (true)
- {
- var wait = _windowSignal.WaitAsync();
- lock (_lock)
- {
- if (_closed)
- {
- return; // stream will be failed by the reader-loop teardown
- }
-
- budget = Math.Min(Math.Min(stream.SendWindow, _connSendWindow), _peerMaxFrameSize);
- budget = Math.Min(budget, remaining.Length);
- if (budget > 0)
- {
- stream.SendWindow -= budget;
- _connSendWindow -= budget;
- break;
- }
- }
- await wait.ConfigureAwait(false);
- }
-
- var chunk = remaining[..budget];
- remaining = remaining[budget..];
- await _writer.WriteDataAsync(stream.Id, chunk, endStream: remaining.IsEmpty, ct)
- .ConfigureAwait(false);
- }
- }
-
- private List<(string, string)> BuildRequestFields(HttpRequest request, StarlingUrl url)
- {
- var scheme = url.IsHttps ? "https" : "http";
- var path = H1.H1RequestWriter.BuildRequestTarget(url);
- var authority = H1.H1RequestWriter.BuildHostHeader(url);
-
- var fields = new List<(string, string)>(request.Headers.Count + 8)
- {
- (":method", request.Method),
- (":scheme", scheme),
- (":authority", authority),
- (":path", path),
- };
-
- var hasUserAgent = false;
- var hasAccept = false;
- var hasAcceptEncoding = false;
- var hasContentLength = false;
-
- foreach (var kv in request.Headers)
- {
- var lower = kv.Key.ToLowerInvariant();
- // RFC 9113 §8.2.2: connection-specific header fields are forbidden;
- // the authority replaces Host.
- if (lower is "connection" or "keep-alive" or "proxy-connection"
- or "transfer-encoding" or "upgrade" or "host")
- {
- continue;
- }
-
- if (lower == "te" && !string.Equals(kv.Value, "trailers", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (lower == "user-agent")
- {
- hasUserAgent = true;
- }
- else if (lower == "accept")
- {
- hasAccept = true;
- }
- else if (lower == "accept-encoding")
- {
- hasAcceptEncoding = true;
- }
- else if (lower == "content-length")
- {
- hasContentLength = true;
- }
-
- fields.Add((lower, kv.Value));
- }
-
- if (!hasUserAgent)
- {
- fields.Add(("user-agent", DefaultUserAgent));
- }
-
- if (!hasAccept)
- {
- fields.Add(("accept", DefaultAccept));
- }
-
- if (!hasAcceptEncoding)
- {
- fields.Add(("accept-encoding", DefaultAcceptEncoding));
- }
- // Send content-length for body-bearing methods even with an empty body.
- // END_STREAM already signals "no DATA", but some origins/WAFs reject an
- // empty POST that lacks content-length (411). Browsers always send "0".
- if (!hasContentLength &&
- (!request.Body.IsEmpty || H1.H1RequestWriter.MethodCarriesBody(request.Method)))
- {
- fields.Add(("content-length",
- request.Body.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)));
- }
-
- return fields;
- }
-
- // ---- Reader loop -------------------------------------------------------
-
- private async Task ReaderLoopAsync(CancellationToken ct)
- {
- try
- {
- while (true)
- {
- RawFrame? maybe;
- try
- {
- maybe = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
- }
- catch (EndOfStreamException)
- {
- break; // truncated frame == peer closed
- }
-
- if (maybe is not { } frame)
- {
- break; // clean EOF
- }
-
- await HandleFrameAsync(frame, ct).ConfigureAwait(false);
- }
-
- CloseAll(NetworkError.TransportFailure);
- }
- catch (OperationCanceledException)
- {
- // The lifetime token fired — the connection is being torn down.
- CloseAll(NetworkError.TransportFailure);
- }
- catch (H2ConnectionException ex)
- {
- await SafeWriteAsync(() => _writer.WriteGoAwayAsync(0, ex.Code, ct))
- .ConfigureAwait(false);
- H2ConnectionLog.ConnectionError(_log, ex.Code.ToString(), ex.Message);
- CloseAll(NetworkError.ProtocolError);
- }
- catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException)
- {
- CloseAll(NetworkError.TransportFailure);
- }
- finally
- {
- // The loop is the sole reader; once it stops the socket is done.
- // Disposing here prevents a leak when the server closes first.
- await SafeDisposeTransportAsync().ConfigureAwait(false);
- }
- }
-
- private async Task HandleFrameAsync(RawFrame frame, CancellationToken ct)
- {
- // While assembling a header block only CONTINUATION frames for the same
- // stream are legal (RFC 9113 §6.10).
- if (_headerStreamId != 0
- && (frame.Type != H2FrameType.Continuation || frame.StreamId != _headerStreamId))
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "expected CONTINUATION");
- }
-
- switch (frame.Type)
- {
- case H2FrameType.Headers: HandleHeaders(frame); break;
- case H2FrameType.Continuation: HandleContinuation(frame); break;
- case H2FrameType.Data: await HandleDataAsync(frame, ct).ConfigureAwait(false); break;
- case H2FrameType.Settings: await HandleSettingsAsync(frame, ct).ConfigureAwait(false); break;
- case H2FrameType.WindowUpdate: HandleWindowUpdate(frame); break;
- case H2FrameType.Ping: await HandlePingAsync(frame, ct).ConfigureAwait(false); break;
- case H2FrameType.GoAway: HandleGoAway(frame); break;
- case H2FrameType.RstStream: HandleRstStream(frame); break;
- case H2FrameType.Priority: break; // ignored
- case H2FrameType.PushPromise:
- // We advertised ENABLE_PUSH=0, so a push is a protocol error.
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "unexpected PUSH_PROMISE");
- default: break; // unknown frame types are ignored (§4.1)
- }
- }
-
- private void HandleHeaders(RawFrame frame)
- {
- if (frame.StreamId == 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS on stream 0");
- }
-
- var payload = frame.Payload.AsSpan();
- var content = StripHeadersPadding(payload, frame.Flags);
-
- _headerFragments.SetLength(0);
- _headerFragments.Write(content);
- _headerStreamId = frame.StreamId;
- _headerEndStream = frame.HasFlag(H2Flags.EndStream);
-
- if (frame.HasFlag(H2Flags.EndHeaders))
- {
- CompleteHeaderBlock();
- }
- }
-
- private void HandleContinuation(RawFrame frame)
- {
- _headerFragments.Write(frame.Payload);
- if (frame.HasFlag(H2Flags.EndHeaders))
- {
- CompleteHeaderBlock();
- }
- }
-
- private void CompleteHeaderBlock()
- {
- var streamId = _headerStreamId;
- var endStream = _headerEndStream;
- var block = _headerFragments.ToArray();
- _headerStreamId = 0;
- _headerFragments.SetLength(0);
-
- // Always decode to keep HPACK state in sync, even if the stream is gone.
- if (!_decoder.TryDecode(block, out var fields))
- {
- throw new H2ConnectionException(H2ErrorCode.CompressionError, "HPACK decode failed");
- }
-
- H2Stream? stream;
- lock (_lock)
- {
- _streams.TryGetValue(streamId, out stream);
- }
-
- if (stream is null)
- {
- return; // unknown/reset stream — fields discarded
- }
-
- if (stream.ResponseHeaders is null)
- {
- var status = ReadStatus(fields);
- if (status is null)
- {
- FailStream(stream, NetworkError.ProtocolError, H2ErrorCode.ProtocolError);
- return;
- }
- if (status is >= 100 and < 200)
- {
- return; // interim (1xx) response — keep waiting for the final one
- }
-
- stream.ResponseHeaders = fields;
- }
- // else: trailers — decoded for state, otherwise ignored (v1).
-
- if (endStream)
- {
- FinishStream(stream);
- }
- }
-
- private async Task HandleDataAsync(RawFrame frame, CancellationToken ct)
- {
- if (frame.StreamId == 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "DATA on stream 0");
- }
-
- var flowLength = frame.Payload.Length; // padding counts toward flow control
- var data = StripDataPadding(frame.Payload.AsSpan(), frame.Flags);
-
- H2Stream? stream;
- lock (_lock)
- {
- _streams.TryGetValue(frame.StreamId, out stream);
- }
-
- if (stream is not null)
- {
- stream.Body.Write(data);
- if (frame.HasFlag(H2Flags.EndStream))
- {
- FinishStream(stream);
- }
- }
-
- // Replenish receive windows so the peer can keep sending.
- if (flowLength > 0)
- {
- await _writer.WriteWindowUpdateAsync(0, flowLength, ct).ConfigureAwait(false);
- if (stream is not null && !frame.HasFlag(H2Flags.EndStream))
- {
- await _writer.WriteWindowUpdateAsync(frame.StreamId, flowLength, ct)
- .ConfigureAwait(false);
- }
- }
- }
-
- private async Task HandleSettingsAsync(RawFrame frame, CancellationToken ct)
- {
- if (frame.StreamId != 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "SETTINGS on non-zero stream");
- }
-
- if (frame.HasFlag(H2Flags.Ack))
- {
- if (frame.Payload.Length != 0)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "SETTINGS ACK with payload");
- }
-
- return;
- }
- if (frame.Payload.Length % 6 != 0)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "SETTINGS length not a multiple of 6");
- }
-
- var p = frame.Payload;
- for (var i = 0; i < p.Length; i += 6)
- {
- var id = (H2SettingId)((p[i] << 8) | p[i + 1]);
- var value = ((uint)p[i + 2] << 24) | ((uint)p[i + 3] << 16) | ((uint)p[i + 4] << 8) | p[i + 5];
- ApplyPeerSetting(id, value);
- }
-
- _windowSignal.Pulse();
- await _writer.WriteSettingsAckAsync(ct).ConfigureAwait(false);
- }
-
- private void ApplyPeerSetting(H2SettingId id, uint value)
- {
- switch (id)
- {
- case H2SettingId.InitialWindowSize:
- if (value > int.MaxValue)
- {
- throw new H2ConnectionException(H2ErrorCode.FlowControlError, "INITIAL_WINDOW_SIZE too large");
- }
-
- lock (_lock)
- {
- var delta = (int)value - _peerInitialWindowSize;
- _peerInitialWindowSize = (int)value;
- foreach (var s in _streams.Values)
- {
- s.SendWindow += delta; // RFC 9113 §6.9.2
- }
- }
- break;
- case H2SettingId.MaxFrameSize:
- if (value is < H2Protocol.DefaultMaxFrameSize or > H2Protocol.MaxAllowedFrameSize)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "invalid MAX_FRAME_SIZE");
- }
-
- lock (_lock)
- {
- _peerMaxFrameSize = (int)value;
- }
-
- break;
- case H2SettingId.MaxConcurrentStreams:
- lock (_lock)
- {
- _peerMaxConcurrentStreams = value;
- }
-
- break;
- case H2SettingId.EnablePush:
- if (value > 1)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "invalid ENABLE_PUSH");
- }
-
- break;
- case H2SettingId.HeaderTableSize:
- case H2SettingId.MaxHeaderListSize:
- default:
- break; // our encoder uses no dynamic table; no list-size cap enforced
- }
- }
-
- private void HandleWindowUpdate(RawFrame frame)
- {
- if (frame.Payload.Length != 4)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "WINDOW_UPDATE length != 4");
- }
-
- var increment = (int)(((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16
- | (uint)frame.Payload[2] << 8 | frame.Payload[3]) & 0x7fff_ffff);
-
- if (frame.StreamId == 0)
- {
- if (increment == 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "0 connection WINDOW_UPDATE");
- }
-
- lock (_lock)
- {
- var updated = (long)_connSendWindow + increment;
- if (updated > H2Protocol.MaxWindowSize)
- {
- throw new H2ConnectionException(H2ErrorCode.FlowControlError, "connection window overflow");
- }
-
- _connSendWindow = (int)updated;
- }
- _windowSignal.Pulse();
- return;
- }
-
- H2Stream? stream;
- lock (_lock)
- {
- _streams.TryGetValue(frame.StreamId, out stream);
- if (stream is not null && increment > 0)
- {
- stream.SendWindow = (int)Math.Min((long)stream.SendWindow + increment, H2Protocol.MaxWindowSize);
- }
- }
- if (increment > 0)
- {
- _windowSignal.Pulse();
- }
- }
-
- private async Task HandlePingAsync(RawFrame frame, CancellationToken ct)
- {
- if (frame.StreamId != 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "PING on non-zero stream");
- }
-
- if (frame.Payload.Length != 8)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "PING length != 8");
- }
-
- if (!frame.HasFlag(H2Flags.Ack))
- {
- await _writer.WritePingAckAsync(frame.Payload, ct).ConfigureAwait(false);
- }
- }
-
- private void HandleGoAway(RawFrame frame)
- {
- if (frame.Payload.Length < 8)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "GOAWAY too short");
- }
-
- var lastStreamId = (int)(((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16
- | (uint)frame.Payload[2] << 8 | frame.Payload[3]) & 0x7fff_ffff);
-
- List refused;
- lock (_lock)
- {
- _goAwayReceived = true;
- // Streams above lastStreamId were never processed — safe to retry.
- refused = _streams.Values.Where(s => s.Id > lastStreamId).ToList();
- }
- foreach (var s in refused)
- {
- FailStream(s, NetworkError.TransportFailure, rstCode: null);
- }
-
- _windowSignal.Pulse();
- }
-
- private void HandleRstStream(RawFrame frame)
- {
- if (frame.StreamId == 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "RST_STREAM on stream 0");
- }
-
- if (frame.Payload.Length != 4)
- {
- throw new H2ConnectionException(H2ErrorCode.FrameSizeError, "RST_STREAM length != 4");
- }
-
- var code = (H2ErrorCode)((uint)frame.Payload[0] << 24 | (uint)frame.Payload[1] << 16
- | (uint)frame.Payload[2] << 8 | frame.Payload[3]);
-
- H2Stream? stream;
- lock (_lock)
- {
- _streams.TryGetValue(frame.StreamId, out stream);
- }
-
- if (stream is not null)
- {
- var err = code == H2ErrorCode.RefusedStream
- ? NetworkError.TransportFailure // never processed — retryable
- : NetworkError.ProtocolError;
- FailStream(stream, err, rstCode: null);
- }
- }
-
- // ---- Stream completion -------------------------------------------------
-
- private void FinishStream(H2Stream stream)
- {
- lock (_lock)
- {
- if (stream.Finished)
- {
- return;
- }
-
- stream.Finished = true;
- _streams.Remove(stream.Id);
- _activeStreams--;
- }
- _windowSignal.Pulse();
-
- var result = BuildResponse(stream);
- stream.Completion.TrySetResult(result);
- }
-
- private void FailStream(H2Stream stream, NetworkError error, H2ErrorCode? rstCode)
- {
- bool first;
- lock (_lock)
- {
- first = !stream.Finished;
- if (first)
- {
- stream.Finished = true;
- _streams.Remove(stream.Id);
- _activeStreams--;
- }
- }
- if (!first)
- {
- return;
- }
-
- _windowSignal.Pulse();
-
- if (rstCode is { } rc)
- {
- _ = SafeWriteAsync(() => _writer.WriteRstStreamAsync(stream.Id, rc, _lifetimeToken));
- }
-
- stream.Completion.TrySetResult(Result.Err(error));
- }
-
- private static Result BuildResponse(H2Stream stream)
- {
- var fields = stream.ResponseHeaders!;
- var status = ReadStatus(fields)!.Value;
-
- var headers = new HttpHeaders();
- foreach (var f in fields)
- {
- if (f.Name.Length == 0 || f.Name[0] == ':')
- {
- continue; // skip pseudo-headers
- }
-
- try { headers.Add(f.Name, f.Value); }
- catch (ArgumentException) { return Result.Err(NetworkError.ProtocolError); }
- }
-
- var raw = stream.Body.ToArray();
- byte[] decoded;
- try
- {
- var encodings = BodyDecoder.ParseEncodings(headers.GetFirst("Content-Encoding"));
- decoded = encodings.Count == 0 ? raw : BodyDecoder.Decode(raw, encodings);
- }
- catch (Exception ex) when (ex is NotSupportedException or InvalidDataException)
- {
- return Result.Err(NetworkError.ProtocolError);
- }
-
- return Result.Ok(
- new HttpResponse("HTTP/2", status, string.Empty, headers, decoded));
- }
-
- private static int? ReadStatus(List fields)
- {
- foreach (var f in fields)
- {
- if (!string.Equals(f.Name, ":status", StringComparison.Ordinal))
- {
- continue;
- }
-
- return int.TryParse(f.Value, NumberStyles.None, CultureInfo.InvariantCulture, out var code)
- && code is >= 100 and <= 599
- ? code
- : null;
- }
- return null;
- }
-
- // ---- Teardown ----------------------------------------------------------
-
- private void CloseAll(NetworkError error)
- {
- List pending;
- lock (_lock)
- {
- if (_closed)
- {
- return;
- }
-
- _closed = true;
- pending = [.. _streams.Values];
- _streams.Clear();
- _activeStreams = 0;
- }
- // Stop the reader loop and any in-flight control-frame writes.
- _lifetime.Cancel();
- _windowSignal.Pulse();
- foreach (var s in pending)
- {
- if (!s.Finished)
- {
- s.Finished = true;
- s.Completion.TrySetResult(Result.Err(error));
- }
- }
- _onClosed?.Invoke(this);
- }
-
- private void RemoveStream(int streamId)
- {
- lock (_lock)
- {
- if (_streams.Remove(streamId))
- {
- _activeStreams--;
- }
- }
- _windowSignal.Pulse();
- }
-
- private async Task SafeDisposeTransportAsync()
- {
- if (Interlocked.Exchange(ref _transportDisposed, 1) != 0)
- {
- return;
- }
-
- try { await _transport.DisposeAsync().ConfigureAwait(false); }
- catch (Exception ex) { H2ConnectionLog.TransportDisposeFailed(_log, ex); /* a half-broken socket may throw on shutdown */ }
- }
-
- private async Task SafeWriteAsync(Func write)
- {
- try { await write().ConfigureAwait(false); }
- catch (Exception ex) { H2ConnectionLog.SafeWriteFailed(_log, ex); /* best-effort control frame on a dying connection */ }
- }
-
- private static Span StripHeadersPadding(Span payload, H2Flags flags)
- {
- var offset = 0;
- var padLength = 0;
- if ((flags & H2Flags.Padded) != 0)
- {
- if (payload.Length < 1)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "padded HEADERS too short");
- }
-
- padLength = payload[0];
- offset = 1;
- }
- if ((flags & H2Flags.Priority) != 0)
- {
- if (payload.Length < offset + 5)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS priority too short");
- }
-
- offset += 5;
- }
- var contentLength = payload.Length - offset - padLength;
- if (contentLength < 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "HEADERS padding exceeds frame");
- }
-
- return payload.Slice(offset, contentLength);
- }
-
- private static Span StripDataPadding(Span payload, H2Flags flags)
- {
- if ((flags & H2Flags.Padded) == 0)
- {
- return payload;
- }
-
- if (payload.Length < 1)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "padded DATA too short");
- }
-
- var padLength = payload[0];
- var contentLength = payload.Length - 1 - padLength;
- if (contentLength < 0)
- {
- throw new H2ConnectionException(H2ErrorCode.ProtocolError, "DATA padding exceeds frame");
- }
-
- return payload.Slice(1, contentLength);
- }
-
- public async ValueTask DisposeAsync()
- {
- // Graceful goodbye while the connection is still alive; if the reader loop
- // already tore down (lifetime cancelled), this write is skipped.
- await SafeWriteAsync(() => _writer.WriteGoAwayAsync(0, H2ErrorCode.NoError, _lifetimeToken))
- .ConfigureAwait(false);
- CloseAll(NetworkError.TransportFailure);
- // Dispose the transport first to unblock the reader loop's pending
- // ReadAsync, then wait for the loop to exit before tearing down the
- // writer it might still touch.
- await SafeDisposeTransportAsync().ConfigureAwait(false);
- try { await _readerTask.ConfigureAwait(false); }
- catch (Exception ex) { H2ConnectionLog.ReaderTaskCleanupFailed(_log, ex); /* loop teardown */ }
- _writer.Dispose();
- _openLock.Dispose();
- _lifetime.Dispose();
- }
-}
diff --git a/src/Starling.Net/Http/H2/H2ConnectionManager.cs b/src/Starling.Net/Http/H2/H2ConnectionManager.cs
deleted file mode 100644
index 9718d564..00000000
--- a/src/Starling.Net/Http/H2/H2ConnectionManager.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace Starling.Net.Http.H2;
-
-internal static partial class H2ConnectionManagerLog
-{
- [LoggerMessage(Level = LogLevel.Debug, Message = "H2 connection dispose threw during teardown")]
- public static partial void ConnectionDisposeFailed(ILogger logger, Exception ex);
-}
-
-///
-/// Holds at most one live per origin. Unlike the
-/// HTTP/1.1 (a queue of idle single-use sockets),
-/// an HTTP/2 connection is long-lived and multiplexed, so the "pool" is just a
-/// per-origin map of shared connections.
-///
-internal sealed class H2ConnectionManager : IAsyncDisposable
-{
- private readonly object _gate = new();
- private readonly Dictionary _byOrigin = [];
- private readonly ILogger _log;
- private bool _disposed;
-
- public H2ConnectionManager(ILogger? log = null)
- {
- _log = log ?? NullLogger.Instance;
- }
-
- /// Return the live, usable connection for an origin, or null.
- public H2Connection? TryGet(OriginKey origin)
- {
- lock (_gate)
- {
- if (_disposed)
- {
- return null;
- }
-
- if (!_byOrigin.TryGetValue(origin, out var conn))
- {
- return null;
- }
-
- if (conn.IsUsable)
- {
- return conn;
- }
- // Stale (GOAWAY/closed) — drop the reference; it disposes itself.
- _byOrigin.Remove(origin);
- return null;
- }
- }
-
- ///
- /// Register as the connection for its origin,
- /// unless another usable connection won a concurrent race — in which case
- /// the winner is returned and the caller must dispose the candidate.
- ///
- public H2Connection Adopt(OriginKey origin, H2Connection candidate)
- {
- lock (_gate)
- {
- if (!_disposed
- && _byOrigin.TryGetValue(origin, out var existing)
- && existing.IsUsable)
- {
- return existing;
- }
- _byOrigin[origin] = candidate;
- return candidate;
- }
- }
-
- /// Drop a connection (called from its close callback) if still mapped.
- public void Remove(OriginKey origin, H2Connection conn)
- {
- lock (_gate)
- {
- if (_byOrigin.TryGetValue(origin, out var current) && ReferenceEquals(current, conn))
- {
- _byOrigin.Remove(origin);
- }
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- List all;
- lock (_gate)
- {
- _disposed = true;
- all = [.. _byOrigin.Values];
- _byOrigin.Clear();
- }
- foreach (var c in all)
- {
- try { await c.DisposeAsync().ConfigureAwait(false); }
- catch (Exception ex) { H2ConnectionManagerLog.ConnectionDisposeFailed(_log, ex); /* teardown of a dying connection */ }
- }
- }
-}
diff --git a/src/Starling.Net/Http/H2/H2Exception.cs b/src/Starling.Net/Http/H2/H2Exception.cs
deleted file mode 100644
index f6765630..00000000
--- a/src/Starling.Net/Http/H2/H2Exception.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace Starling.Net.Http.H2;
-
-// RCS1194: this is a purpose-built internal signal that always carries an
-// H2ErrorCode, so the standard parameterless / inner-exception constructors
-// don't apply.
-#pragma warning disable RCS1194
-///
-/// A connection-level HTTP/2 error (RFC 9113 §5.4.1): fatal to the whole
-/// connection. Carries the error code we will report in a GOAWAY frame.
-///
-internal sealed class H2ConnectionException(H2ErrorCode code, string message) : Exception(message)
-{
- public H2ErrorCode Code { get; } = code;
-}
-#pragma warning restore RCS1194
diff --git a/src/Starling.Net/Http/H2/H2FrameReader.cs b/src/Starling.Net/Http/H2/H2FrameReader.cs
deleted file mode 100644
index c9e9c440..00000000
--- a/src/Starling.Net/Http/H2/H2FrameReader.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-namespace Starling.Net.Http.H2;
-
-/// One frame read off the wire: header fields plus the raw payload.
-internal readonly struct RawFrame(H2FrameType type, H2Flags flags, int streamId, byte[] payload)
-{
- public H2FrameType Type { get; } = type;
- public H2Flags Flags { get; } = flags;
- public int StreamId { get; } = streamId;
- public byte[] Payload { get; } = payload;
-
- public bool HasFlag(H2Flags flag) => (Flags & flag) == flag;
-}
-
-///
-/// Reads HTTP/2 frames (RFC 9113 §4.1) from a byte stream. Each call returns
-/// the next whole frame or null on a clean end-of-stream. A frame whose length
-/// exceeds the negotiated maximum is a connection-level FRAME_SIZE_ERROR.
-///
-internal sealed class H2FrameReader(Stream stream, int maxFrameSize)
-{
- private readonly byte[] _header = new byte[H2Protocol.FrameHeaderLength];
-
- public async Task ReadFrameAsync(CancellationToken ct)
- {
- if (!await TryReadExactAsync(_header, ct).ConfigureAwait(false))
- {
- return null; // clean EOF between frames
- }
-
- var length = (_header[0] << 16) | (_header[1] << 8) | _header[2];
- var type = (H2FrameType)_header[3];
- var flags = (H2Flags)_header[4];
- var streamId =
- ((_header[5] & 0x7f) << 24) | (_header[6] << 16) | (_header[7] << 8) | _header[8];
-
- if (length > maxFrameSize)
- {
- throw new H2ConnectionException(
- H2ErrorCode.FrameSizeError, $"frame length {length} exceeds max {maxFrameSize}");
- }
-
- var payload = length == 0 ? [] : new byte[length];
- if (length > 0 && !await TryReadExactAsync(payload, ct).ConfigureAwait(false))
- {
- return null; // truncated payload — treat as connection closed
- }
-
- return new RawFrame(type, flags, streamId, payload);
- }
-
- private async Task TryReadExactAsync(Memory buffer, CancellationToken ct)
- {
- var read = 0;
- while (read < buffer.Length)
- {
- var n = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false);
- if (n == 0)
- {
- return read == 0 ? false : throw new EndOfStreamException("truncated HTTP/2 frame");
- }
-
- read += n;
- }
- return true;
- }
-}
diff --git a/src/Starling.Net/Http/H2/H2FrameWriter.cs b/src/Starling.Net/Http/H2/H2FrameWriter.cs
deleted file mode 100644
index 655f6da9..00000000
--- a/src/Starling.Net/Http/H2/H2FrameWriter.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-namespace Starling.Net.Http.H2;
-
-///
-/// Serializes and writes HTTP/2 frames to the connection's byte stream. All
-/// writes are serialized through a single semaphore so frames never interleave
-/// at the octet level, and a HEADERS block plus its CONTINUATION frames are
-/// emitted contiguously (RFC 9113 §6.10) by holding the lock across the whole
-/// sequence.
-///
-internal sealed class H2FrameWriter(Stream stream) : IDisposable
-{
- private readonly SemaphoreSlim _writeLock = new(1, 1);
-
- /// Send the client preface immediately followed by our SETTINGS frame.
- public async Task WritePrefaceAndSettingsAsync(
- IReadOnlyList<(H2SettingId Id, uint Value)> settings, CancellationToken ct)
- {
- await _writeLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- await stream.WriteAsync(H2Protocol.ClientPreface.ToArray(), ct).ConfigureAwait(false);
- await WriteSettingsLockedAsync(settings, ct).ConfigureAwait(false);
- await stream.FlushAsync(ct).ConfigureAwait(false);
- }
- finally { _writeLock.Release(); }
- }
-
- public Task WriteSettingsAckAsync(CancellationToken ct) =>
- WriteSimpleAsync(H2FrameType.Settings, H2Flags.Ack, 0, ReadOnlyMemory.Empty, ct);
-
- public Task WritePingAckAsync(byte[] opaqueData, CancellationToken ct) =>
- WriteSimpleAsync(H2FrameType.Ping, H2Flags.Ack, 0, opaqueData, ct);
-
- public Task WriteRstStreamAsync(int streamId, H2ErrorCode code, CancellationToken ct)
- {
- var payload = new byte[4];
- WriteUInt32(payload, (uint)code);
- return WriteSimpleAsync(H2FrameType.RstStream, H2Flags.None, streamId, payload, ct);
- }
-
- public Task WriteWindowUpdateAsync(int streamId, int increment, CancellationToken ct)
- {
- var payload = new byte[4];
- WriteUInt32(payload, (uint)increment);
- return WriteSimpleAsync(H2FrameType.WindowUpdate, H2Flags.None, streamId, payload, ct);
- }
-
- public Task WriteGoAwayAsync(int lastStreamId, H2ErrorCode code, CancellationToken ct)
- {
- var payload = new byte[8];
- WriteUInt32(payload.AsSpan(0), (uint)lastStreamId);
- WriteUInt32(payload.AsSpan(4), (uint)code);
- _ = WriteSimpleAsync(H2FrameType.GoAway, H2Flags.None, 0, payload, ct);
-
- // don't wait on the GOAWAY
- return Task.CompletedTask;
- }
-
- /// Write one DATA frame for .
- public Task WriteDataAsync(int streamId, ReadOnlyMemory data, bool endStream, CancellationToken ct) =>
- WriteSimpleAsync(
- H2FrameType.Data, endStream ? H2Flags.EndStream : H2Flags.None, streamId, data, ct);
-
- ///
- /// Write a HEADERS frame, splitting into CONTINUATION frames when the
- /// encoded block exceeds the peer's max frame size. END_STREAM (if set)
- /// rides on the HEADERS frame; END_HEADERS rides on the final fragment.
- ///
- public async Task WriteHeadersAsync(
- int streamId, ReadOnlyMemory block, bool endStream, int peerMaxFrameSize, CancellationToken ct)
- {
- await _writeLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- var first = Math.Min(block.Length, peerMaxFrameSize);
- var isOnly = first == block.Length;
- var flags = (endStream ? H2Flags.EndStream : H2Flags.None)
- | (isOnly ? H2Flags.EndHeaders : H2Flags.None);
- await WriteFrameLockedAsync(H2FrameType.Headers, flags, streamId, block[..first], ct)
- .ConfigureAwait(false);
-
- var offset = first;
- while (offset < block.Length)
- {
- var n = Math.Min(block.Length - offset, peerMaxFrameSize);
- var last = offset + n == block.Length;
- await WriteFrameLockedAsync(
- H2FrameType.Continuation,
- last ? H2Flags.EndHeaders : H2Flags.None,
- streamId,
- block.Slice(offset, n),
- ct).ConfigureAwait(false);
- offset += n;
- }
-
- await stream.FlushAsync(ct).ConfigureAwait(false);
- }
- finally { _writeLock.Release(); }
- }
-
- private async Task WriteSimpleAsync(
- H2FrameType type, H2Flags flags, int streamId, ReadOnlyMemory payload, CancellationToken ct)
- {
- await _writeLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- await WriteFrameLockedAsync(type, flags, streamId, payload, ct).ConfigureAwait(false);
- await stream.FlushAsync(ct).ConfigureAwait(false);
- }
- finally { _writeLock.Release(); }
- }
-
- private async Task WriteSettingsLockedAsync(
- IReadOnlyList<(H2SettingId Id, uint Value)> settings, CancellationToken ct)
- {
- var payload = new byte[settings.Count * 6];
- for (var i = 0; i < settings.Count; i++)
- {
- var o = i * 6;
- payload[o] = (byte)((ushort)settings[i].Id >> 8);
- payload[o + 1] = (byte)(ushort)settings[i].Id;
- WriteUInt32(payload.AsSpan(o + 2), settings[i].Value);
- }
- await WriteFrameLockedAsync(H2FrameType.Settings, H2Flags.None, 0, payload, ct)
- .ConfigureAwait(false);
- }
-
- private async Task WriteFrameLockedAsync(
- H2FrameType type, H2Flags flags, int streamId, ReadOnlyMemory payload, CancellationToken ct)
- {
- var header = new byte[H2Protocol.FrameHeaderLength];
- var len = payload.Length;
- header[0] = (byte)(len >> 16);
- header[1] = (byte)(len >> 8);
- header[2] = (byte)len;
- header[3] = (byte)type;
- header[4] = (byte)flags;
- WriteUInt32(header.AsSpan(5), (uint)streamId & 0x7fff_ffff);
-
- await stream.WriteAsync(header, ct).ConfigureAwait(false);
- if (len > 0)
- {
- await stream.WriteAsync(payload, ct).ConfigureAwait(false);
- }
- }
-
- private static void WriteUInt32(Span dst, uint value)
- {
- dst[0] = (byte)(value >> 24);
- dst[1] = (byte)(value >> 16);
- dst[2] = (byte)(value >> 8);
- dst[3] = (byte)value;
- }
-
- public void Dispose() => _writeLock.Dispose();
-}
diff --git a/src/Starling.Net/Http/H2/H2Protocol.cs b/src/Starling.Net/Http/H2/H2Protocol.cs
deleted file mode 100644
index 19b4b8c0..00000000
--- a/src/Starling.Net/Http/H2/H2Protocol.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-namespace Starling.Net.Http.H2;
-
-/// HTTP/2 frame types (RFC 9113 §6).
-internal enum H2FrameType : byte
-{
- Data = 0x0,
- Headers = 0x1,
- Priority = 0x2,
- RstStream = 0x3,
- Settings = 0x4,
- PushPromise = 0x5,
- Ping = 0x6,
- GoAway = 0x7,
- WindowUpdate = 0x8,
- Continuation = 0x9,
-}
-
-/// HTTP/2 frame flag bits (RFC 9113 §6). Meanings are per-frame-type.
-[Flags]
-internal enum H2Flags : byte
-{
- None = 0,
- Ack = 0x1, // SETTINGS, PING
- EndStream = 0x1, // DATA, HEADERS
- EndHeaders = 0x4, // HEADERS, CONTINUATION, PUSH_PROMISE
- Padded = 0x8, // DATA, HEADERS, PUSH_PROMISE
- Priority = 0x20, // HEADERS
-}
-
-/// HTTP/2 error codes (RFC 9113 §7).
-internal enum H2ErrorCode : uint
-{
- NoError = 0x0,
- ProtocolError = 0x1,
- InternalError = 0x2,
- FlowControlError = 0x3,
- SettingsTimeout = 0x4,
- StreamClosed = 0x5,
- FrameSizeError = 0x6,
- RefusedStream = 0x7,
- Cancel = 0x8,
- CompressionError = 0x9,
- ConnectError = 0xa,
- EnhanceYourCalm = 0xb,
- InadequateSecurity = 0xc,
- Http11Required = 0xd,
-}
-
-/// SETTINGS parameter identifiers (RFC 9113 §6.5.2).
-internal enum H2SettingId : ushort
-{
- HeaderTableSize = 0x1,
- EnablePush = 0x2,
- MaxConcurrentStreams = 0x3,
- InitialWindowSize = 0x4,
- MaxFrameSize = 0x5,
- MaxHeaderListSize = 0x6,
-}
-
-/// Wire constants and protocol defaults (RFC 9113).
-internal static class H2Protocol
-{
- /// Client connection preface (RFC 9113 §3.4), 24 octets.
- public static ReadOnlySpan ClientPreface =>
- "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8;
-
- /// Fixed frame-header length: 24-bit length + type + flags + 31-bit stream id.
- public const int FrameHeaderLength = 9;
-
- /// Default and minimum SETTINGS_MAX_FRAME_SIZE (RFC 9113 §6.5.2), 2^14.
- public const int DefaultMaxFrameSize = 16_384;
-
- /// Largest permitted SETTINGS_MAX_FRAME_SIZE, 2^24 - 1.
- public const int MaxAllowedFrameSize = 16_777_215;
-
- /// Default SETTINGS_INITIAL_WINDOW_SIZE (RFC 9113 §6.9.2), 2^16 - 1.
- public const int DefaultInitialWindowSize = 65_535;
-
- /// Largest legal flow-control window; exceeding it is a FLOW_CONTROL_ERROR.
- public const int MaxWindowSize = int.MaxValue; // 2^31 - 1
-
- /// Default SETTINGS_HEADER_TABLE_SIZE (RFC 7541 §4.2).
- public const int DefaultHeaderTableSize = 4_096;
-}
diff --git a/src/Starling.Net/Http/H2/H2Stream.cs b/src/Starling.Net/Http/H2/H2Stream.cs
deleted file mode 100644
index 5a64f341..00000000
--- a/src/Starling.Net/Http/H2/H2Stream.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Starling.Common;
-using Starling.Net.Http.H2.Hpack;
-
-namespace Starling.Net.Http.H2;
-
-///
-/// State for a single client-initiated HTTP/2 stream: the response being
-/// assembled from HEADERS/DATA frames, the send-side flow-control window for
-/// any request body, and the completion source the caller awaits.
-///
-///
-/// Mutable fields are written by the connection's single reader loop or under
-/// the connection lock; the caller only reads .
-///
-internal sealed class H2Stream(int id)
-{
- public int Id { get; } = id;
-
- /// Completed once the full response is assembled, or on stream/connection failure.
- public TaskCompletionSource> Completion { get; } =
- new(TaskCreationOptions.RunContinuationsAsynchronously);
-
- /// Final (non-1xx) response headers, set when the response HEADERS block decodes.
- public List? ResponseHeaders { get; set; }
-
- /// Accumulated, de-padded response body bytes (still content-encoded).
- public MemoryStream Body { get; } = new();
-
- /// Remaining peer flow-control credit for sending this stream's body.
- public int SendWindow { get; set; }
-
- /// True once the result has been published, to guard double-completion.
- public bool Finished { get; set; }
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs b/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs
deleted file mode 100644
index 7df3aa62..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackDecoder.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-using System.Text;
-
-namespace Starling.Net.Http.H2.Hpack;
-
-/// A single decoded header field.
-internal readonly record struct HpackHeaderField(string Name, string Value, bool NeverIndexed);
-
-///
-/// HPACK decoder (RFC 7541 §3 / §6). Stateful: one decoder mirrors the peer
-/// encoder's dynamic table across an entire connection, so header blocks must
-/// be fed in the order they arrive on the wire. Header octets are interpreted
-/// as Latin-1 so each octet maps to exactly one char — keeping the dynamic
-/// table's size accounting (octet length) equal to string.Length.
-///
-internal sealed class HpackDecoder
-{
- private readonly HpackDynamicTable _dynamic;
- private readonly int _maxAllowedTableSize;
-
- public HpackDecoder(int maxDynamicTableSize)
- {
- _maxAllowedTableSize = maxDynamicTableSize;
- _dynamic = new HpackDynamicTable(maxDynamicTableSize);
- }
-
- ///
- /// Decode one complete header block into . Returns
- /// false on any malformed input (RFC 7541 calls these COMPRESSION_ERROR):
- /// truncation, a zero or out-of-range index, an invalid Huffman string, or
- /// a dynamic-table size update above the negotiated maximum.
- ///
- public bool TryDecode(ReadOnlySpan block, out List fields)
- {
- fields = [];
- var offset = 0;
-
- while (offset < block.Length)
- {
- var b = block[offset];
-
- if ((b & 0x80) != 0)
- {
- // §6.1 Indexed Header Field.
- if (!HpackInteger.TryDecode(block, ref offset, 7, out var index) || index == 0)
- {
- return false;
- }
-
- if (!Resolve(index, out var name, out var value))
- {
- return false;
- }
-
- fields.Add(new HpackHeaderField(name, value, NeverIndexed: false));
- }
- else if ((b & 0x40) != 0)
- {
- // §6.2.1 Literal Header Field with Incremental Indexing.
- if (!ReadLiteral(block, ref offset, 6, out var name, out var value))
- {
- return false;
- }
-
- _dynamic.Add(name, value, name.Length, value.Length);
- fields.Add(new HpackHeaderField(name, value, NeverIndexed: false));
- }
- else if ((b & 0x20) != 0)
- {
- // §6.3 Dynamic Table Size Update.
- if (!HpackInteger.TryDecode(block, ref offset, 5, out var newSize))
- {
- return false;
- }
-
- if (newSize > _maxAllowedTableSize)
- {
- return false;
- }
-
- _dynamic.Resize(newSize);
- }
- else
- {
- // §6.2.2 (0x00) without indexing, §6.2.3 (0x10) never indexed.
- var neverIndexed = (b & 0x10) != 0;
- if (!ReadLiteral(block, ref offset, 4, out var name, out var value))
- {
- return false;
- }
-
- fields.Add(new HpackHeaderField(name, value, neverIndexed));
- }
- }
-
- return true;
- }
-
- /// Read a literal representation whose name is either an index or an inline string.
- private bool ReadLiteral(
- ReadOnlySpan block, ref int offset, int namePrefixBits, out string name, out string value)
- {
- name = string.Empty;
- value = string.Empty;
-
- if (!HpackInteger.TryDecode(block, ref offset, namePrefixBits, out var nameIndex))
- {
- return false;
- }
-
- if (nameIndex == 0)
- {
- if (!TryReadString(block, ref offset, out name))
- {
- return false;
- }
- }
- else if (!Resolve(nameIndex, out name, out _))
- {
- return false;
- }
-
- return TryReadString(block, ref offset, out value);
- }
-
- /// Resolve a combined (static + dynamic) index to a name/value pair.
- private bool Resolve(int index, out string name, out string value)
- {
- if (index <= HpackStaticTable.Count)
- {
- return HpackStaticTable.TryGet(index, out name, out value);
- }
-
- return _dynamic.TryGet(index - HpackStaticTable.Count, out name, out value);
- }
-
- /// Read a length-prefixed (optionally Huffman-coded) string literal (§5.2).
- private static bool TryReadString(ReadOnlySpan block, ref int offset, out string result)
- {
- result = string.Empty;
- if (offset >= block.Length)
- {
- return false;
- }
-
- var huffman = (block[offset] & 0x80) != 0;
- if (!HpackInteger.TryDecode(block, ref offset, 7, out var length))
- {
- return false;
- }
-
- if (length < 0 || offset + length > block.Length)
- {
- return false;
- }
-
- var raw = block.Slice(offset, length);
- offset += length;
-
- if (huffman)
- {
- if (!HpackHuffman.TryDecode(raw, out var decoded))
- {
- return false;
- }
-
- result = Encoding.Latin1.GetString(decoded);
- }
- else
- {
- result = Encoding.Latin1.GetString(raw);
- }
- return true;
- }
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs b/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs
deleted file mode 100644
index 44374074..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackDynamicTable.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-namespace Starling.Net.Http.H2.Hpack;
-
-///
-/// HPACK dynamic table (RFC 7541 §2.3.2 / §4): a FIFO of recently seen header
-/// fields that follows the static table in the index space. Newly inserted
-/// entries get the lowest dynamic index (combined index 62); the table evicts
-/// oldest-first to stay within its size bound.
-///
-///
-/// One direction of HPACK state. A decoder owns one of these to mirror the
-/// peer encoder's table; entry size accounting (name octets + value octets +
-/// 32) must match the encoder exactly or the shared index space drifts.
-///
-internal sealed class HpackDynamicTable
-{
- private const int EntryOverhead = 32; // RFC 7541 §4.1
-
- // _entries[0] is the newest entry (dynamic index 1 / combined index 62).
- private readonly List _entries = [];
-
- public HpackDynamicTable(int maxSize)
- {
- MaxSize = maxSize;
- }
-
- /// Current upper bound on , in octets.
- public int MaxSize { get; private set; }
-
- /// Sum of all entry sizes currently held.
- public int Size { get; private set; }
-
- /// Number of entries currently held.
- public int Count => _entries.Count;
-
- ///
- /// Insert a header field at the front. Evicts oldest entries until it fits;
- /// if the entry alone exceeds the table is emptied
- /// and nothing is inserted (RFC 7541 §4.4).
- ///
- public void Add(string name, string value, int nameOctets, int valueOctets)
- {
- var entrySize = nameOctets + valueOctets + EntryOverhead;
- EvictTo(MaxSize - entrySize);
- if (entrySize > MaxSize)
- {
- return; // doesn't fit even in an empty table
- }
-
- _entries.Insert(0, new Entry(name, value, entrySize));
- Size += entrySize;
- }
-
- ///
- /// Resize the table per a dynamic table size update (RFC 7541 §6.3),
- /// evicting oldest entries as needed to satisfy the new bound.
- ///
- public void Resize(int newMaxSize)
- {
- MaxSize = newMaxSize;
- EvictTo(MaxSize);
- }
-
- ///
- /// Resolve a 1-based dynamic index (1 == newest). Returns false if out of
- /// range.
- ///
- public bool TryGet(int dynamicIndex, out string name, out string value)
- {
- if (dynamicIndex < 1 || dynamicIndex > _entries.Count)
- {
- name = string.Empty;
- value = string.Empty;
- return false;
- }
- var e = _entries[dynamicIndex - 1];
- name = e.Name;
- value = e.Value;
- return true;
- }
-
- private void EvictTo(int target)
- {
- while (Size > target && _entries.Count > 0)
- {
- var last = _entries[^1];
- _entries.RemoveAt(_entries.Count - 1);
- Size -= last.Size;
- }
- }
-
- private readonly record struct Entry(string Name, string Value, int Size);
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs b/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs
deleted file mode 100644
index dbecbc39..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackEncoder.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System.Text;
-
-namespace Starling.Net.Http.H2.Hpack;
-
-///
-/// HPACK encoder (RFC 7541 §6). Deliberately simple and stateless: it indexes
-/// the static table for exact and name matches but never inserts into a dynamic
-/// table, so it carries no per-connection state and cannot drift. String
-/// literals are Huffman-coded only when that is strictly shorter. This is a
-/// fully conformant encoding — the peer decodes it the same regardless of the
-/// indexing strategy we choose.
-///
-internal sealed class HpackEncoder
-{
- /// Encode a header field list into a single HPACK header block.
- public byte[] Encode(IReadOnlyList<(string Name, string Value)> fields)
- {
- var dst = new List(64);
- foreach (var (name, value) in fields)
- {
- var index = HpackStaticTable.FindIndex(name, value, out var exact);
- if (exact)
- {
- // §6.1 Indexed Header Field.
- WriteInteger(dst, index, 7, 0x80);
- continue;
- }
-
- if (index > 0)
- {
- // §6.2.2 Literal without indexing, name from the static table.
- WriteInteger(dst, index, 4, 0x00);
- }
- else
- {
- // §6.2.2 Literal without indexing, new name.
- dst.Add(0x00);
- WriteString(dst, name);
- }
- WriteString(dst, value);
- }
- return [.. dst];
- }
-
- private static void WriteInteger(List dst, int value, int prefixBits, byte firstByteHigh)
- {
- Span tmp = stackalloc byte[HpackInteger.MaxEncodedLength];
- var n = HpackInteger.Encode(tmp, value, prefixBits, firstByteHigh);
- for (var i = 0; i < n; i++)
- {
- dst.Add(tmp[i]);
- }
- }
-
- private static void WriteString(List dst, string s)
- {
- var bytes = Encoding.Latin1.GetBytes(s);
- var huffLen = HpackHuffman.EncodedLength(bytes);
-
- if (huffLen < bytes.Length)
- {
- WriteInteger(dst, huffLen, 7, 0x80); // H flag set
- var buf = new byte[huffLen];
- HpackHuffman.Encode(bytes, buf);
- dst.AddRange(buf);
- }
- else
- {
- WriteInteger(dst, bytes.Length, 7, 0x00);
- dst.AddRange(bytes);
- }
- }
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs b/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs
deleted file mode 100644
index 4e6ca76c..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackHuffman.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-namespace Starling.Net.Http.H2.Hpack;
-
-///
-/// HPACK Huffman codec (RFC 7541 Appendix B). Decode walks a bit-trie built
-/// once at static init; encode emits MSB-first codes. The canonical 257-symbol
-/// table (256 octet symbols + EOS) is embedded verbatim from the RFC.
-///
-internal static class HpackHuffman
-{
- /// Symbol index of the End-Of-String code; never appears decoded.
- private const int Eos = 256;
-
- // (code right-aligned in a uint, bit length). Index = symbol value.
- private static readonly (uint Code, int Bits)[] Table =
- [
- (0x00001ff8u, 13), (0x007fffd8u, 23), (0x0fffffe2u, 28), (0x0fffffe3u, 28), (0x0fffffe4u, 28),
- (0x0fffffe5u, 28), (0x0fffffe6u, 28), (0x0fffffe7u, 28), (0x0fffffe8u, 28), (0x00ffffeau, 24),
- (0x3ffffffcu, 30), (0x0fffffe9u, 28), (0x0fffffeau, 28), (0x3ffffffdu, 30), (0x0fffffebu, 28),
- (0x0fffffecu, 28), (0x0fffffedu, 28), (0x0fffffeeu, 28), (0x0fffffefu, 28), (0x0ffffff0u, 28),
- (0x0ffffff1u, 28), (0x0ffffff2u, 28), (0x3ffffffeu, 30), (0x0ffffff3u, 28), (0x0ffffff4u, 28),
- (0x0ffffff5u, 28), (0x0ffffff6u, 28), (0x0ffffff7u, 28), (0x0ffffff8u, 28), (0x0ffffff9u, 28),
- (0x0ffffffau, 28), (0x0ffffffbu, 28), (0x00000014u, 6), (0x000003f8u, 10), (0x000003f9u, 10),
- (0x00000ffau, 12), (0x00001ff9u, 13), (0x00000015u, 6), (0x000000f8u, 8), (0x000007fau, 11),
- (0x000003fau, 10), (0x000003fbu, 10), (0x000000f9u, 8), (0x000007fbu, 11), (0x000000fau, 8),
- (0x00000016u, 6), (0x00000017u, 6), (0x00000018u, 6), (0x00000000u, 5), (0x00000001u, 5),
- (0x00000002u, 5), (0x00000019u, 6), (0x0000001au, 6), (0x0000001bu, 6), (0x0000001cu, 6),
- (0x0000001du, 6), (0x0000001eu, 6), (0x0000001fu, 6), (0x0000005cu, 7), (0x000000fbu, 8),
- (0x00007ffcu, 15), (0x00000020u, 6), (0x00000ffbu, 12), (0x000003fcu, 10), (0x00001ffau, 13),
- (0x00000021u, 6), (0x0000005du, 7), (0x0000005eu, 7), (0x0000005fu, 7), (0x00000060u, 7),
- (0x00000061u, 7), (0x00000062u, 7), (0x00000063u, 7), (0x00000064u, 7), (0x00000065u, 7),
- (0x00000066u, 7), (0x00000067u, 7), (0x00000068u, 7), (0x00000069u, 7), (0x0000006au, 7),
- (0x0000006bu, 7), (0x0000006cu, 7), (0x0000006du, 7), (0x0000006eu, 7), (0x0000006fu, 7),
- (0x00000070u, 7), (0x00000071u, 7), (0x00000072u, 7), (0x000000fcu, 8), (0x00000073u, 7),
- (0x000000fdu, 8), (0x00001ffbu, 13), (0x0007fff0u, 19), (0x00001ffcu, 13), (0x00003ffcu, 14),
- (0x00000022u, 6), (0x00007ffdu, 15), (0x00000003u, 5), (0x00000023u, 6), (0x00000004u, 5),
- (0x00000024u, 6), (0x00000005u, 5), (0x00000025u, 6), (0x00000026u, 6), (0x00000027u, 6),
- (0x00000006u, 5), (0x00000074u, 7), (0x00000075u, 7), (0x00000028u, 6), (0x00000029u, 6),
- (0x0000002au, 6), (0x00000007u, 5), (0x0000002bu, 6), (0x00000076u, 7), (0x0000002cu, 6),
- (0x00000008u, 5), (0x00000009u, 5), (0x0000002du, 6), (0x00000077u, 7), (0x00000078u, 7),
- (0x00000079u, 7), (0x0000007au, 7), (0x0000007bu, 7), (0x00007ffeu, 15), (0x000007fcu, 11),
- (0x00003ffdu, 14), (0x00001ffdu, 13), (0x0ffffffcu, 28), (0x000fffe6u, 20), (0x003fffd2u, 22),
- (0x000fffe7u, 20), (0x000fffe8u, 20), (0x003fffd3u, 22), (0x003fffd4u, 22), (0x003fffd5u, 22),
- (0x007fffd9u, 23), (0x003fffd6u, 22), (0x007fffdau, 23), (0x007fffdbu, 23), (0x007fffdcu, 23),
- (0x007fffddu, 23), (0x007fffdeu, 23), (0x00ffffebu, 24), (0x007fffdfu, 23), (0x00ffffecu, 24),
- (0x00ffffedu, 24), (0x003fffd7u, 22), (0x007fffe0u, 23), (0x00ffffeeu, 24), (0x007fffe1u, 23),
- (0x007fffe2u, 23), (0x007fffe3u, 23), (0x007fffe4u, 23), (0x001fffdcu, 21), (0x003fffd8u, 22),
- (0x007fffe5u, 23), (0x003fffd9u, 22), (0x007fffe6u, 23), (0x007fffe7u, 23), (0x00ffffefu, 24),
- (0x003fffdau, 22), (0x001fffddu, 21), (0x000fffe9u, 20), (0x003fffdbu, 22), (0x003fffdcu, 22),
- (0x007fffe8u, 23), (0x007fffe9u, 23), (0x001fffdeu, 21), (0x007fffeau, 23), (0x003fffddu, 22),
- (0x003fffdeu, 22), (0x00fffff0u, 24), (0x001fffdfu, 21), (0x003fffdfu, 22), (0x007fffebu, 23),
- (0x007fffecu, 23), (0x001fffe0u, 21), (0x001fffe1u, 21), (0x003fffe0u, 22), (0x001fffe2u, 21),
- (0x007fffedu, 23), (0x003fffe1u, 22), (0x007fffeeu, 23), (0x007fffefu, 23), (0x000fffeau, 20),
- (0x003fffe2u, 22), (0x003fffe3u, 22), (0x003fffe4u, 22), (0x007ffff0u, 23), (0x003fffe5u, 22),
- (0x003fffe6u, 22), (0x007ffff1u, 23), (0x03ffffe0u, 26), (0x03ffffe1u, 26), (0x000fffebu, 20),
- (0x0007fff1u, 19), (0x003fffe7u, 22), (0x007ffff2u, 23), (0x003fffe8u, 22), (0x01ffffecu, 25),
- (0x03ffffe2u, 26), (0x03ffffe3u, 26), (0x03ffffe4u, 26), (0x07ffffdeu, 27), (0x07ffffdfu, 27),
- (0x03ffffe5u, 26), (0x00fffff1u, 24), (0x01ffffedu, 25), (0x0007fff2u, 19), (0x001fffe3u, 21),
- (0x03ffffe6u, 26), (0x07ffffe0u, 27), (0x07ffffe1u, 27), (0x03ffffe7u, 26), (0x07ffffe2u, 27),
- (0x00fffff2u, 24), (0x001fffe4u, 21), (0x001fffe5u, 21), (0x03ffffe8u, 26), (0x03ffffe9u, 26),
- (0x0ffffffdu, 28), (0x07ffffe3u, 27), (0x07ffffe4u, 27), (0x07ffffe5u, 27), (0x000fffecu, 20),
- (0x00fffff3u, 24), (0x000fffedu, 20), (0x001fffe6u, 21), (0x003fffe9u, 22), (0x001fffe7u, 21),
- (0x001fffe8u, 21), (0x007ffff3u, 23), (0x003fffeau, 22), (0x003fffebu, 22), (0x01ffffeeu, 25),
- (0x01ffffefu, 25), (0x00fffff4u, 24), (0x00fffff5u, 24), (0x03ffffeau, 26), (0x007ffff4u, 23),
- (0x03ffffebu, 26), (0x07ffffe6u, 27), (0x03ffffecu, 26), (0x03ffffedu, 26), (0x07ffffe7u, 27),
- (0x07ffffe8u, 27), (0x07ffffe9u, 27), (0x07ffffeau, 27), (0x07ffffebu, 27), (0x0ffffffeu, 28),
- (0x07ffffecu, 27), (0x07ffffedu, 27), (0x07ffffeeu, 27), (0x07ffffefu, 27), (0x07fffff0u, 27),
- (0x03ffffeeu, 26), (0x3fffffffu, 30),
- ];
-
- // Bit-trie: two arrays of child indices (0-bit / 1-bit) plus a per-node
- // symbol (-1 for internal nodes). Node 0 is the root.
- private static readonly int[] Zero;
- private static readonly int[] One;
- private static readonly int[] Symbol;
-
- static HpackHuffman()
- {
- // Worst case one internal node per code bit; over-allocate then trim.
- var capacity = 1;
- foreach (var (_, bits) in Table)
- {
- capacity += bits;
- }
-
- Zero = new int[capacity];
- One = new int[capacity];
- Symbol = new int[capacity];
- Array.Fill(Zero, -1);
- Array.Fill(One, -1);
- Array.Fill(Symbol, -1);
-
- var next = 1; // node 0 is the root
- for (var sym = 0; sym < Table.Length; sym++)
- {
- var (code, bits) = Table[sym];
- var node = 0;
- for (var i = bits - 1; i >= 0; i--)
- {
- var bit = (code >> i) & 1;
- ref var edge = ref (bit == 0 ? ref Zero[node] : ref One[node]);
- if (edge < 0)
- {
- edge = next++;
- }
- node = edge;
- }
- Symbol[node] = sym;
- }
- }
-
- ///
- /// Decode a Huffman-coded octet string. Returns false on any RFC 7541
- /// §5.2 violation: an EOS symbol appearing in the stream, padding longer
- /// than 7 bits, or padding that is not the MSBs of the all-ones EOS code.
- ///
- public static bool TryDecode(ReadOnlySpan input, out byte[] output)
- {
- var sink = new List(input.Length * 8 / 5 + 4);
- var node = 0;
- var bitsInNode = 0;
- var allOnesSinceRoot = true;
-
- foreach (var b in input)
- {
- for (var i = 7; i >= 0; i--)
- {
- var bit = (b >> i) & 1;
- if (node == 0)
- {
- bitsInNode = 0;
- allOnesSinceRoot = true;
- }
- bitsInNode++;
- if (bit == 0)
- {
- allOnesSinceRoot = false;
- }
-
- node = bit == 0 ? Zero[node] : One[node];
- if (node < 0)
- {
- output = [];
- return false; // no such code path
- }
-
- if (Symbol[node] >= 0)
- {
- if (Symbol[node] == Eos)
- {
- output = [];
- return false; // EOS must never be encoded
- }
- sink.Add((byte)Symbol[node]);
- node = 0;
- }
- }
- }
-
- // Trailing bits must be a (<=7-bit) prefix of EOS — i.e. all ones.
- if (node != 0 && (bitsInNode > 7 || !allOnesSinceRoot))
- {
- output = [];
- return false;
- }
-
- output = [.. sink];
- return true;
- }
-
- /// Number of bytes the Huffman encoding of occupies.
- public static int EncodedLength(ReadOnlySpan src)
- {
- var bits = 0L;
- foreach (var b in src)
- {
- bits += Table[b].Bits;
- }
-
- return (int)((bits + 7) / 8);
- }
-
- ///
- /// Huffman-encode into ,
- /// padding the final byte with the MSBs of the EOS code (all ones).
- /// Returns the number of bytes written.
- ///
- public static int Encode(ReadOnlySpan src, Span dst)
- {
- var pos = 0;
- ulong acc = 0;
- var accBits = 0;
-
- foreach (var b in src)
- {
- var (code, bits) = Table[b];
- acc = (acc << bits) | code;
- accBits += bits;
- while (accBits >= 8)
- {
- accBits -= 8;
- dst[pos++] = (byte)(acc >> accBits);
- }
- }
-
- if (accBits > 0)
- {
- // Pad the remaining bits with 1s (EOS prefix).
- var pad = 8 - accBits;
- acc = (acc << pad) | ((1UL << pad) - 1);
- dst[pos++] = (byte)acc;
- }
-
- return pos;
- }
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs b/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs
deleted file mode 100644
index 922d89f1..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackInteger.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-namespace Starling.Net.Http.H2.Hpack;
-
-///
-/// HPACK variable-length integer representation (RFC 7541 §5.1). Integers are
-/// stored in an N-bit prefix of the first octet; values that don't fit spill
-/// into following octets, 7 bits at a time, little-endian, with a continuation
-/// bit in the MSB.
-///
-internal static class HpackInteger
-{
- ///
- /// Decode an integer with an -bit prefix
- /// starting at . On success advances
- /// past the integer and returns true. Returns
- /// false on truncation or an over-long encoding (> 32 bits of payload).
- ///
- public static bool TryDecode(
- ReadOnlySpan buf, ref int offset, int prefixBits, out int value)
- {
- value = 0;
- if (offset >= buf.Length)
- {
- return false;
- }
-
- var max = (1 << prefixBits) - 1;
- var prefix = buf[offset] & max;
- offset++;
- if (prefix < max)
- {
- value = prefix;
- return true;
- }
-
- // Continuation octets: 7 bits each, low-order first.
- long result = max;
- var shift = 0;
- while (true)
- {
- if (offset >= buf.Length)
- {
- return false;
- }
-
- var b = buf[offset++];
- result += (long)(b & 0x7f) << shift;
- if (result > int.MaxValue)
- {
- return false; // guard against overflow / DoS
- }
-
- if ((b & 0x80) == 0)
- {
- break;
- }
-
- shift += 7;
- if (shift >= 32)
- {
- return false;
- }
- }
-
- value = (int)result;
- return true;
- }
-
- ///
- /// Encode into using an
- /// -bit prefix.
- /// supplies the bits above the prefix in the first octet (e.g. the 0x80
- /// "indexed" flag). Returns the number of bytes written.
- ///
- public static int Encode(Span dst, int value, int prefixBits, byte firstByteHigh)
- {
- var max = (1 << prefixBits) - 1;
- if (value < max)
- {
- dst[0] = (byte)(firstByteHigh | value);
- return 1;
- }
-
- dst[0] = (byte)(firstByteHigh | max);
- var pos = 1;
- var remaining = value - max;
- while (remaining >= 0x80)
- {
- dst[pos++] = (byte)((remaining & 0x7f) | 0x80);
- remaining >>= 7;
- }
- dst[pos++] = (byte)remaining;
- return pos;
- }
-
- /// Worst-case byte count for encoding a 32-bit value (1 prefix + 5 continuation).
- public const int MaxEncodedLength = 6;
-}
diff --git a/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs b/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs
deleted file mode 100644
index bf604e38..00000000
--- a/src/Starling.Net/Http/H2/Hpack/HpackStaticTable.cs
+++ /dev/null
@@ -1,125 +0,0 @@
-namespace Starling.Net.Http.H2.Hpack;
-
-///
-/// HPACK static table (RFC 7541 Appendix A). 61 predefined header field
-/// entries, addressed by 1-based index in the combined index space (the
-/// dynamic table follows immediately after, starting at index 62).
-///
-internal static class HpackStaticTable
-{
- /// Number of entries in the static table (RFC 7541 §2.3.1).
- public const int Count = 61;
-
- // Index 1 == Entries[0]. Value is the empty string when the table defines
- // no value for the entry (e.g. ":authority").
- private static readonly (string Name, string Value)[] Entries =
- [
- (":authority", ""),
- (":method", "GET"),
- (":method", "POST"),
- (":path", "/"),
- (":path", "/index.html"),
- (":scheme", "http"),
- (":scheme", "https"),
- (":status", "200"),
- (":status", "204"),
- (":status", "206"),
- (":status", "304"),
- (":status", "400"),
- (":status", "404"),
- (":status", "500"),
- ("accept-charset", ""),
- ("accept-encoding", "gzip, deflate"),
- ("accept-language", ""),
- ("accept-ranges", ""),
- ("accept", ""),
- ("access-control-allow-origin", ""),
- ("age", ""),
- ("allow", ""),
- ("authorization", ""),
- ("cache-control", ""),
- ("content-disposition", ""),
- ("content-encoding", ""),
- ("content-language", ""),
- ("content-length", ""),
- ("content-location", ""),
- ("content-range", ""),
- ("content-type", ""),
- ("cookie", ""),
- ("date", ""),
- ("etag", ""),
- ("expect", ""),
- ("expires", ""),
- ("from", ""),
- ("host", ""),
- ("if-match", ""),
- ("if-modified-since", ""),
- ("if-none-match", ""),
- ("if-range", ""),
- ("if-unmodified-since", ""),
- ("last-modified", ""),
- ("link", ""),
- ("location", ""),
- ("max-forwards", ""),
- ("proxy-authenticate", ""),
- ("proxy-authorization", ""),
- ("range", ""),
- ("referer", ""),
- ("refresh", ""),
- ("retry-after", ""),
- ("server", ""),
- ("set-cookie", ""),
- ("strict-transport-security", ""),
- ("transfer-encoding", ""),
- ("user-agent", ""),
- ("vary", ""),
- ("via", ""),
- ("www-authenticate", ""),
- ];
-
- ///
- /// Look up an entry by its 1-based static index. Returns false when the
- /// index is out of range (1..61).
- ///
- public static bool TryGet(int index, out string name, out string value)
- {
- if (index is < 1 or > Count)
- {
- name = string.Empty;
- value = string.Empty;
- return false;
- }
- (name, value) = Entries[index - 1];
- return true;
- }
-
- ///
- /// Find a static index for a header. Returns the index of an exact
- /// name+value match if one exists; otherwise the index of the first
- /// name-only match; otherwise 0. reports whether
- /// the returned index also matched the value.
- ///
- public static int FindIndex(string name, string value, out bool exact)
- {
- var nameOnly = 0;
- for (var i = 0; i < Entries.Length; i++)
- {
- if (!string.Equals(Entries[i].Name, name, StringComparison.Ordinal))
- {
- continue;
- }
-
- if (string.Equals(Entries[i].Value, value, StringComparison.Ordinal))
- {
- exact = true;
- return i + 1;
- }
- if (nameOnly == 0)
- {
- nameOnly = i + 1;
- }
- }
- exact = false;
- return nameOnly;
- }
-}
diff --git a/src/Starling.Net/Http/HttpError.cs b/src/Starling.Net/Http/HttpError.cs
deleted file mode 100644
index 848dd0a9..00000000
--- a/src/Starling.Net/Http/HttpError.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace Starling.Net.Http;
-
-public enum HttpError
-{
- /// Malformed status line.
- BadStatusLine,
- /// Malformed header (missing colon, invalid token, etc.).
- BadHeader,
- /// Chunked framing was syntactically broken.
- BadChunkedFraming,
- /// Connection closed before the response was complete.
- UnexpectedEof,
- /// Response header block exceeded the configured size cap.
- HeadersTooLarge,
- /// Response body exceeded the configured size cap.
- BodyTooLarge,
- /// Content-Encoding referenced an algorithm we don't support.
- UnsupportedEncoding,
- /// Content-Encoding payload failed to decode (truncated/corrupt).
- DecodeFailed,
- /// An IO error happened on the underlying transport.
- TransportFailure,
-}
diff --git a/src/Starling.Net/Http/IHttpTransport.cs b/src/Starling.Net/Http/IHttpTransport.cs
deleted file mode 100644
index 9b703c12..00000000
--- a/src/Starling.Net/Http/IHttpTransport.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using Starling.Net.Tcp;
-
-namespace Starling.Net.Http;
-
-///
-/// A live, in-use connection over which one HTTP/1.1 request/response cycle
-/// can be conducted. May be plain TCP or TLS-wrapped TCP. Owned either by the
-/// caller (when freshly dialed) or by a (when
-/// it's an idle, kept-alive transport awaiting reuse).
-///
-///
-/// The concrete implementations wrap an plus
-/// optionally a TLS transport, and expose the resulting byte
-/// that the HTTP layer reads/writes through.
-/// Disposing the transport tears down the TLS session (if any) and the
-/// underlying TCP socket — there is no "soft" close that keeps the socket
-/// alive.
-///
-public interface IHttpTransport : IAsyncDisposable
-{
- /// The origin (scheme/host/port) this transport is bound to.
- OriginKey Origin { get; }
-
- /// Byte stream the HTTP request writer / response parser operate on.
- Stream Stream { get; }
-
- ///
- /// The ALPN protocol negotiated during the TLS handshake (e.g. "h2"
- /// or "http/1.1"), or null for plain HTTP / when no ALPN was agreed.
- /// Selects whether the HTTP/2 or HTTP/1.1 path drives this transport.
- ///
- string? Alpn { get; }
-
- /// The verified leaf certificate for a TLS transport, else null.
- Starling.Net.Tls.CertificateSummary? PeerCertificate { get; }
-
- /// True until either side closes the connection.
- bool IsOpen { get; }
-}
diff --git a/src/Starling.Net/Http/OriginKey.cs b/src/Starling.Net/Http/OriginKey.cs
deleted file mode 100644
index 5382c174..00000000
--- a/src/Starling.Net/Http/OriginKey.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace Starling.Net.Http;
-
-///
-/// (scheme, host, port) tuple identifying an HTTP origin for connection-pooling
-/// purposes. Matches RFC 6454 §4's "origin" projection but without the URL's
-/// path or query — those don't influence which TCP connection a request can
-/// reuse.
-///
-///
-/// Scheme and host are case-insensitive per the URL spec;
-/// normalises them to lowercase ASCII so equality lookups work without
-/// per-call fold-case overhead.
-///
-public readonly record struct OriginKey(string Scheme, string Host, int Port)
-{
- public static OriginKey Create(string scheme, string host, int port)
- {
- if (string.IsNullOrEmpty(scheme))
- {
- throw new ArgumentException("scheme must be non-empty.", nameof(scheme));
- }
-
- if (string.IsNullOrEmpty(host))
- {
- throw new ArgumentException("host must be non-empty.", nameof(host));
- }
-
- if (port is < 1 or > 65535)
- {
- throw new ArgumentOutOfRangeException(nameof(port));
- }
-
- return new OriginKey(scheme.ToLowerInvariant(), host.ToLowerInvariant(), port);
- }
-
- public override string ToString() => $"{Scheme}://{Host}:{Port}";
-}
diff --git a/src/Starling.Net/Http/PooledHttpTransport.cs b/src/Starling.Net/Http/PooledHttpTransport.cs
deleted file mode 100644
index e93fb829..00000000
--- a/src/Starling.Net/Http/PooledHttpTransport.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using Starling.Net.Tcp;
-using Starling.Net.Tls;
-
-namespace Starling.Net.Http;
-
-///
-/// Concrete wrapping a TCP connection optionally
-/// upgraded to TLS. The instance owns its underlying TCP socket and (if
-/// present) ; tears
-/// everything down so the same wrapper can never be used twice after a close.
-///
-///
-/// The exposed to the HTTP layer is the TLS-wrapped
-/// stream for HTTPS or a thin adapter for
-/// plain HTTP. Either way, reads/writes go through one byte-oriented stream
-/// so the request writer and response parser remain transport-agnostic.
-///
-internal sealed class PooledHttpTransport : IHttpTransport
-{
- private readonly ITcpConnection _tcp;
- private readonly BcTlsTransport? _tls;
- private readonly TcpConnectionStream? _plainStream;
- private bool _disposed;
-
- public OriginKey Origin { get; }
- public Stream Stream { get; }
- public string? Alpn { get; }
- public CertificateSummary? PeerCertificate { get; }
-
- public bool IsOpen => !_disposed && _tcp.IsOpen;
-
- private PooledHttpTransport(
- OriginKey origin,
- ITcpConnection tcp,
- BcTlsTransport? tls,
- TcpConnectionStream? plainStream,
- Stream stream,
- string? alpn,
- CertificateSummary? peerCertificate)
- {
- Origin = origin;
- _tcp = tcp;
- _tls = tls;
- _plainStream = plainStream;
- Stream = stream;
- Alpn = alpn;
- PeerCertificate = peerCertificate;
- }
-
- public static PooledHttpTransport ForPlainHttp(
- OriginKey origin, ITcpConnection tcp)
- {
- ArgumentNullException.ThrowIfNull(tcp);
- var stream = new TcpConnectionStream(tcp);
- return new PooledHttpTransport(
- origin, tcp, tls: null, plainStream: stream, stream, alpn: null, peerCertificate: null);
- }
-
- public static PooledHttpTransport ForTls(
- OriginKey origin, ITcpConnection tcp, BcTlsTransport tls)
- {
- ArgumentNullException.ThrowIfNull(tcp);
- ArgumentNullException.ThrowIfNull(tls);
- return new PooledHttpTransport(
- origin, tcp, tls, plainStream: null, tls.Stream,
- tls.NegotiatedApplicationProtocol, tls.PeerCertificate);
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- if (_tls is not null)
- {
- // Disposing the TLS transport also disposes the wrapped TCP stream
- // (which in turn disposes the connection).
- _tls.Dispose();
- }
- else if (_plainStream is not null)
- {
- // Plain stream owns the connection via its Dispose override.
- _plainStream.Dispose();
- }
- else
- {
- await _tcp.DisposeAsync().ConfigureAwait(false);
- }
- }
-}
diff --git a/src/Starling.Net/Http/SecureConnector.cs b/src/Starling.Net/Http/SecureConnector.cs
new file mode 100644
index 00000000..04a790f9
--- /dev/null
+++ b/src/Starling.Net/Http/SecureConnector.cs
@@ -0,0 +1,128 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using Starling.Common.Diagnostics;
+using Starling.Net.Tls;
+
+namespace Starling.Net.Http;
+
+///
+/// Opens the TCP + TLS connections behind a .
+/// Wired in as so we keep three
+/// things the browser needs that the default connect path hides: our bundled
+/// trust anchors (the OS store is not the authority), the verified leaf
+/// certificate surfaced to the shell lock UI, and per-phase (dns / tcp / tls)
+/// telemetry spans.
+///
+internal sealed class SecureConnector
+{
+ private readonly RootCertificates _roots;
+ private readonly List _alpn;
+ // Latest verified leaf per origin ("host:port"). A connection is only
+ // recorded after its chain validated, so a present entry always describes a
+ // certificate that passed CertificateVerifier. Reused across the origin's
+ // pooled connections, which all present the same certificate.
+ private readonly ConcurrentDictionary _certificates = new(StringComparer.Ordinal);
+
+ public SecureConnector(RootCertificates roots, IReadOnlyList alpnProtocols)
+ {
+ _roots = roots;
+ _alpn = alpnProtocols.Select(p => new SslApplicationProtocol(p)).ToList();
+ }
+
+ public CertificateSummary? CertificateFor(string host, int port) =>
+ _certificates.TryGetValue(Key(host, port), out var summary) ? summary : null;
+
+ public async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)
+ {
+ var host = context.DnsEndPoint.Host;
+ var port = context.DnsEndPoint.Port;
+ var isHttps = context.InitialRequestMessage.RequestUri is { Scheme: "https" };
+
+ var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ try
+ {
+ IPAddress[] addresses;
+ using (StarlingTelemetry.Span("net", "dns"))
+ {
+ StarlingTelemetry.Counter("net.dns.resolutions", 1);
+ addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false);
+ }
+
+ using (StarlingTelemetry.Span("net", "tcp_connect"))
+ {
+ StarlingTelemetry.Counter("net.tcp.connects", 1);
+ await socket.ConnectAsync(addresses, port, ct).ConfigureAwait(false);
+ }
+
+ var networkStream = new NetworkStream(socket, ownsSocket: true);
+ if (!isHttps)
+ {
+ return networkStream;
+ }
+
+ var ssl = new SslStream(networkStream, leaveInnerStreamOpen: false);
+ var sslOptions = new SslClientAuthenticationOptions
+ {
+ TargetHost = host,
+ EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12,
+ ApplicationProtocols = _alpn,
+ RemoteCertificateValidationCallback = (_, cert, chain, _) => Verify(host, cert, chain),
+ };
+
+ try
+ {
+ using (StarlingTelemetry.Span("net", "tls_handshake"))
+ {
+ StarlingTelemetry.Counter("net.tls.handshakes", 1);
+ await ssl.AuthenticateAsClientAsync(sslOptions, ct).ConfigureAwait(false);
+ }
+ }
+ catch
+ {
+ StarlingTelemetry.Counter("net.tls.failures", 1);
+ await ssl.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
+ if (ssl.RemoteCertificate is { } leaf)
+ {
+ _certificates[Key(host, port)] = CertificateVerifier.Summarize(AsCertificate2(leaf));
+ }
+ return ssl;
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ private bool Verify(string host, X509Certificate? cert, X509Chain? chain)
+ {
+ if (cert is null)
+ {
+ return false;
+ }
+
+ X509Certificate2Collection? intermediates = null;
+ if (chain is { ChainElements.Count: > 0 })
+ {
+ intermediates = new X509Certificate2Collection();
+ foreach (var element in chain.ChainElements)
+ {
+ intermediates.Add(element.Certificate);
+ }
+ }
+
+ return CertificateVerifier.Verify(AsCertificate2(cert), intermediates, host, _roots);
+ }
+
+ private static X509Certificate2 AsCertificate2(X509Certificate cert) =>
+ cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.Export(X509ContentType.Cert));
+
+ private static string Key(string host, int port) => $"{host}:{port}";
+}
diff --git a/src/Starling.Net/Starling.Net.csproj b/src/Starling.Net/Starling.Net.csproj
index f7ffdf83..d678731d 100644
--- a/src/Starling.Net/Starling.Net.csproj
+++ b/src/Starling.Net/Starling.Net.csproj
@@ -3,7 +3,6 @@
false
-
diff --git a/src/Starling.Net/StarlingHttpClient.cs b/src/Starling.Net/StarlingHttpClient.cs
index ac1093ae..14c10ee6 100644
--- a/src/Starling.Net/StarlingHttpClient.cs
+++ b/src/Starling.Net/StarlingHttpClient.cs
@@ -1,14 +1,11 @@
using System.Diagnostics;
+using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Starling.Common;
using Starling.Common.Diagnostics;
-using Starling.Net.Dns;
using Starling.Net.Http;
using Starling.Net.Http.Cookies;
-using Starling.Net.Http.H1;
-using Starling.Net.Http.H2;
-using Starling.Net.Tcp;
using Starling.Net.Tls;
using StarlingUrl = global::Starling.Url.Url;
@@ -18,43 +15,28 @@ internal static partial class StarlingHttpClientLog
{
[LoggerMessage(Level = LogLevel.Warning, Message = "HTTP request failed: {Error}")]
public static partial void RequestFailed(ILogger logger, string error);
-
- [LoggerMessage(Level = LogLevel.Debug, Message = "transport dispose threw during cleanup")]
- public static partial void SafeDisposeFailed(ILogger logger, Exception ex);
}
///
-/// Top-level HTTP client. Resolves a URL to a TCP endpoint, opens a transport
-/// (TLS for https, plain for http), and speaks HTTP/2 or HTTP/1.1 depending on
-/// the TLS application protocol negotiated during the handshake. Over HTTP/1.1,
-/// sequential requests to the same origin reuse a pooled TCP+TLS transport when
-/// both sides advertise keep-alive. Over HTTP/2, requests to the
-/// same origin are multiplexed onto a single shared connection held by the
-/// .
-///
-///
-/// For a single GET against https://example.com the data flow is:
+/// Top-level HTTP client. A thin browser-policy layer over .NET's
+/// (HTTP/1.1 + HTTP/2 over a
+/// ). We keep the pieces a browser must own and
+/// that the default handler would otherwise do automatically:
///
-/// - → A/AAAA records
-/// - →
-/// - → negotiated TLS stream
-/// - → wire bytes onto the stream
-/// - → fully buffered
+/// - Redirects are not auto-followed — callers observe each hop
+/// ( is false).
+/// - Cookies come from our , not
+/// (which lacks PSL / prefix rules).
+/// - Server certificates chain to our bundled trust anchors via
+/// , and the verified leaf is surfaced on the
+/// response for the shell lock UI.
///
-/// For HTTP/1.1 a sits in front of the dialer:
-/// every send asks the pool first, and clean responses with a definite body
-/// length and keep-alive headers return the transport to the pool. For HTTP/2,
-/// the post-handshake transport is handed to an and
-/// subsequent requests to the same origin are multiplexed onto it.
-///
+///
public sealed class StarlingHttpClient : IDisposable
{
private readonly StarlingHttpClientOptions _options;
- private readonly DnsResolver _dns;
- private readonly TcpDialer _dialer;
- private readonly ConnectionPool _pool;
- private readonly ILoggerFactory _loggerFactory;
- private readonly H2ConnectionManager _h2;
+ private readonly System.Net.Http.HttpClient _http;
+ private readonly SecureConnector _connector;
private readonly ILogger _log;
private bool _disposed;
@@ -63,12 +45,29 @@ public StarlingHttpClient() : this(new StarlingHttpClientOptions()) { }
public StarlingHttpClient(StarlingHttpClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
- _loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance;
- _log = _loggerFactory.CreateLogger();
- _h2 = new H2ConnectionManager(_loggerFactory.CreateLogger());
- _dns = options.DnsResolver ?? new DnsResolver(new UdpDnsTransport());
- _dialer = new TcpDialer(_dns) { ConnectTimeout = options.ConnectTimeout };
- _pool = options.ConnectionPool ?? new ConnectionPool();
+ var loggerFactory = options.LoggerFactory ?? NullLoggerFactory.Instance;
+ _log = loggerFactory.CreateLogger();
+ _connector = new SecureConnector(RootCertificates.SystemTrust, options.AlpnProtocols);
+
+ var handler = new SocketsHttpHandler
+ {
+ // Browser policy lives above the transport — the engine follows
+ // redirects, our jar drives cookies. Let .NET only decompress.
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ AutomaticDecompression = DecompressionMethods.All,
+ ConnectTimeout = options.ConnectTimeout,
+ PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
+ EnableMultipleHttp2Connections = true,
+ ConnectCallback = _connector.ConnectAsync,
+ };
+
+ _http = new System.Net.Http.HttpClient(handler, disposeHandler: true)
+ {
+ // Per-request timeouts are enforced with a linked CTS in SendAsync so
+ // a timeout is distinguishable from caller cancellation.
+ Timeout = Timeout.InfiniteTimeSpan,
+ };
}
/// The User-Agent presented on the wire. Surfaced so the JS layer
@@ -76,10 +75,6 @@ public StarlingHttpClient(StarlingHttpClientOptions options)
/// send (sites compare the two).
public string UserAgent => _options.UserAgent;
- /// Idle connection pool. Exposed mainly for tests asserting on
- /// reuse / capacity behaviour.
- public ConnectionPool ConnectionPool => _pool;
-
public Task> GetAsync(string url, CancellationToken ct = default)
{
var parsed = global::Starling.Url.UrlParser.Parse(url);
@@ -111,23 +106,17 @@ public async Task> SendAsync(
return Result.Err(NetworkError.BadUrl);
}
- var port = url.Port ?? url.DefaultPort
- ?? (url.IsHttps ? 443 : url.IsHttp ? 80 : 0);
+ var port = url.Port ?? url.DefaultPort ?? (url.IsHttps ? 443 : 80);
if (port is < 1 or > 65535)
{
return Result.Err(NetworkError.BadUrl);
}
- var origin = OriginKey.Create(url.IsHttps ? "https" : "http", url.Host, port);
-
- // Present a recognised browser UA on every request (document, subresource,
- // and JS fetch/XHR) unless the caller forced one. Both the H1 writer and
- // the H2 header builder honour an existing User-Agent header, so this is
- // the single wire-side source of truth. See StarlingHttpClientOptions.
if (!request.Headers.Contains("User-Agent"))
{
request.Headers.Set("User-Agent", _options.UserAgent);
}
+ ApplyRequestCookies(request, url);
using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
requestCts.CancelAfter(_options.RequestTimeout);
@@ -135,372 +124,142 @@ public async Task> SendAsync(
using var httpSpan = StarlingTelemetry.Span("net", "http");
Activity.Current?.SetTag("http.method", request.Method);
Activity.Current?.SetTag("http.url", url.ToString());
- Activity.Current?.SetTag("server.address", origin.Host);
- Activity.Current?.SetTag("server.port", origin.Port);
+ Activity.Current?.SetTag("server.address", url.Host);
+ Activity.Current?.SetTag("server.port", port);
StarlingTelemetry.Counter("net.http.requests", 1);
- try
+ using var message = BuildRequestMessage(request);
+ if (!request.Body.IsEmpty)
{
- // 0. Reuse a live, multiplexed HTTP/2 connection if one is open for
- // this origin. A GOAWAY/closed connection between requests yields
- // a retryable failure; we then fall through to a fresh dial.
- if (_h2.TryGet(origin) is { } h2Existing)
- {
- var h2Result = await SendOverH2Async(
- h2Existing, request, url, reused: true, requestCts.Token).ConfigureAwait(false);
- if (h2Result is { } got)
- {
- RecordResponseTags(got);
- return got;
- }
- // Connection went away before our request was processed — re-dial.
- }
-
- // 1. Try the pool first. A pooled transport may turn out to have
- // been closed by the peer between the prior response and this
- // request; if our send raises an IO error — or reads back zero
- // bytes when we were expecting a status line — we fall through
- // to a fresh dial.
- var pooled = _pool.TryAcquire(origin);
- if (pooled is not null)
- {
- StarlingTelemetry.Counter("net.http.connection_reused", 1);
- Activity.Current?.SetTag("connection.reused", true);
- var pooledOutcome = await TrySendOnTransportAsync(
- pooled, request, url, fromPool: true, requestCts.Token).ConfigureAwait(false);
- if (pooledOutcome.UsedTransport)
- {
- RecordResponseTags(pooledOutcome.Result);
- return pooledOutcome.Result;
- }
- // Otherwise the connection was unusable (closed/IO) — dispose
- // and retry with a fresh dial. Re-tag the span: this request
- // ended up paying for a fresh handshake despite the pool hit.
- Activity.Current?.SetTag("connection.reused", false);
- await SafeDisposeAsync(pooled).ConfigureAwait(false);
- }
- else
- {
- Activity.Current?.SetTag("connection.reused", false);
- }
-
- // 2. Dial + (optionally) TLS-handshake a new transport.
- var dialed = await DialAsync(url, origin, requestCts.Token).ConfigureAwait(false);
- if (dialed.IsErr)
- {
- RecordError(dialed.Error);
- return Result.Err(dialed.Error);
- }
-
- StarlingTelemetry.Counter("net.http.connection_opened", 1);
-
- var fresh = dialed.Value;
-
- // ALPN decides the protocol. For "h2" the transport becomes a
- // multiplexed connection owned by the H2 manager; for everything
- // else we speak HTTP/1.1 over the transport's byte stream.
- if (string.Equals(fresh.Alpn, "h2", StringComparison.Ordinal))
- {
- var conn = await H2Connection.StartAsync(
- fresh, origin, _loggerFactory.CreateLogger(), c => _h2.Remove(origin, c), requestCts.Token).ConfigureAwait(false);
- var winner = _h2.Adopt(origin, conn);
- if (!ReferenceEquals(winner, conn))
- {
- await conn.DisposeAsync().ConfigureAwait(false); // lost the race; use the incumbent
- }
-
- var h2Fresh = await SendOverH2Async(
- winner, request, url, reused: false, requestCts.Token).ConfigureAwait(false);
- var h2Final = h2Fresh ?? Result.Err(NetworkError.TransportFailure);
- RecordResponseTags(h2Final);
- return h2Final;
- }
+ StarlingTelemetry.Counter("net.http.bytes_out", request.Body.Length);
+ }
- var freshOutcome = await TrySendOnTransportAsync(
- fresh, request, url, fromPool: false, requestCts.Token).ConfigureAwait(false);
- if (!freshOutcome.UsedTransport)
+ try
+ {
+ using var response = await _http.SendAsync(
+ message, HttpCompletionOption.ResponseHeadersRead, requestCts.Token).ConfigureAwait(false);
+
+ var body = await response.Content.ReadAsByteArrayAsync(requestCts.Token).ConfigureAwait(false);
+ var result = BuildResponse(response, url, port, body);
+
+ StoreResponseCookies(result, url);
+ Activity.Current?.SetTag("http.status_code", result.StatusCode);
+ Activity.Current?.SetTag("network.protocol.version", response.Version.ToString());
+ Activity.Current?.SetTag("http.response.body.size", body.Length);
+ StarlingTelemetry.Counter("net.http.bytes_in", body.Length);
+ if (result.StatusCode >= 500)
{
- // A brand-new transport that refused to talk: surface a
- // transport error and discard. We don't loop again to avoid
- // hammering.
- await SafeDisposeAsync(fresh).ConfigureAwait(false);
- RecordError(NetworkError.TransportFailure);
- return Result.Err(NetworkError.TransportFailure);
+ Activity.Current?.SetStatus(ActivityStatusCode.Error, $"HTTP {result.StatusCode}");
}
- RecordResponseTags(freshOutcome.Result);
- return freshOutcome.Result;
+ return Result.Ok(result);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
RecordError(NetworkError.RequestTimeout);
return Result.Err(NetworkError.RequestTimeout);
}
- }
-
- private void RecordResponseTags(Result result)
- {
- if (result.IsErr)
+ catch (HttpRequestException ex)
{
- RecordError(result.Error);
- return;
+ var error = MapException(ex);
+ RecordError(error);
+ return Result.Err(error);
}
- var response = result.Value;
- Activity.Current?.SetTag("http.status_code", response.StatusCode);
- Activity.Current?.SetTag("http.response.body.size", response.Body.Length);
- StarlingTelemetry.Counter("net.http.bytes_in", response.Body.Length);
- if (response.StatusCode >= 500)
- {
- Activity.Current?.SetStatus(ActivityStatusCode.Error, $"HTTP {response.StatusCode}");
- }
- }
-
- private void RecordError(NetworkError error)
- {
- var message = error.ToString();
- StarlingTelemetry.Counter("net.http.failures", 1);
- Activity.Current?.SetStatus(ActivityStatusCode.Error, message);
- StarlingHttpClientLog.RequestFailed(_log, message);
}
- private async Task> DialAsync(
- StarlingUrl url, OriginKey origin, CancellationToken ct)
+ private HttpRequestMessage BuildRequestMessage(HttpRequest request)
{
- // DNS resolution span. The TcpDialer drives DNS internally; we mirror
- // the work with our own resolve call so the span/counter scope is
- // crisp and DNS-only failures are distinguishable from connect-only
- // failures. The result feeds DialDirectAsync below.
- Result dnsResult;
- using (var dnsSpan = StarlingTelemetry.Span("net", "dns"))
+ var message = new HttpRequestMessage(new HttpMethod(request.Method), request.Url.ToString())
{
- Activity.Current?.SetTag("dns.host", origin.Host);
- StarlingTelemetry.Counter("net.dns.resolutions", 1);
- dnsResult = await _dns.ResolveAsync(origin.Host, ct).ConfigureAwait(false);
- if (dnsResult.IsErr)
- {
- StarlingTelemetry.Counter("net.dns.failures", 1);
- Activity.Current?.SetStatus(ActivityStatusCode.Error, dnsResult.Error.ToString());
- return Result.Err(NetworkError.DnsFailure);
- }
- }
+ // ALPN chooses the wire protocol; fall back to HTTP/1.1 if the peer
+ // does not offer HTTP/2.
+ Version = HttpVersion.Version20,
+ VersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
+ };
- // TCP connect span. Try each resolved address; first success wins.
- ITcpConnection? tcp = null;
- TcpError? lastTcpError = null;
- using (var tcpSpan = StarlingTelemetry.Span("net", "tcp_connect"))
+ if (!request.Body.IsEmpty)
{
- Activity.Current?.SetTag("server.address", origin.Host);
- Activity.Current?.SetTag("server.port", origin.Port);
- StarlingTelemetry.Counter("net.tcp.connects", 1);
-
- foreach (var ip in dnsResult.Value.Addresses)
- {
- var endpoint = new System.Net.IPEndPoint(ip, origin.Port);
- var dial = await _dialer.DialDirectAsync(
- endpoint,
- new TcpEndpoint(origin.Host, origin.Port),
- ct).ConfigureAwait(false);
- if (dial.IsOk)
- {
- tcp = dial.Value;
- break;
- }
- lastTcpError = dial.Error;
- }
-
- if (tcp is null)
- {
- StarlingTelemetry.Counter("net.tcp.failures", 1);
- var err = lastTcpError == TcpError.Timeout
- ? NetworkError.ConnectTimeout
- : NetworkError.ConnectFailed;
- Activity.Current?.SetStatus(ActivityStatusCode.Error, err.ToString());
- return Result.Err(err);
- }
+ message.Content = new ReadOnlyMemoryContent(request.Body);
}
- if (url.IsHttps)
+ foreach (var header in request.Headers)
{
- using var tlsSpan = StarlingTelemetry.Span("net", "tls_handshake");
- Activity.Current?.SetTag("server.address", origin.Host);
- StarlingTelemetry.Counter("net.tls.handshakes", 1);
-
- var tlsResult = await BcTlsTransport.ConnectAsync(
- tcp,
- new TlsClientOptions(origin.Host, _options.AlpnProtocols),
- ct).ConfigureAwait(false);
- if (tlsResult.IsErr)
- {
- StarlingTelemetry.Counter("net.tls.failures", 1);
- await tcp.DisposeAsync().ConfigureAwait(false);
- var err = tlsResult.Error == TlsError.CertificateRejected
- ? NetworkError.TlsCertificateRejected
- : NetworkError.TlsHandshakeFailed;
- Activity.Current?.SetStatus(ActivityStatusCode.Error, err.ToString());
- return Result.Err(err);
- }
- // StarlingTlsClient pins TLS 1.3; tag it post-handshake.
- Activity.Current?.SetTag("tls.protocol", "TLSv1.3");
- // ALPN selects HTTP/2 vs HTTP/1.1 downstream (see SendAsync). We
- // advertise "h2, http/1.1", so either is expected; an empty/absent
- // ALPN falls back to HTTP/1.1.
- if (tlsResult.Value.NegotiatedApplicationProtocol is { Length: > 0 } alpn)
+ // Content-* headers belong on the content object; everything else on
+ // the request. TryAddWithoutValidation keeps values verbatim.
+ if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value))
{
- Activity.Current?.SetTag("tls.alpn", alpn);
+ message.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
-
- return Result.Ok(
- PooledHttpTransport.ForTls(origin, tcp, tlsResult.Value));
}
- return Result.Ok(
- PooledHttpTransport.ForPlainHttp(origin, tcp));
+ return message;
}
- ///
- /// Write the request, parse the response, and decide whether to return
- /// the transport to the pool or close it. The returned
- /// flag is false when the
- /// caller should retry on a different transport (e.g. the pooled socket
- /// was closed between requests); the transport is still owned by the
- /// caller in that case and must be disposed.
- ///
- private async Task TrySendOnTransportAsync(
- IHttpTransport transport,
- HttpRequest request,
- StarlingUrl url,
- bool fromPool,
- CancellationToken ct)
+ private HttpResponse BuildResponse(HttpResponseMessage message, StarlingUrl url, int port, byte[] body)
{
- try
+ var headers = new HttpHeaders();
+ // NonValidated preserves raw values and duplicates (notably repeated
+ // Set-Cookie), which the fetch/XHR bindings depend on.
+ foreach (var header in message.Headers.NonValidated)
{
- ApplyRequestCookies(request, url);
-
- using (var writeSpan = StarlingTelemetry.Span("net", "h1_request"))
- {
- StarlingTelemetry.Counter("net.h1.requests_written", 1);
- await _options.RequestWriter
- .WriteAsync(request, transport.Stream, ct)
- .ConfigureAwait(false);
- if (!request.Body.IsEmpty)
- {
- StarlingTelemetry.Counter("net.http.bytes_out", request.Body.Length);
- }
- }
-
- Result parseResult;
- using (var parseSpan = StarlingTelemetry.Span("net", "h1_response"))
- {
- StarlingTelemetry.Counter("net.h1.responses_parsed", 1);
- parseResult = await _options.ResponseParser
- .ParseAsync(transport.Stream, ct).ConfigureAwait(false);
- if (parseResult.IsOk)
- {
- Activity.Current?.SetTag("http.status_code", parseResult.Value.StatusCode);
- }
- }
-
- if (parseResult.IsErr)
- {
- // A pooled connection that the peer closed since the prior
- // response will write OK (TCP buffers locally) but read 0
- // bytes — the parser surfaces that as UnexpectedEof. Retry
- // on a fresh dial in that case; for everything else (or on
- // a fresh transport) surface the protocol failure.
- if (fromPool && parseResult.Error == HttpError.UnexpectedEof)
- {
- return TransportSendOutcome.Unused();
- }
-
- await SafeDisposeAsync(transport).ConfigureAwait(false);
- return TransportSendOutcome.Used(
- Result.Err(MapParseError(parseResult.Error)));
- }
-
- var response = parseResult.Value;
- response.Security = new ConnectionSecurity(
- response.HttpVersion, url.IsHttps, transport.PeerCertificate);
-
- StoreResponseCookies(response, url);
-
- // Decide pool fate. We can only safely reuse the transport if:
- // - both sides agreed to keep-alive (RFC 9112 §9.3), AND
- // - the body had a definite framing so we know we drained
- // exactly to the end (no over-read into the next response).
- if (H1ResponseParser.IndicatesKeepAlive(response)
- && H1ResponseParser.HasDefiniteBodyFraming(response)
- && transport.IsOpen
- && !_disposed)
- {
- await _pool.ReleaseAsync(transport).ConfigureAwait(false);
- }
- else
+ foreach (var value in header.Value)
{
- await SafeDisposeAsync(transport).ConfigureAwait(false);
+ headers.Add(header.Key, value);
}
-
- return TransportSendOutcome.Used(Result.Ok(response));
}
- catch (OperationCanceledException)
+ foreach (var header in message.Content.Headers.NonValidated)
{
- await SafeDisposeAsync(transport).ConfigureAwait(false);
- // Cancellation includes our internal request-timeout CTS; let the
- // caller distinguish via its own token.
- throw;
+ foreach (var value in header.Value)
+ {
+ headers.Add(header.Key, value);
+ }
}
- catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException)
+
+ var protocol = VersionString(message.Version);
+ var response = new HttpResponse(
+ protocol,
+ (int)message.StatusCode,
+ message.ReasonPhrase ?? string.Empty,
+ headers,
+ body)
{
- // If this was a pooled connection on its first byte, treat the
- // failure as "socket died while idle" and let the caller retry.
- // We can't reliably tell whether anything was actually written;
- // for idempotent GETs (the only verb the engine uses today) a
- // re-dial is safe. For non-GETs the caller surfaces this as a
- // transport failure when the retry has nothing to fall back to.
- //
- // Sockets propagate ECONNRESET as raw SocketException (not wrapped
- // in IOException), so we accept both.
- return TransportSendOutcome.Unused();
- }
+ Security = new ConnectionSecurity(
+ protocol,
+ url.IsHttps,
+ url.IsHttps ? _connector.CertificateFor(url.Host!, port) : null),
+ };
+ return response;
}
- ///
- /// Send a request over a multiplexed HTTP/2 connection. Returns null when a
- /// connection turned out to be unusable (GOAWAY /
- /// closed) before the request was processed, signalling the caller to retry
- /// on a fresh dial. For a freshly-dialled connection the result — success or
- /// failure — is always returned.
- ///
- private async Task?> SendOverH2Async(
- H2Connection conn, HttpRequest request, StarlingUrl url, bool reused, CancellationToken ct)
+ private static string VersionString(Version version) => version switch
{
- ApplyRequestCookies(request, url);
- Activity.Current?.SetTag("connection.reused", reused);
- Activity.Current?.SetTag("network.protocol.version", "2");
- if (reused)
- {
- StarlingTelemetry.Counter("net.http.connection_reused", 1);
- }
-
- Result result;
- using (var span = StarlingTelemetry.Span("net", "h2_request"))
- {
- StarlingTelemetry.Counter("net.h2.requests_sent", 1);
- result = await conn.SendAsync(request, url, ct).ConfigureAwait(false);
- }
-
- if (reused && result.IsErr && result.Error == NetworkError.TransportFailure)
- {
- return null;
- }
+ { Major: 1, Minor: 0 } => "HTTP/1.0",
+ { Major: 1 } => "HTTP/1.1",
+ { Major: 2 } => "HTTP/2",
+ { Major: 3 } => "HTTP/3",
+ _ => $"HTTP/{version.Major}.{version.Minor}",
+ };
- if (result.IsOk)
- {
- result.Value.Security = new ConnectionSecurity(
- result.Value.HttpVersion, url.IsHttps, conn.PeerCertificate);
- StoreResponseCookies(result.Value, url);
- }
- return result;
+ private void RecordError(NetworkError error)
+ {
+ var message = error.ToString();
+ StarlingTelemetry.Counter("net.http.failures", 1);
+ Activity.Current?.SetStatus(ActivityStatusCode.Error, message);
+ StarlingHttpClientLog.RequestFailed(_log, message);
}
+ private static NetworkError MapException(HttpRequestException ex) => ex.HttpRequestError switch
+ {
+ HttpRequestError.NameResolutionError => NetworkError.DnsFailure,
+ HttpRequestError.ConnectionError => NetworkError.ConnectFailed,
+ HttpRequestError.SecureConnectionError =>
+ ex.InnerException is System.Security.Authentication.AuthenticationException
+ ? NetworkError.TlsCertificateRejected
+ : NetworkError.TlsHandshakeFailed,
+ HttpRequestError.HttpProtocolError => NetworkError.ProtocolError,
+ HttpRequestError.VersionNegotiationError => NetworkError.ProtocolError,
+ _ => NetworkError.TransportFailure,
+ };
+
///
/// Inject the cookie jar's view of the world into the request, but only when
/// the caller hasn't already supplied a Cookie header.
@@ -530,12 +289,6 @@ private void StoreResponseCookies(HttpResponse response, StarlingUrl url)
}
}
- private async ValueTask SafeDisposeAsync(IHttpTransport transport)
- {
- try { await transport.DisposeAsync().ConfigureAwait(false); }
- catch (Exception ex) { StarlingHttpClientLog.SafeDisposeFailed(_log, ex); /* a half-broken socket may throw on shutdown; we don't care */ }
- }
-
public void Dispose()
{
if (_disposed)
@@ -544,31 +297,7 @@ public void Dispose()
}
_disposed = true;
- // Synchronously dispose the pool and any HTTP/2 connections. Both only
- // await socket teardowns, which are non-blocking; .GetAwaiter()
- // .GetResult() is fine on the disposal path.
- _h2.DisposeAsync().AsTask().GetAwaiter().GetResult();
- _pool.DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
-
- private static NetworkError MapParseError(HttpError error) => error switch
- {
- HttpError.UnexpectedEof => NetworkError.TransportFailure,
- HttpError.TransportFailure => NetworkError.TransportFailure,
- HttpError.HeadersTooLarge => NetworkError.ProtocolError,
- HttpError.BodyTooLarge => NetworkError.ProtocolError,
- HttpError.BadStatusLine => NetworkError.ProtocolError,
- HttpError.BadHeader => NetworkError.ProtocolError,
- HttpError.BadChunkedFraming => NetworkError.ProtocolError,
- HttpError.UnsupportedEncoding => NetworkError.ProtocolError,
- HttpError.DecodeFailed => NetworkError.ProtocolError,
- _ => NetworkError.ProtocolError,
- };
-
- private readonly record struct TransportSendOutcome(bool UsedTransport, Result Result)
- {
- public static TransportSendOutcome Used(Result r) => new(true, r);
- public static TransportSendOutcome Unused() => new(false, default);
+ _http.Dispose();
}
}
@@ -579,8 +308,7 @@ public sealed class StarlingHttpClientOptions
/// (notably google.com) sniff this and serve a degraded, JS-free page to any
/// UA they don't recognise as a modern browser, so we present as a current
/// Chrome on macOS. Injected by
- /// when the request doesn't already carry a User-Agent, which is why it
- /// drives both the HTTP/1.1 and HTTP/2 paths from a single source of truth.
+ /// when the request doesn't already carry a User-Agent.
///
public const string DefaultUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
@@ -590,22 +318,11 @@ public sealed class StarlingHttpClientOptions
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
public string UserAgent { get; init; } = DefaultUserAgent;
public IReadOnlyList AlpnProtocols { get; init; } = ["h2", "http/1.1"];
- public DnsResolver? DnsResolver { get; init; }
public CookieJar? CookieJar { get; init; }
- public H1RequestWriter RequestWriter { get; init; } = new();
- public H1ResponseParser ResponseParser { get; init; } = new();
-
- ///
- /// Optional injected connection pool. When null, the client owns a
- /// freshly-constructed pool with default sizing (6 idle per origin,
- /// 60s idle timeout).
- ///
- public ConnectionPool? ConnectionPool { get; init; }
///
/// Optional logger factory. When set, the client emits per-request log
- /// messages and passes the factory through to HTTP/2 connections.
- /// Tracing spans and metrics are always emitted via
+ /// messages. Tracing spans and metrics are always emitted via
/// regardless.
/// Defaults to .
///
diff --git a/src/Starling.Net/Tcp/ITcpConnection.cs b/src/Starling.Net/Tcp/ITcpConnection.cs
deleted file mode 100644
index 25babbe4..00000000
--- a/src/Starling.Net/Tcp/ITcpConnection.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace Starling.Net.Tcp;
-
-///
-/// Async, byte-oriented TCP connection. Closed via .
-///
-///
-/// Read returns 0 to signal a clean half-close from the peer (matches
-/// 's
-/// semantics). Write may return fewer bytes than requested only when the
-/// underlying socket is shutting down; otherwise it loops internally.
-///
-public interface ITcpConnection : IAsyncDisposable
-{
- /// Remote endpoint we connected to (as written by the dialer).
- TcpEndpoint Endpoint { get; }
-
- /// True until either side closes the connection.
- bool IsOpen { get; }
-
- ValueTask ReadAsync(Memory buffer, CancellationToken ct);
- ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken ct);
- ValueTask ShutdownAsync(CancellationToken ct);
-}
diff --git a/src/Starling.Net/Tcp/SocketTcpConnection.cs b/src/Starling.Net/Tcp/SocketTcpConnection.cs
deleted file mode 100644
index 44d661e4..00000000
--- a/src/Starling.Net/Tcp/SocketTcpConnection.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System.Net.Sockets;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace Starling.Net.Tcp;
-
-internal static partial class SocketTcpConnectionLog
-{
- [LoggerMessage(Level = LogLevel.Debug, Message = "socket shutdown threw; peer may already be gone")]
- public static partial void ShutdownFailed(ILogger logger, Exception ex);
-}
-
-///
-/// implementation backed by a real
-/// . Pure managed per Rule 0.
-///
-internal sealed class SocketTcpConnection(Socket socket, TcpEndpoint endpoint, ILogger? log = null)
- : ITcpConnection
-{
- private readonly Socket _socket = socket ?? throw new ArgumentNullException(nameof(socket));
- private readonly ILogger _log = log ?? NullLogger.Instance;
- private bool _open = true;
-
- public TcpEndpoint Endpoint { get; } = endpoint;
-
- public bool IsOpen => _open && _socket.Connected;
-
- public async ValueTask ReadAsync(Memory buffer, CancellationToken ct)
- {
- if (!_open)
- {
- return 0;
- }
-
- var n = await _socket.ReceiveAsync(buffer, SocketFlags.None, ct)
- .ConfigureAwait(false);
- // A zero-length read request always completes with 0 bytes — that is
- // the documented "poll for readability" idiom, which SslStream issues
- // to await data without pinning a buffer. Only a 0-byte result for a
- // *non-empty* request is a peer half-close. Conflating the two marks
- // the connection dead on SslStream's first zero-byte read and breaks
- // the TLS handshake with a spurious EOF.
- if (n == 0 && !buffer.IsEmpty)
- {
- _open = false; // peer closed
- }
-
- return n;
- }
-
- public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken ct)
- {
- if (!_open)
- {
- throw new InvalidOperationException("connection is closed");
- }
-
- var sent = 0;
- while (sent < data.Length)
- {
- var n = await _socket.SendAsync(data[sent..], SocketFlags.None, ct)
- .ConfigureAwait(false);
- if (n == 0) { _open = false; break; }
- sent += n;
- }
- }
-
- public ValueTask ShutdownAsync(CancellationToken ct)
- {
- if (!_open)
- {
- return ValueTask.CompletedTask;
- }
-
- _open = false;
- try { _socket.Shutdown(SocketShutdown.Both); }
- catch (SocketException ex) { SocketTcpConnectionLog.ShutdownFailed(_log, ex); }
- _ = ct;
- return ValueTask.CompletedTask;
- }
-
- public async ValueTask DisposeAsync()
- {
- await ShutdownAsync(CancellationToken.None);
- _socket.Dispose();
- }
-}
diff --git a/src/Starling.Net/Tcp/TcpDialer.cs b/src/Starling.Net/Tcp/TcpDialer.cs
deleted file mode 100644
index f0f6ad84..00000000
--- a/src/Starling.Net/Tcp/TcpDialer.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using Starling.Common;
-using Starling.Net.Dns;
-
-namespace Starling.Net.Tcp;
-
-///
-/// Opens TCP connections by hostname, going through the Starling DNS
-/// resolver. Returns an on success.
-///
-///
-/// Tries each resolved address in order until one connects or all fail
-/// (Happy Eyeballs sequencing is not implemented yet). The connect attempt
-/// itself is bounded by . Cancellation tokens
-/// passed in by the caller compose with that timeout.
-///
-public sealed class TcpDialer
-{
- private readonly DnsResolver _dns;
-
- public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
-
- public TcpDialer(DnsResolver dnsResolver)
- {
- _dns = dnsResolver ?? throw new ArgumentNullException(nameof(dnsResolver));
- }
-
- public async Task> DialAsync(
- TcpEndpoint endpoint, CancellationToken ct = default)
- {
- var dnsResult = await _dns.ResolveAsync(endpoint.Hostname, ct).ConfigureAwait(false);
- if (dnsResult.IsErr)
- {
- return Result.Err(TcpError.DnsFailed);
- }
-
- Exception? last = null;
- foreach (var ip in dnsResult.Value.Addresses)
- {
- var family = ip.AddressFamily;
- var socket = new Socket(family, SocketType.Stream, ProtocolType.Tcp);
- try
- {
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(ConnectTimeout);
- await socket.ConnectAsync(new IPEndPoint(ip, endpoint.Port), cts.Token)
- .ConfigureAwait(false);
- return Result.Ok(
- new SocketTcpConnection(socket, endpoint));
- }
- catch (Exception ex)
- {
- last = ex;
- socket.Dispose();
- }
- }
- _ = last;
- return Result.Err(TcpError.ConnectFailed);
- }
-
- ///
- /// Connect directly to an already-resolved .
- /// Bypasses DNS — useful for tests against a local listener.
- ///
- public async Task> DialDirectAsync(
- IPEndPoint endpoint, TcpEndpoint label, CancellationToken ct = default)
- {
- var socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
- try
- {
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(ConnectTimeout);
- await socket.ConnectAsync(endpoint, cts.Token).ConfigureAwait(false);
- return Result.Ok(
- new SocketTcpConnection(socket, label));
- }
- catch
- {
- socket.Dispose();
- return Result.Err(TcpError.ConnectFailed);
- }
- }
-}
-
-public enum TcpError
-{
- DnsFailed,
- ConnectFailed,
- Timeout,
-}
diff --git a/src/Starling.Net/Tcp/TcpEndpoint.cs b/src/Starling.Net/Tcp/TcpEndpoint.cs
deleted file mode 100644
index bf1aa7dd..00000000
--- a/src/Starling.Net/Tcp/TcpEndpoint.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace Starling.Net.Tcp;
-
-///
-/// Identifies a TCP destination by its DNS-level and
-/// . The hostname is what we'll resolve; the resulting
-/// IP+port goes into the kernel.
-///
-public readonly record struct TcpEndpoint(string Hostname, int Port)
-{
- public override string ToString() => $"{Hostname}:{Port}";
-
- public static TcpEndpoint For(string hostname, int port)
- {
- if (string.IsNullOrWhiteSpace(hostname))
- {
- throw new ArgumentException("Hostname required.", nameof(hostname));
- }
-
- if (port is < 1 or > 65535)
- {
- throw new ArgumentOutOfRangeException(nameof(port), port, "Port must be 1..65535.");
- }
-
- return new TcpEndpoint(hostname, port);
- }
-}
diff --git a/src/Starling.Net/Tls/BcDuplexTlsStream.cs b/src/Starling.Net/Tls/BcDuplexTlsStream.cs
deleted file mode 100644
index e443dec1..00000000
--- a/src/Starling.Net/Tls/BcDuplexTlsStream.cs
+++ /dev/null
@@ -1,238 +0,0 @@
-using System.Buffers;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Org.BouncyCastle.Tls;
-
-namespace Starling.Net.Tls;
-
-internal static partial class BcDuplexTlsStreamLog
-{
- [LoggerMessage(Level = LogLevel.Debug, Message = "close_notify flush failed; peer may already be gone")]
- public static partial void CloseNotifyFailed(ILogger logger, Exception ex);
-}
-
-///
-/// A full-duplex over BouncyCastle's non-blocking
-/// . BouncyCastle's blocking stream serializes
-/// reads and writes (a read blocked waiting for the peer also blocks writes),
-/// which deadlocks HTTP/2 — where one reader loop and concurrent request writers
-/// must share the connection. This wrapper instead feeds ciphertext in/out of
-/// the protocol's in-memory buffers, holding a lock only across the (instant)
-/// state transitions and performing all socket I/O outside it. That lets a
-/// blocked socket read coexist with an in-flight write.
-///
-internal sealed class BcDuplexTlsStream : Stream
-{
- // A TLS record is at most 2^14 + overhead; this comfortably holds one read.
- private const int CipherBufferSize = 18 * 1024;
-
- private readonly TlsClientProtocol _protocol;
- private readonly Stream _transport;
- private readonly object _tlsGate = new();
- private readonly SemaphoreSlim _socketWrite = new(1, 1);
- private readonly byte[] _cipherReadBuffer = new byte[CipherBufferSize];
- private readonly ILogger _log;
- private bool _disposed;
-
- private BcDuplexTlsStream(TlsClientProtocol protocol, Stream transport, ILogger log)
- {
- _protocol = protocol;
- _transport = transport;
- _log = log;
- }
-
- ///
- /// Drive the (non-blocking) TLS handshake to completion over
- /// and return the established duplex stream.
- /// The handshake is single-threaded, so no locking is needed here.
- ///
- public static async Task HandshakeAsync(
- TlsClientProtocol protocol, TlsClient client, Stream transport, CancellationToken ct,
- ILogger? log = null)
- {
- log ??= NullLogger.Instance;
- protocol.Connect(client);
- await PumpOutputAsync(protocol, transport, ct).ConfigureAwait(false); // send ClientHello
-
- var cipher = new byte[CipherBufferSize];
- while (protocol.IsHandshaking)
- {
- var n = await transport.ReadAsync(cipher, ct).ConfigureAwait(false);
- if (n == 0)
- {
- throw new EndOfStreamException("peer closed during TLS handshake");
- }
-
- protocol.OfferInput(cipher, 0, n);
- await PumpOutputAsync(protocol, transport, ct).ConfigureAwait(false); // e.g. client Finished
- }
-
- return new BcDuplexTlsStream(protocol, transport, log);
- }
-
- private static async Task PumpOutputAsync(TlsClientProtocol protocol, Stream transport, CancellationToken ct)
- {
- var available = protocol.GetAvailableOutputBytes();
- if (available == 0)
- {
- return;
- }
-
- var buf = new byte[available];
- var read = protocol.ReadOutput(buf, 0, available);
- await transport.WriteAsync(buf.AsMemory(0, read), ct).ConfigureAwait(false);
- await transport.FlushAsync(ct).ConfigureAwait(false);
- }
-
- public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default)
- {
- if (buffer.IsEmpty)
- {
- return 0;
- }
-
- while (true)
- {
- // 1. Hand back any already-decrypted application data.
- byte[]? appChunk = null;
- var got = 0;
- lock (_tlsGate)
- {
- var available = _protocol.GetAvailableInputBytes();
- if (available > 0)
- {
- got = Math.Min(available, buffer.Length);
- appChunk = ArrayPool.Shared.Rent(got);
- _protocol.ReadInput(appChunk, 0, got);
- }
- }
- if (appChunk is not null)
- {
- appChunk.AsSpan(0, got).CopyTo(buffer.Span);
- ArrayPool.Shared.Return(appChunk);
- return got;
- }
-
- // 2. Otherwise pull ciphertext off the socket (outside the lock, so a
- // concurrent write isn't blocked) and feed it to the protocol.
- var n = await _transport.ReadAsync(_cipherReadBuffer, ct).ConfigureAwait(false);
- if (n == 0)
- {
- return 0; // clean EOF
- }
-
- byte[]? outChunk = null;
- var outLen = 0;
- lock (_tlsGate)
- {
- _protocol.OfferInput(_cipherReadBuffer, 0, n);
- // Processing input can produce output (e.g. a post-handshake
- // KeyUpdate response); flush it back to the peer.
- var pending = _protocol.GetAvailableOutputBytes();
- if (pending > 0)
- {
- outChunk = new byte[pending];
- outLen = _protocol.ReadOutput(outChunk, 0, pending);
- }
- }
- if (outChunk is not null)
- {
- await SocketWriteAsync(outChunk.AsMemory(0, outLen), ct).ConfigureAwait(false);
- }
- }
- }
-
- public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default)
- {
- if (buffer.IsEmpty)
- {
- return;
- }
-
- byte[] cipher;
- int cipherLen;
- lock (_tlsGate)
- {
- _protocol.WriteApplicationData(buffer.ToArray(), 0, buffer.Length);
- var pending = _protocol.GetAvailableOutputBytes();
- cipher = new byte[pending];
- cipherLen = _protocol.ReadOutput(cipher, 0, pending);
- }
- await SocketWriteAsync(cipher.AsMemory(0, cipherLen), ct).ConfigureAwait(false);
- }
-
- private async Task SocketWriteAsync(ReadOnlyMemory data, CancellationToken ct)
- {
- // Serialize socket writes: both the write path and read-path-generated
- // output (KeyUpdate, alerts) can reach here concurrently.
- await _socketWrite.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- await _transport.WriteAsync(data, ct).ConfigureAwait(false);
- await _transport.FlushAsync(ct).ConfigureAwait(false);
- }
- finally { _socketWrite.Release(); }
- }
-
- public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
- public override void Flush() { }
-
- public override int Read(byte[] buffer, int offset, int count) =>
- ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
-
- public override void Write(byte[] buffer, int offset, int count) =>
- WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
-
- public override bool CanRead => !_disposed;
- public override bool CanWrite => !_disposed;
- public override bool CanSeek => false;
- public override long Length => throw new NotSupportedException();
- public override long Position
- {
- get => throw new NotSupportedException();
- set => throw new NotSupportedException();
- }
-
- public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
- public override void SetLength(long value) => throw new NotSupportedException();
-
- protected override void Dispose(bool disposing)
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- if (disposing)
- {
- // Best-effort close_notify, then tear down the socket.
- try
- {
- byte[]? outChunk = null;
- var outLen = 0;
- lock (_tlsGate)
- {
- _protocol.Close();
- var pending = _protocol.GetAvailableOutputBytes();
- if (pending > 0)
- {
- outChunk = new byte[pending];
- outLen = _protocol.ReadOutput(outChunk, 0, pending);
- }
- }
- if (outChunk is not null)
- {
- _socketWrite.Wait();
- try { _transport.Write(outChunk, 0, outLen); }
- finally { _socketWrite.Release(); }
- }
- }
- catch (Exception ex) { BcDuplexTlsStreamLog.CloseNotifyFailed(_log, ex); /* the peer may already be gone */ }
-
- _socketWrite.Dispose();
- _transport.Dispose();
- }
- base.Dispose(disposing);
- }
-}
diff --git a/src/Starling.Net/Tls/BcTlsTransport.cs b/src/Starling.Net/Tls/BcTlsTransport.cs
deleted file mode 100644
index 6d9a40b7..00000000
--- a/src/Starling.Net/Tls/BcTlsTransport.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-using Starling.Common;
-using Starling.Net.Tcp;
-using Org.BouncyCastle.Security;
-using Org.BouncyCastle.Tls;
-using Org.BouncyCastle.Tls.Crypto.Impl.BC;
-
-namespace Starling.Net.Tls;
-
-///
-/// Pure-managed TLS transport built on BouncyCastle's TLS 1.3 implementation.
-/// Drives the protocol in non-blocking mode behind
-/// so reads and writes can proceed concurrently — a hard requirement for HTTP/2,
-/// which multiplexes a single connection between a reader loop and request
-/// writers.
-///
-public sealed class BcTlsTransport : ITlsTransport
-{
- private readonly TlsClientProtocol _protocol;
- private readonly BcDuplexTlsStream _stream;
- private bool _disposed;
-
- private BcTlsTransport(
- TlsClientProtocol protocol,
- BcDuplexTlsStream stream,
- string? negotiatedApplicationProtocol,
- CertificateSummary? peerCertificate)
- {
- _protocol = protocol;
- _stream = stream;
- NegotiatedApplicationProtocol = negotiatedApplicationProtocol;
- PeerCertificate = peerCertificate;
- }
-
- public Stream Stream => _stream;
- public string? NegotiatedApplicationProtocol { get; }
- public CertificateSummary? PeerCertificate { get; }
-
- public static async Task> ConnectAsync(
- ITcpConnection tcpConnection,
- TlsClientOptions options,
- CancellationToken ct = default)
- {
- if (tcpConnection is null)
- {
- throw new ArgumentNullException(nameof(tcpConnection));
- }
-
- if (options is null)
- {
- throw new ArgumentNullException(nameof(options));
- }
-
- if (string.IsNullOrWhiteSpace(options.ServerName) || options.ApplicationProtocols.Count == 0)
- {
- return Result.Err(TlsError.InvalidOptions);
- }
-
- var tcpStream = new TcpConnectionStream(tcpConnection);
- var client = new StarlingTlsClient(
- new BcTlsCrypto(new SecureRandom()),
- options,
- RootCertificates.SystemTrust);
- var protocol = new TlsClientProtocol(); // non-blocking mode
-
- try
- {
- var stream = await BcDuplexTlsStream.HandshakeAsync(protocol, client, tcpStream, ct)
- .ConfigureAwait(false);
- return Result.Ok(
- new BcTlsTransport(protocol, stream, client.NegotiatedApplicationProtocol, client.PeerCertificate));
- }
- catch (TlsFatalAlert alert) when (alert.AlertDescription is AlertDescription.bad_certificate
- or AlertDescription.certificate_expired
- or AlertDescription.certificate_revoked
- or AlertDescription.certificate_unknown
- or AlertDescription.unknown_ca)
- {
- protocol.Close();
- return Result.Err(TlsError.CertificateRejected);
- }
- catch
- {
- protocol.Close();
- return Result.Err(TlsError.HandshakeFailed);
- }
- }
-
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- // Disposing the duplex stream sends close_notify (best-effort) and tears
- // down the wrapped TCP connection.
- _stream.Dispose();
- }
-}
diff --git a/src/Starling.Net/Tls/CertificateVerifier.cs b/src/Starling.Net/Tls/CertificateVerifier.cs
index b0b8a78e..3bb8e9c3 100644
--- a/src/Starling.Net/Tls/CertificateVerifier.cs
+++ b/src/Starling.Net/Tls/CertificateVerifier.cs
@@ -1,236 +1,95 @@
-using Org.BouncyCastle.Asn1.X509;
-using Org.BouncyCastle.Pkix;
-using Org.BouncyCastle.Tls;
-using Org.BouncyCastle.Utilities.Collections;
-using Org.BouncyCastle.X509;
-using Org.BouncyCastle.X509.Store;
+using System.Security.Cryptography.X509Certificates;
namespace Starling.Net.Tls;
+///
+/// Server-certificate verification against the bundled trust anchors. Chains the
+/// presented leaf to a root in (the OS trust store
+/// is not consulted unless folded in via ),
+/// enforces the validity window and path constraints via ,
+/// and matches the requested host against the leaf's subject alternative names.
+/// Starling fails closed: a rejected chain aborts the connection.
+///
public static class CertificateVerifier
{
public static bool Verify(
- Certificate certificate,
+ X509Certificate2 leaf,
+ X509Certificate2Collection? presentedIntermediates,
string hostname,
RootCertificates roots,
- DateTimeOffset? validationTime = null,
- RevocationSet? revocations = null)
+ DateTimeOffset? validationTime = null)
{
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- if (roots is null)
- {
- throw new ArgumentNullException(nameof(roots));
- }
+ ArgumentNullException.ThrowIfNull(leaf);
+ ArgumentNullException.ThrowIfNull(roots);
if (string.IsNullOrWhiteSpace(hostname))
{
return false;
}
- var chain = DecodeChain(certificate);
- if (chain.Count == 0)
+ // RFC 6125 host match. No fall-through to the legacy CN field.
+ if (!leaf.MatchesHostname(hostname, allowWildcards: true, allowCommonName: false))
{
return false;
}
- // PKIX path validation does not match the hostname, so that stays ours.
- // Everything else — path building to a trusted anchor, signatures,
- // validity windows, basic constraints, key usage, name constraints — is
- // delegated to BouncyCastle's RFC 5280 validator below.
- if (!CertificateHostNameMatcher.Matches(chain[0], hostname))
+ using var chain = new X509Chain();
+ var policy = chain.ChainPolicy;
+ // Chain to our bundled anchors only — the OS trust store is not consulted.
+ policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ policy.CustomTrustStore.AddRange(roots.Certificates);
+ // Live OCSP/CRL would add blocking network I/O on the handshake path.
+ policy.RevocationMode = X509RevocationMode.NoCheck;
+ policy.VerificationFlags = X509VerificationFlags.NoFlag;
+ if (validationTime is { } when)
{
- return false;
- }
-
- var path = BuildTrustedPath(chain, roots.Certificates, validationTime);
- if (path is null)
- {
- return false;
+ policy.VerificationTime = when.UtcDateTime;
}
-
- // Local CRLSet-style blocklist check over the validated path. Empty by
- // default, so this is a no-op until a revocation feed is loaded.
- var revocationSet = revocations ?? RevocationSet.Empty;
- if (!revocationSet.IsEmpty && PathContainsRevokedCert(path, revocationSet))
+ if (presentedIntermediates is { Count: > 0 })
{
- return false;
+ policy.ExtraStore.AddRange(presentedIntermediates);
}
- return true;
- }
-
- // Build and validate a path from the leaf (chain[0]) to any trusted root,
- // using the presented certs as the pool of candidate intermediates. The
- // builder picks the shortest valid path, so extra cross-sign certs the
- // server appends above the real anchor (e.g. Google's GTS Root R1 trailed by
- // a legacy GlobalSign root we don't bundle) no longer cause a rejection.
- // Returns null when no valid path exists.
- private static PkixCertPathBuilderResult? BuildTrustedPath(
- List chain,
- IReadOnlyList roots,
- DateTimeOffset? validationTime)
- {
- var anchors = new HashSet();
- foreach (var root in roots)
- {
- anchors.Add(new TrustAnchor(root, null));
- }
-
- var target = new X509CertStoreSelector { Certificate = chain[0] };
- var parameters = new PkixBuilderParameters(anchors, target)
- {
- // Live OCSP/CRL would add blocking network I/O on the handshake path.
- // Revocation is handled out of band by the local blocklist below.
- IsRevocationEnabled = false,
- Date = (validationTime ?? DateTimeOffset.UtcNow).UtcDateTime,
- };
- parameters.AddStoreCert(CollectionUtilities.CreateStore(chain));
-
- try
- {
- return new PkixCertPathBuilder().Build(parameters);
- }
- catch (PkixCertPathBuilderException)
- {
- return null;
- }
- }
-
- // Walk the built path from leaf up to the trust anchor, checking each cert
- // against the blocklist. The issuer of each cert is the next one up; the
- // anchor is self-issued.
- private static bool PathContainsRevokedCert(PkixCertPathBuilderResult path, RevocationSet revocations)
- {
- var ordered = new List(path.CertPath.Certificates) { path.TrustAnchor.TrustedCert };
- for (var i = 0; i < ordered.Count; i++)
- {
- var issuer = i + 1 < ordered.Count ? ordered[i + 1] : ordered[i];
- if (revocations.IsRevoked(ordered[i], issuer))
- {
- return true;
- }
- }
-
- return false;
+ return chain.Build(leaf);
}
///
- /// Build a display summary of the leaf (end-entity) certificate. Returns
- /// null when the chain is empty. Intended for UI surfaces (the lock popover)
- /// after has already accepted the chain.
+ /// Build a display summary of the leaf certificate for the shell lock UI.
+ /// Intended to be called after has accepted the chain.
///
- public static CertificateSummary? Summarize(Certificate certificate)
+ public static CertificateSummary Summarize(X509Certificate2 leaf)
{
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- var chain = DecodeChain(certificate);
- if (chain.Count == 0)
- {
- return null;
- }
-
- var leaf = chain[0];
+ ArgumentNullException.ThrowIfNull(leaf);
return new CertificateSummary(
- FriendlyName(leaf.SubjectDN),
- FriendlyName(leaf.IssuerDN),
- new DateTimeOffset(leaf.NotBefore.ToUniversalTime(), TimeSpan.Zero),
- new DateTimeOffset(leaf.NotAfter.ToUniversalTime(), TimeSpan.Zero));
+ FriendlyName(leaf.SubjectName, leaf.Subject),
+ FriendlyName(leaf.IssuerName, leaf.Issuer),
+ leaf.NotBefore.ToUniversalTime(),
+ leaf.NotAfter.ToUniversalTime());
}
// Prefer the common name; fall back to the organisation, then the full DN.
- private static string FriendlyName(X509Name dn)
+ private static string FriendlyName(X500DistinguishedName dn, string fullDn)
{
- foreach (var oid in new[] { X509Name.CN, X509Name.O })
+ foreach (var oid in new[] { "CN", "O" })
{
- var values = dn.GetValueList(oid);
- if (values.Count > 0 && values[0] is string s && !string.IsNullOrWhiteSpace(s))
+ var value = FindRdn(dn, oid);
+ if (!string.IsNullOrWhiteSpace(value))
{
- return s;
+ return value;
}
}
- return dn.ToString();
+ return fullDn;
}
- private static List DecodeChain(Certificate certificate)
+ private static string? FindRdn(X500DistinguishedName dn, string oidFriendlyName)
{
- var parser = new X509CertificateParser();
- return certificate.GetCertificateList()
- .Select(tlsCertificate => parser.ReadCertificate(tlsCertificate.GetEncoded()))
- .ToList();
- }
-}
-
-public static class CertificateHostNameMatcher
-{
- public static bool Matches(X509Certificate certificate, string hostname)
- {
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- if (string.IsNullOrWhiteSpace(hostname))
+ foreach (var rdn in dn.EnumerateRelativeDistinguishedNames())
{
- return false;
- }
-
- var normalizedHost = hostname.Trim().TrimEnd('.').ToLowerInvariant();
- var names = certificate.GetSubjectAlternativeNames();
- if (names is null || names.Count == 0)
- {
- return false;
- }
-
- foreach (var name in names)
- {
- if (name.Count < 2 || name[0] is not int { } type || type != GeneralName.DnsName)
- {
- continue;
- }
-
- if (name[1] is string dnsName && MatchDnsName(dnsName, normalizedHost))
+ if (string.Equals(rdn.GetSingleElementType().FriendlyName, oidFriendlyName, StringComparison.OrdinalIgnoreCase))
{
- return true;
+ return rdn.GetSingleElementValue();
}
}
-
- return false;
- }
-
- public static bool MatchDnsName(string pattern, string hostname)
- {
- var normalizedPattern = pattern.Trim().TrimEnd('.').ToLowerInvariant();
- var normalizedHost = hostname.Trim().TrimEnd('.').ToLowerInvariant();
- if (normalizedPattern.Length == 0 || normalizedHost.Length == 0)
- {
- return false;
- }
-
- if (!normalizedPattern.Contains('*', StringComparison.Ordinal))
- {
- return normalizedPattern == normalizedHost;
- }
-
- if (!normalizedPattern.StartsWith("*.", StringComparison.Ordinal)
- || normalizedPattern.IndexOf('*', 1) >= 0)
- {
- return false;
- }
-
- var suffix = normalizedPattern[1..];
- if (!normalizedHost.EndsWith(suffix, StringComparison.Ordinal))
- {
- return false;
- }
-
- var unmatched = normalizedHost[..^suffix.Length];
- return unmatched.Length > 0 && !unmatched.Contains('.', StringComparison.Ordinal);
+ return null;
}
}
diff --git a/src/Starling.Net/Tls/ITlsTransport.cs b/src/Starling.Net/Tls/ITlsTransport.cs
deleted file mode 100644
index 0d5ba00d..00000000
--- a/src/Starling.Net/Tls/ITlsTransport.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace Starling.Net.Tls;
-
-///
-/// TLS-protected byte stream established over a Starling TCP connection.
-///
-public interface ITlsTransport : IDisposable
-{
- Stream Stream { get; }
- string? NegotiatedApplicationProtocol { get; }
-
- /// The verified leaf certificate presented by the peer, or null.
- CertificateSummary? PeerCertificate { get; }
-}
diff --git a/src/Starling.Net/Tls/RevocationSet.cs b/src/Starling.Net/Tls/RevocationSet.cs
deleted file mode 100644
index 23f6bbdd..00000000
--- a/src/Starling.Net/Tls/RevocationSet.cs
+++ /dev/null
@@ -1,188 +0,0 @@
-using System.Security.Cryptography;
-using Org.BouncyCastle.X509;
-
-namespace Starling.Net.Tls;
-
-///
-/// A local, CRLSet-style revocation blocklist consulted during certificate
-/// validation. It holds two kinds of entries, both matched in memory with no
-/// network on the handshake path:
-///
-///
-/// - Blocked SPKIs — the SHA-256 of a Subject Public Key Info. Any
-/// cert carrying that key is rejected wherever it appears in the path. Used to
-/// distrust a whole compromised or misbehaving CA key.
-/// - Revoked serials — an (issuer SPKI SHA-256, serial) pair, the
-/// standard way to revoke one leaf or intermediate without distrusting its
-/// issuer.
-///
-///
-/// The set is empty by default, so it changes no behaviour until a data source
-/// populates it. Refreshing that data from an out-of-band feed is a separate,
-/// later piece — this type is only the consumer.
-///
-public sealed class RevocationSet
-{
- private readonly HashSet _blockedSpki;
- private readonly HashSet<(string IssuerSpki, string Serial)> _revokedSerials;
-
- private RevocationSet(
- HashSet blockedSpki,
- HashSet<(string, string)> revokedSerials)
- {
- _blockedSpki = blockedSpki;
- _revokedSerials = revokedSerials;
- }
-
- /// A set with no entries — revokes nothing.
- public static RevocationSet Empty { get; } =
- new(new HashSet(), new HashSet<(string, string)>());
-
- ///
- /// The blocklist live connections consult. Loaded from an embedded resource
- /// if one is present, otherwise .
- ///
- public static RevocationSet Default { get; } = LoadDefault();
-
- public bool IsEmpty => _blockedSpki.Count == 0 && _revokedSerials.Count == 0;
-
- public static RevocationSet Create(
- IEnumerable blockedSpki,
- IEnumerable<(string IssuerSpki, string Serial)> revokedSerials)
- {
- if (blockedSpki is null)
- {
- throw new ArgumentNullException(nameof(blockedSpki));
- }
-
- if (revokedSerials is null)
- {
- throw new ArgumentNullException(nameof(revokedSerials));
- }
-
- return new RevocationSet(
- blockedSpki.Select(Normalize).ToHashSet(),
- revokedSerials.Select(e => (Normalize(e.IssuerSpki), Normalize(e.Serial))).ToHashSet());
- }
-
- ///
- /// True when is revoked — either its key is
- /// blocked outright, or its serial is revoked under .
- ///
- public bool IsRevoked(X509Certificate certificate, X509Certificate issuer)
- {
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- if (issuer is null)
- {
- throw new ArgumentNullException(nameof(issuer));
- }
-
- if (IsEmpty)
- {
- return false;
- }
-
- if (_blockedSpki.Count > 0 && _blockedSpki.Contains(SpkiHash(certificate)))
- {
- return true;
- }
-
- return _revokedSerials.Count > 0
- && _revokedSerials.Contains((SpkiHash(issuer), SerialHex(certificate)));
- }
-
- /// SHA-256 of the cert's Subject Public Key Info, as upper-hex.
- public static string SpkiHash(X509Certificate certificate)
- {
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- var spki = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory
- .CreateSubjectPublicKeyInfo(certificate.GetPublicKey())
- .GetDerEncoded();
- return Convert.ToHexString(SHA256.HashData(spki));
- }
-
- /// The cert's serial number as upper-hex of its unsigned big-endian bytes.
- public static string SerialHex(X509Certificate certificate)
- {
- if (certificate is null)
- {
- throw new ArgumentNullException(nameof(certificate));
- }
-
- return Convert.ToHexString(certificate.SerialNumber.ToByteArrayUnsigned());
- }
-
- ///
- /// Parse the blocklist text format. One directive per line, '#' starts a
- /// comment, blank lines ignored:
- ///
- /// spki <sha256-hex-of-SPKI>
- /// serial <sha256-hex-of-issuer-SPKI> <serial-hex>
- ///
- ///
- public static RevocationSet FromText(Stream textStream)
- {
- if (textStream is null)
- {
- throw new ArgumentNullException(nameof(textStream));
- }
-
- var blockedSpki = new HashSet();
- var revokedSerials = new HashSet<(string, string)>();
-
- using var reader = new StreamReader(textStream);
- string? line;
- while ((line = reader.ReadLine()) is not null)
- {
- var trimmed = line.Trim();
- if (trimmed.Length == 0 || trimmed[0] == '#')
- {
- continue;
- }
-
- var parts = trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
- switch (parts[0].ToLowerInvariant())
- {
- case "spki" when parts.Length == 2:
- blockedSpki.Add(Normalize(parts[1]));
- break;
- case "serial" when parts.Length == 3:
- revokedSerials.Add((Normalize(parts[1]), Normalize(parts[2])));
- break;
- default:
- throw new InvalidDataException($"malformed revocation entry: {trimmed}");
- }
- }
-
- return new RevocationSet(blockedSpki, revokedSerials);
- }
-
- private static string Normalize(string hex) =>
- (hex ?? throw new ArgumentNullException(nameof(hex)))
- .Trim()
- .Replace(":", "", StringComparison.Ordinal)
- .ToUpperInvariant();
-
- private static RevocationSet LoadDefault()
- {
- const string resourceSuffix = ".Resources.Revocations.blocklist.txt";
- var assembly = typeof(RevocationSet).Assembly;
- var resourceName = assembly.GetManifestResourceNames()
- .FirstOrDefault(name => name.EndsWith(resourceSuffix, StringComparison.Ordinal));
- if (resourceName is null)
- {
- return Empty;
- }
-
- using var stream = assembly.GetManifestResourceStream(resourceName);
- return stream is null ? Empty : FromText(stream);
- }
-}
diff --git a/src/Starling.Net/Tls/RootCertificates.cs b/src/Starling.Net/Tls/RootCertificates.cs
index fa152693..7809091c 100644
--- a/src/Starling.Net/Tls/RootCertificates.cs
+++ b/src/Starling.Net/Tls/RootCertificates.cs
@@ -1,13 +1,18 @@
-using Org.BouncyCastle.X509;
+using System.Security.Cryptography.X509Certificates;
namespace Starling.Net.Tls;
+///
+/// The trust anchors Starling chains server certificates to. Backed by the
+/// embedded CCADB bundle so trust decisions are deterministic across machines,
+/// independent of the OS trust store.
+///
public sealed class RootCertificates
{
private const string ResourceSuffix = ".Resources.Roots.ccadb.pem";
- private readonly IReadOnlyList _certificates;
+ private readonly X509Certificate2Collection _certificates;
- private RootCertificates(IReadOnlyList certificates)
+ private RootCertificates(X509Certificate2Collection certificates)
{
_certificates = certificates;
}
@@ -26,7 +31,11 @@ private RootCertificates(IReadOnlyList certificates)
///
public static RootCertificates SystemTrust { get; } = BuildSystemTrust();
- public IReadOnlyList Certificates => _certificates;
+ ///
+ /// The trust anchors as a collection suitable for
+ /// .
+ ///
+ public X509Certificate2Collection Certificates => _certificates;
public static RootCertificates FromPem(Stream pemStream)
{
@@ -35,9 +44,11 @@ public static RootCertificates FromPem(Stream pemStream)
throw new ArgumentNullException(nameof(pemStream));
}
- var parser = new X509CertificateParser();
- var certificates = parser.ReadCertificates(pemStream).ToArray();
- if (certificates.Length == 0)
+ using var reader = new StreamReader(pemStream);
+ var pem = reader.ReadToEnd();
+ var certificates = new X509Certificate2Collection();
+ certificates.ImportFromPem(pem);
+ if (certificates.Count == 0)
{
throw new InvalidDataException("root certificate bundle is empty");
}
@@ -47,18 +58,20 @@ public static RootCertificates FromPem(Stream pemStream)
private static RootCertificates BuildSystemTrust()
{
- var combined = new List(Default._certificates);
- // Dedup by encoded bytes so a CA present in both the bundle and the OS
- // store becomes a single trust anchor.
- var seen = new HashSet(Default._certificates.Count);
+ var combined = new X509Certificate2Collection();
+ combined.AddRange(Default._certificates);
+
+ // Dedup by thumbprint so a CA present in both the bundle and the OS store
+ // becomes a single trust anchor.
+ var seen = new HashSet(Default._certificates.Count, StringComparer.Ordinal);
foreach (var certificate in Default._certificates)
{
- seen.Add(Fingerprint(certificate));
+ seen.Add(certificate.Thumbprint);
}
foreach (var certificate in SystemRootCertificates.Load())
{
- if (seen.Add(Fingerprint(certificate)))
+ if (seen.Add(certificate.Thumbprint))
{
combined.Add(certificate);
}
@@ -67,9 +80,6 @@ private static RootCertificates BuildSystemTrust()
return new RootCertificates(combined);
}
- private static string Fingerprint(X509Certificate certificate) =>
- Convert.ToHexString(certificate.GetEncoded());
-
private static RootCertificates LoadDefault()
{
var assembly = typeof(RootCertificates).Assembly;
diff --git a/src/Starling.Net/Tls/StarlingTlsAuthentication.cs b/src/Starling.Net/Tls/StarlingTlsAuthentication.cs
deleted file mode 100644
index 100b3911..00000000
--- a/src/Starling.Net/Tls/StarlingTlsAuthentication.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using Org.BouncyCastle.Tls;
-
-namespace Starling.Net.Tls;
-
-internal sealed class StarlingTlsAuthentication : TlsAuthentication
-{
- private readonly TlsClientOptions _options;
- private readonly RootCertificates _roots;
- private readonly RevocationSet _revocations;
- private readonly Action _onVerified;
-
- public StarlingTlsAuthentication(
- TlsClientOptions options,
- RootCertificates roots,
- RevocationSet revocations,
- Action onVerified)
- {
- _options = options;
- _roots = roots;
- _revocations = revocations;
- _onVerified = onVerified;
- }
-
- public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
- {
- if (!CertificateVerifier.Verify(
- serverCertificate.Certificate, _options.ServerName, _roots, _options.ValidationTime, _revocations))
- {
- throw new TlsFatalAlert(AlertDescription.bad_certificate, "server certificate validation failed");
- }
-
- // Capture the verified leaf for the UI lock popover. Only reached once
- // the chain has validated against the bundled root store.
- _onVerified(CertificateVerifier.Summarize(serverCertificate.Certificate));
- }
-
- public TlsCredentials? GetClientCredentials(CertificateRequest certificateRequest) => null;
-}
diff --git a/src/Starling.Net/Tls/StarlingTlsClient.cs b/src/Starling.Net/Tls/StarlingTlsClient.cs
deleted file mode 100644
index b9bfd1df..00000000
--- a/src/Starling.Net/Tls/StarlingTlsClient.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using System.Text;
-using Org.BouncyCastle.Tls;
-using Org.BouncyCastle.Tls.Crypto;
-
-namespace Starling.Net.Tls;
-
-internal sealed class StarlingTlsClient : DefaultTlsClient
-{
- private readonly TlsClientOptions _options;
- private readonly RootCertificates _roots;
- private readonly IList _protocolNames;
-
- public StarlingTlsClient(TlsCrypto crypto, TlsClientOptions options, RootCertificates roots)
- : base(crypto)
- {
- _options = options;
- _roots = roots;
- _protocolNames = options.ApplicationProtocols
- .Select(ProtocolName.AsUtf8Encoding)
- .ToArray();
- }
-
- public string? NegotiatedApplicationProtocol { get; private set; }
-
- /// The verified leaf certificate, available after the handshake.
- public CertificateSummary? PeerCertificate { get; private set; }
-
- public override TlsAuthentication GetAuthentication() =>
- new StarlingTlsAuthentication(
- _options, _roots, RevocationSet.Default, cert => PeerCertificate = cert);
-
- public override IDictionary GetClientExtensions()
- {
- var extensions = TlsExtensionsUtilities.EnsureExtensionsInitialised(base.GetClientExtensions());
- return AddStarlingExtensions(extensions);
- }
-
- internal IDictionary CreateClientExtensionsForTesting() =>
- AddStarlingExtensions(new Dictionary());
-
- public override void ProcessServerExtensions(IDictionary serverExtensions)
- {
- base.ProcessServerExtensions(serverExtensions);
- var protocol = TlsExtensionsUtilities.GetAlpnExtensionServer(serverExtensions);
- NegotiatedApplicationProtocol = protocol?.GetUtf8Decoding();
- }
-
- protected override IList GetProtocolNames() => _protocolNames;
-
- protected override IList GetSniServerNames() =>
- [new ServerName(NameType.host_name, Encoding.ASCII.GetBytes(_options.ServerName))];
-
- protected override ProtocolVersion[] GetSupportedVersions() =>
- [ProtocolVersion.TLSv13, ProtocolVersion.TLSv12];
-
- protected override int[] GetSupportedCipherSuites() =>
- [
- CipherSuite.TLS_AES_128_GCM_SHA256,
- CipherSuite.TLS_AES_256_GCM_SHA384,
- CipherSuite.TLS_CHACHA20_POLY1305_SHA256,
- CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
- CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
- CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
- CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
- CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
- CipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
- ];
-
- private IDictionary AddStarlingExtensions(IDictionary extensions)
- {
- TlsExtensionsUtilities.AddServerNameExtensionClient(extensions, GetSniServerNames());
- TlsExtensionsUtilities.AddAlpnExtensionClient(extensions, _protocolNames);
- return extensions;
- }
-}
diff --git a/src/Starling.Net/Tls/SystemRootCertificates.cs b/src/Starling.Net/Tls/SystemRootCertificates.cs
index d7d66984..a1ba6dd9 100644
--- a/src/Starling.Net/Tls/SystemRootCertificates.cs
+++ b/src/Starling.Net/Tls/SystemRootCertificates.cs
@@ -1,16 +1,11 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
-using Org.BouncyCastle.X509;
-using BcCertificate = Org.BouncyCastle.X509.X509Certificate;
namespace Starling.Net.Tls;
internal static partial class SystemRootCertificatesLog
{
- [LoggerMessage(Level = LogLevel.Debug, Message = "skipping unparseable certificate in {StoreLocation} Root store")]
- public static partial void DroppedCert(ILogger logger, Exception ex, string storeLocation);
-
[LoggerMessage(Level = LogLevel.Debug, Message = "OS Root store unavailable for {StoreLocation}")]
public static partial void StoreUnavailable(ILogger logger, Exception ex, string storeLocation);
}
@@ -27,11 +22,10 @@ internal static partial class SystemRootCertificatesLog
///
internal static class SystemRootCertificates
{
- public static IReadOnlyList Load(ILogger? log = null)
+ public static IReadOnlyList Load(ILogger? log = null)
{
log ??= NullLogger.Instance;
- var parser = new X509CertificateParser();
- var certificates = new List();
+ var certificates = new List();
foreach (var location in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine })
{
@@ -41,16 +35,7 @@ public static IReadOnlyList Load(ILogger? log = null)
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var osCertificate in store.Certificates)
{
- try
- {
- certificates.Add(parser.ReadCertificate(osCertificate.RawData));
- }
- catch (Exception ex)
- {
- // Skip any entry BouncyCastle can't parse; one bad cert
- // must not poison the whole store.
- SystemRootCertificatesLog.DroppedCert(log, ex, location.ToString());
- }
+ certificates.Add(osCertificate);
}
}
catch (Exception ex)
diff --git a/src/Starling.Net/Tls/TcpConnectionStream.cs b/src/Starling.Net/Tls/TcpConnectionStream.cs
deleted file mode 100644
index ca57bb73..00000000
--- a/src/Starling.Net/Tls/TcpConnectionStream.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using Starling.Net.Tcp;
-
-namespace Starling.Net.Tls;
-
-internal sealed class TcpConnectionStream : Stream
-{
- private readonly ITcpConnection _connection;
-
- public TcpConnectionStream(ITcpConnection connection)
- {
- _connection = connection ?? throw new ArgumentNullException(nameof(connection));
- }
-
- public override bool CanRead => _connection.IsOpen;
- public override bool CanSeek => false;
- public override bool CanWrite => _connection.IsOpen;
- public override long Length => throw new NotSupportedException();
- public override long Position
- {
- get => throw new NotSupportedException();
- set => throw new NotSupportedException();
- }
-
- public override void Flush()
- {
- }
-
- public override int Read(byte[] buffer, int offset, int count) =>
- _connection.ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None)
- .AsTask()
- .GetAwaiter()
- .GetResult();
-
- public override void Write(byte[] buffer, int offset, int count) =>
- _connection.WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None)
- .AsTask()
- .GetAwaiter()
- .GetResult();
-
- public override async ValueTask ReadAsync(
- Memory buffer,
- CancellationToken cancellationToken = default) =>
- await _connection.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
-
- public override async ValueTask WriteAsync(
- ReadOnlyMemory buffer,
- CancellationToken cancellationToken = default) =>
- await _connection.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
-
- public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
- public override void SetLength(long value) => throw new NotSupportedException();
-
- protected override void Dispose(bool disposing)
- {
- if (disposing)
- {
- _connection.DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
-
- base.Dispose(disposing);
- }
-}
diff --git a/src/Starling.Net/Tls/TlsClientOptions.cs b/src/Starling.Net/Tls/TlsClientOptions.cs
deleted file mode 100644
index f75a2c72..00000000
--- a/src/Starling.Net/Tls/TlsClientOptions.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Starling.Net.Tls;
-
-public sealed record TlsClientOptions(
- string ServerName,
- IReadOnlyList ApplicationProtocols,
- DateTimeOffset? ValidationTime = null)
-{
- public static TlsClientOptions ForHttps(string serverName) =>
- new(serverName, ["h2", "http/1.1"]);
-}
diff --git a/src/Starling.Net/Tls/TlsError.cs b/src/Starling.Net/Tls/TlsError.cs
deleted file mode 100644
index 32595658..00000000
--- a/src/Starling.Net/Tls/TlsError.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Starling.Net.Tls;
-
-public enum TlsError
-{
- InvalidOptions,
- HandshakeFailed,
- CertificateRejected,
-}
diff --git a/tests/Starling.Engine.Tests/EngineHttpTests.cs b/tests/Starling.Engine.Tests/EngineHttpTests.cs
index 2927d0fb..1ff020dd 100644
--- a/tests/Starling.Engine.Tests/EngineHttpTests.cs
+++ b/tests/Starling.Engine.Tests/EngineHttpTests.cs
@@ -5,7 +5,6 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Starling.Net;
-using Starling.Net.Http;
using StarlingUrlParser = global::Starling.Url.UrlParser;
namespace Starling.Engine.Tests;
@@ -288,9 +287,6 @@ public async Task StarlingHttpClient_reuses_a_single_TCP_connection_across_two_s
server.AcceptCount.Should().Be(1,
"the pool must reuse the kept-alive TCP connection across sequential GETs");
- client.ConnectionPool.IdleCountFor(
- OriginKey.Create("http", "localhost", server.Port))
- .Should().Be(1, "after the second response the transport returns to the idle pool");
}
[TestMethod]
@@ -356,7 +352,6 @@ public async Task StarlingHttpClient_does_not_reuse_when_server_closes_the_conne
(await client.GetAsync(url, CancellationToken.None)).IsOk.Should().BeTrue();
server.AcceptCount.Should().Be(2);
- client.ConnectionPool.IdleCount.Should().Be(0);
}
[TestMethod]
diff --git a/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs b/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs
deleted file mode 100644
index fedefd99..00000000
--- a/tests/Starling.Net.Tests/Dns/DnsMessageTests.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using AwesomeAssertions;
-using Starling.Net.Dns;
-namespace Starling.Net.Tests.Dns;
-
-[TestClass]
-public class DnsMessageTests
-{
- [TestMethod]
- public void EncodeName_simple_hostname()
- {
- var bytes = DnsMessage.EncodeName("example.com");
- // 7,e,x,a,m,p,l,e, 3,c,o,m, 0
- bytes.Should().Equal(
- 0x07, (byte)'e', (byte)'x', (byte)'a', (byte)'m', (byte)'p', (byte)'l', (byte)'e',
- 0x03, (byte)'c', (byte)'o', (byte)'m',
- 0x00);
- }
-
- [TestMethod]
- public void EncodeName_with_trailing_dot_is_same_as_without()
- {
- DnsMessage.EncodeName("example.com.").Should().Equal(DnsMessage.EncodeName("example.com"));
- }
-
- [TestMethod]
- public void EncodeName_empty_emits_root_only()
- {
- DnsMessage.EncodeName("").Should().Equal((byte)0);
- }
-
- [TestMethod]
- public void EncodeName_label_over_63_is_rejected()
- {
- var act = () => DnsMessage.EncodeName(new string('a', 64));
- act.Should().Throw();
- }
-
- [TestMethod]
- public void BuildQuery_writes_expected_header()
- {
- var pkt = DnsMessage.BuildQuery(0xABCD, "example.com", DnsMessage.QType.A);
- pkt.Length.Should().Be(12 + 13 + 4); // header + name + qtype/qclass
- pkt[0].Should().Be(0xAB);
- pkt[1].Should().Be(0xCD);
- pkt[2].Should().Be(0x01); // RD=1
- pkt[3].Should().Be(0x00);
- // qdcount=1
- pkt[4].Should().Be(0);
- pkt[5].Should().Be(1);
- }
-
- [TestMethod]
- public void DecodeName_roundtrips_simple_name()
- {
- var encoded = DnsMessage.EncodeName("a.b.c");
- // Place at offset 0 of a buffer.
- var (name, next) = DnsMessage.DecodeName(encoded, 0);
- name.Should().Be("a.b.c");
- next.Should().Be(encoded.Length);
- }
-
- [TestMethod]
- public void DecodeName_follows_compression_pointer()
- {
- // Build a packet that contains "example.com" at offset 12, and a
- // pointer at offset 30 referencing offset 12.
- var name = DnsMessage.EncodeName("example.com");
- var pkt = new byte[12 + name.Length + 2];
- Array.Copy(name, 0, pkt, 12, name.Length);
- pkt[12 + name.Length] = 0xC0; // pointer high byte
- pkt[12 + name.Length + 1] = 12; // → offset 12
-
- var (decoded, _) = DnsMessage.DecodeName(pkt, 12 + name.Length);
- decoded.Should().Be("example.com");
- }
-
- [TestMethod]
- public void Parse_response_with_one_A_answer()
- {
- // Build a tiny synthetic response: id, flags (QR=1, RA=1, RCODE=0),
- // QDCOUNT=1, ANCOUNT=1.
- var name = DnsMessage.EncodeName("example.com");
- var pkt = new byte[12 + name.Length + 4 // question
- + name.Length + 10 + 4]; // answer
- // Header
- pkt[0] = 0xAB; pkt[1] = 0xCD;
- pkt[2] = 0x81; // QR=1, RD=1
- pkt[3] = 0x80; // RA=1, RCODE=0
- pkt[5] = 1; // QDCOUNT
- pkt[7] = 1; // ANCOUNT
-
- // Question
- Array.Copy(name, 0, pkt, 12, name.Length);
- var off = 12 + name.Length;
- pkt[off + 1] = (byte)DnsMessage.QType.A;
- pkt[off + 3] = (byte)DnsMessage.QClass.IN;
-
- // Answer (NAME, TYPE=A, CLASS=IN, TTL=300, RDLENGTH=4, RDATA=93.184.216.34)
- var aoff = off + 4;
- Array.Copy(name, 0, pkt, aoff, name.Length);
- var raoff = aoff + name.Length;
- pkt[raoff + 1] = (byte)DnsMessage.QType.A;
- pkt[raoff + 3] = (byte)DnsMessage.QClass.IN;
- pkt[raoff + 4] = 0; pkt[raoff + 5] = 0;
- pkt[raoff + 6] = 0x01; pkt[raoff + 7] = 0x2C; // TTL = 300
- pkt[raoff + 8] = 0; pkt[raoff + 9] = 4;
- pkt[raoff + 10] = 93; pkt[raoff + 11] = 184;
- pkt[raoff + 12] = 216; pkt[raoff + 13] = 34;
-
- var (header, questions, answers) = DnsMessage.Parse(pkt);
- header.Rcode.Should().Be(DnsMessage.RCode.NoError);
- header.AnCount.Should().Be(1);
- questions.Should().ContainSingle()
- .Which.Name.Should().Be("example.com");
- answers.Should().ContainSingle()
- .Which.Should().BeOfType()
- .Which.IPv4.Should().Equal(93, 184, 216, 34);
- ((DnsMessage.AAnswer)answers[0]).Ttl.Should().Be(300u);
- }
-}
diff --git a/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs b/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs
deleted file mode 100644
index 7d050f22..00000000
--- a/tests/Starling.Net.Tests/Dns/DnsResolverTests.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using System.Net;
-using AwesomeAssertions;
-using Starling.Net.Dns;
-namespace Starling.Net.Tests.Dns;
-
-[TestClass]
-public class DnsResolverTests
-{
- [TestMethod]
- public async Task Localhost_short_circuits_to_loopback_addresses()
- {
- var resolver = new DnsResolver(new FailingTransport());
- var ct = CancellationToken.None;
- var r = await resolver.ResolveAsync("localhost", ct);
- r.IsOk.Should().BeTrue();
- r.Value.Addresses.Should().Contain(IPAddress.Loopback);
- }
-
- [TestMethod]
- public async Task Numeric_dotted_quad_passes_through_without_query()
- {
- var resolver = new DnsResolver(new FailingTransport());
- var ct = CancellationToken.None;
- var r = await resolver.ResolveAsync("8.8.8.8", ct);
- r.IsOk.Should().BeTrue();
- r.Value.Addresses.Should().ContainSingle()
- .Which.Should().Be(IPAddress.Parse("8.8.8.8"));
- }
-
- [TestMethod]
- public async Task Empty_hostname_is_an_error()
- {
- var resolver = new DnsResolver(new FailingTransport());
- var ct = CancellationToken.None;
- var r = await resolver.ResolveAsync(" ", ct);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(DnsError.EmptyHostname);
- }
-
- [TestMethod]
- public async Task Query_against_fake_transport_returns_parsed_address()
- {
- // FakeDnsTransport returns a canned response for example.com → 93.184.216.34.
- var transport = new FakeDnsTransport();
- var resolver = new DnsResolver(transport, new DnsCache(), () => 0x1234);
- var ct = CancellationToken.None;
- var r = await resolver.ResolveAsync("example.com", ct);
- r.IsOk.Should().BeTrue();
- r.Value.Addresses.Should().Contain(IPAddress.Parse("93.184.216.34"));
- }
-
- [TestMethod]
- public async Task Cached_result_skips_transport()
- {
- var transport = new FakeDnsTransport();
- var cache = new DnsCache();
- var resolver = new DnsResolver(transport, cache, () => 0x1234);
- var ct = CancellationToken.None;
-
- await resolver.ResolveAsync("example.com", ct);
- var firstCalls = transport.CallCount;
-
- await resolver.ResolveAsync("example.com", ct);
- transport.CallCount.Should().Be(firstCalls,
- because: "cache should serve the second lookup");
- }
-
- [TestMethod]
- public async Task NoRecords_when_response_has_zero_answers()
- {
- // A response with NOERROR but ANCOUNT=0.
- var transport = new EmptyDnsTransport();
- var resolver = new DnsResolver(transport, new DnsCache(), () => 0xABCD);
- var ct = CancellationToken.None;
- var r = await resolver.ResolveAsync("nowhere.example", ct);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(DnsError.NoRecords);
- }
-
- // -----------------------------------------------------------------------
- // Cache unit tests
- // -----------------------------------------------------------------------
-
- [TestMethod]
- public void Cache_returns_cached_within_ttl_and_evicts_after()
- {
- var fakeNow = new MutableClock(DateTimeOffset.UtcNow);
- var cache = new DnsCache(now: () => fakeNow.Now);
-
- var r = new DnsResult("example.com", [IPAddress.Loopback], TimeSpan.FromSeconds(10));
- cache.Put("example.com", r);
-
- cache.TryGet("example.com", out var hit).Should().BeTrue();
- hit.Hostname.Should().Be("example.com");
-
- fakeNow.Now += TimeSpan.FromSeconds(11);
- cache.TryGet("example.com", out _).Should().BeFalse();
- }
-
- [TestMethod]
- public void Cache_evicts_oldest_when_over_capacity()
- {
- var cache = new DnsCache(maxEntries: 2);
- cache.Put("a", new DnsResult("a", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
- cache.Put("b", new DnsResult("b", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
- cache.Put("c", new DnsResult("c", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
-
- cache.Count.Should().Be(2);
- cache.TryGet("a", out _).Should().BeFalse();
- cache.TryGet("b", out _).Should().BeTrue();
- cache.TryGet("c", out _).Should().BeTrue();
- }
-
- [TestMethod]
- public void Cache_recent_access_bumps_LRU_order()
- {
- var cache = new DnsCache(maxEntries: 2);
- cache.Put("a", new DnsResult("a", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
- cache.Put("b", new DnsResult("b", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
- // Touching 'a' makes 'b' the LRU candidate for eviction.
- cache.TryGet("a", out _);
- cache.Put("c", new DnsResult("c", [IPAddress.Loopback], TimeSpan.FromSeconds(60)));
-
- cache.TryGet("a", out _).Should().BeTrue();
- cache.TryGet("b", out _).Should().BeFalse();
- cache.TryGet("c", out _).Should().BeTrue();
- }
-
- // -----------------------------------------------------------------------
- // Test doubles
- // -----------------------------------------------------------------------
-
- private sealed class FailingTransport : IDnsTransport
- {
- public Task SendAsync(byte[] queryPacket, CancellationToken ct)
- => throw new InvalidOperationException("test transport must not be called");
- }
-
- private sealed class EmptyDnsTransport : IDnsTransport
- {
- public Task SendAsync(byte[] queryPacket, CancellationToken ct)
- {
- // Echo the question section with QR=1, ANCOUNT=0.
- var resp = new byte[queryPacket.Length];
- Array.Copy(queryPacket, resp, queryPacket.Length);
- resp[2] = 0x81; // QR=1, RD=1
- resp[3] = 0x80; // RA=1
- // Leave ANCOUNT at 0.
- return Task.FromResult(resp);
- }
- }
-
- private sealed class FakeDnsTransport : IDnsTransport
- {
- public int CallCount { get; private set; }
-
- public Task SendAsync(byte[] queryPacket, CancellationToken ct)
- {
- CallCount++;
-
- // Parse the question to discover the qtype + name.
- var (h, qs, _) = DnsMessage.Parse(queryPacket);
- if (qs.Count == 0)
- {
- throw new InvalidOperationException();
- }
-
- var q = qs[0];
-
- // Build a response only for the A query; for AAAA return NOERROR
- // with zero answers (simulates v4-only host).
- if (q.Type != DnsMessage.QType.A)
- {
- var empty = new byte[queryPacket.Length];
- Array.Copy(queryPacket, empty, queryPacket.Length);
- empty[2] = 0x81; empty[3] = 0x80;
- empty[7] = 0;
- return Task.FromResult(empty);
- }
-
- // Manually assemble: header(12) + question + answer.
- var name = DnsMessage.EncodeName(q.Name);
- var resp = new byte[12 + name.Length + 4 + name.Length + 10 + 4];
- // Header
- resp[0] = queryPacket[0]; resp[1] = queryPacket[1];
- resp[2] = 0x81; resp[3] = 0x80;
- resp[5] = 1;
- resp[7] = 1;
- // Question
- Array.Copy(name, 0, resp, 12, name.Length);
- var qoff = 12 + name.Length;
- resp[qoff + 1] = (byte)DnsMessage.QType.A;
- resp[qoff + 3] = (byte)DnsMessage.QClass.IN;
- // Answer
- var aoff = qoff + 4;
- Array.Copy(name, 0, resp, aoff, name.Length);
- var roff = aoff + name.Length;
- resp[roff + 1] = (byte)DnsMessage.QType.A;
- resp[roff + 3] = (byte)DnsMessage.QClass.IN;
- resp[roff + 6] = 0x01; resp[roff + 7] = 0x2C; // TTL = 300
- resp[roff + 9] = 4;
- resp[roff + 10] = 93; resp[roff + 11] = 184;
- resp[roff + 12] = 216; resp[roff + 13] = 34;
- return Task.FromResult(resp);
- }
- }
-
- private sealed class MutableClock
- {
- public DateTimeOffset Now;
- public MutableClock(DateTimeOffset start) { Now = start; }
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs b/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs
deleted file mode 100644
index f39d0fb2..00000000
--- a/tests/Starling.Net.Tests/Http/ChunkedReaderTests.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using System.IO.Compression;
-using System.Text;
-using AwesomeAssertions;
-using Starling.Net.Http.Decoding;
-namespace Starling.Net.Tests.Http;
-
-[TestClass]
-public class ChunkedReaderTests
-{
- private static InboundBuffer FromString(string data) =>
- new(new MemoryStream(Encoding.ASCII.GetBytes(data)));
-
- [TestMethod]
- public async Task Reads_single_chunk()
- {
- var src = FromString("5\r\nhello\r\n0\r\n\r\n");
- var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- Encoding.ASCII.GetString(bytes).Should().Be("hello");
- }
-
- [TestMethod]
- public async Task Reads_multiple_chunks_in_order()
- {
- var src = FromString("5\r\nhello\r\n6\r\n world\r\n1\r\n!\r\n0\r\n\r\n");
- var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- Encoding.ASCII.GetString(bytes).Should().Be("hello world!");
- }
-
- [TestMethod]
- public async Task Handles_chunk_extensions()
- {
- var src = FromString("5;name=value\r\nhello\r\n0;final=1\r\n\r\n");
- var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- Encoding.ASCII.GetString(bytes).Should().Be("hello");
- }
-
- [TestMethod]
- public async Task Skips_trailers()
- {
- var src = FromString("5\r\nhello\r\n0\r\nX-Trailer-One: value\r\nX-Trailer-Two: more\r\n\r\n");
- var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- Encoding.ASCII.GetString(bytes).Should().Be("hello");
- }
-
- [TestMethod]
- public async Task Accepts_uppercase_hex_chunk_size()
- {
- var src = FromString("FF\r\n" + new string('x', 0xFF) + "\r\n0\r\n\r\n");
- var bytes = await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- bytes.Length.Should().Be(0xFF);
- }
-
- [TestMethod]
- public async Task Rejects_truncated_stream()
- {
- var src = FromString("5\r\nhel"); // not enough data
- var act = async () => await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- await act.Should().ThrowAsync();
- }
-
- [TestMethod]
- public async Task Rejects_missing_crlf_after_chunk_data()
- {
- var src = FromString("5\r\nhellobad");
- var act = async () => await ChunkedReader.ReadAllAsync(src, 1024, CancellationToken.None);
- await act.Should().ThrowAsync();
- }
-
- [TestMethod]
- public async Task Enforces_body_size_cap()
- {
- var src = FromString("a\r\n0123456789\r\n0\r\n\r\n");
- var act = async () => await ChunkedReader.ReadAllAsync(src, 5, CancellationToken.None);
- await act.Should().ThrowAsync().WithMessage("*exceeded cap*");
- }
-
- [TestMethod]
- public void ParseChunkSize_handles_extensions()
- {
- var line = Encoding.ASCII.GetBytes("a;ext=1");
- ChunkedReader.ParseChunkSize(line).Should().Be(10);
- }
-
- [TestMethod]
- public void ParseChunkSize_rejects_empty()
- {
- var act = () => ChunkedReader.ParseChunkSize(Array.Empty());
- act.Should().Throw();
- }
-
- [TestMethod]
- public void ParseChunkSize_rejects_non_hex()
- {
- var act = () => ChunkedReader.ParseChunkSize(Encoding.ASCII.GetBytes("xyz"));
- act.Should().Throw();
- }
-}
-
-[TestClass]
-public class BodyDecoderTests
-{
- [TestMethod]
- public void Identity_passes_through()
- {
- var input = Encoding.UTF8.GetBytes("hello");
- BodyDecoder.Decode(input, Array.Empty())
- .Should().Equal(input);
- }
-
- [TestMethod]
- public void Decodes_gzip()
- {
- var payload = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog");
- using var ms = new MemoryStream();
- using (var gz = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- gz.Write(payload);
- }
-
- BodyDecoder.Decode(ms.ToArray(), new[] { "gzip" })
- .Should().Equal(payload);
- }
-
- [TestMethod]
- public void Decodes_brotli()
- {
- var payload = Encoding.UTF8.GetBytes("brotli compressed text payload, repeated. " + new string('a', 200));
- using var ms = new MemoryStream();
- using (var br = new BrotliStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- br.Write(payload);
- }
-
- BodyDecoder.Decode(ms.ToArray(), new[] { "br" })
- .Should().Equal(payload);
- }
-
- [TestMethod]
- public void Decodes_deflate_with_zlib_wrapping()
- {
- var payload = Encoding.UTF8.GetBytes("zlib wrapped deflate payload");
- using var ms = new MemoryStream();
- using (var z = new ZLibStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- z.Write(payload);
- }
-
- BodyDecoder.Decode(ms.ToArray(), new[] { "deflate" })
- .Should().Equal(payload);
- }
-
- [TestMethod]
- public void Decodes_raw_deflate_when_no_zlib_wrapping()
- {
- var payload = Encoding.UTF8.GetBytes("raw deflate payload, no header");
- using var ms = new MemoryStream();
- using (var d = new DeflateStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- d.Write(payload);
- }
-
- BodyDecoder.Decode(ms.ToArray(), new[] { "deflate" })
- .Should().Equal(payload);
- }
-
- [TestMethod]
- public void Decodes_stacked_encodings_in_reverse_order()
- {
- // Server applied gzip first, then brotli. To recover identity we must
- // peel brotli first, then gzip — i.e. iterate the list in reverse.
- var payload = Encoding.UTF8.GetBytes("stacked encoding payload");
- byte[] gz, brOverGz;
- using (var ms = new MemoryStream())
- {
- using (var z = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- z.Write(payload);
- }
-
- gz = ms.ToArray();
- }
- using (var ms = new MemoryStream())
- {
- using (var b = new BrotliStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- b.Write(gz);
- }
-
- brOverGz = ms.ToArray();
- }
-
- BodyDecoder.Decode(brOverGz, new[] { "gzip", "br" })
- .Should().Equal(payload);
- }
-
- [TestMethod]
- [DataRow("gzip, br", new[] { "gzip", "br" })]
- [DataRow(" gzip , identity, br ", new[] { "gzip", "br" })]
- [DataRow("identity", new string[0])]
- [DataRow("", new string[0])]
- [DataRow(null, new string[0])]
- public void ParseEncodings_handles_common_inputs(string? header, string[] expected)
- {
- BodyDecoder.ParseEncodings(header).Should().Equal(expected);
- }
-
- [TestMethod]
- public void Rejects_unknown_encoding()
- {
- var act = () => BodyDecoder.Decode(new byte[] { 1, 2, 3 }, new[] { "compress" });
- act.Should().Throw();
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs b/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs
deleted file mode 100644
index 18252a8c..00000000
--- a/tests/Starling.Net.Tests/Http/ConnectionPoolIntegrationTests.cs
+++ /dev/null
@@ -1,353 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-using AwesomeAssertions;
-using Starling.Net.Http;
-namespace Starling.Net.Tests.Http;
-
-///
-/// End-to-end tests that exercise 's use of the
-/// connection pool. Drives a real loopback HTTP/1.1 server that holds the TCP
-/// connection open for keep-alive responses, then asserts the second request
-/// reused the same socket (no new accept) or didn't (new accept).
-///
-[TestClass]
-public class ConnectionPoolIntegrationTests
-{
- [TestMethod]
- public async Task Second_same_origin_request_reuses_pooled_connection()
- {
- using var server = await KeepAliveStubServer.StartAsync(req =>
- ResponseBuilder.KeepAlive("hi", "text/plain"));
-
- using var client = new StarlingHttpClient();
- var url = $"http://localhost:{server.Port}/";
-
- var r1 = await client.GetAsync(url, CancellationToken.None);
- var r2 = await client.GetAsync(url, CancellationToken.None);
-
- r1.IsOk.Should().BeTrue();
- r2.IsOk.Should().BeTrue();
-
- // Same TCP connection serviced both requests: the server should only
- // have accepted once.
- server.AcceptCount.Should().Be(1, "the second request must reuse the pooled connection");
- server.RequestCount.Should().Be(2);
-
- // After the second request completes and is released, the pool should
- // still hold one idle entry for this origin.
- var origin = OriginKey.Create("http", "localhost", server.Port);
- client.ConnectionPool.IdleCountFor(origin).Should().Be(1);
- }
-
- [TestMethod]
- public async Task Different_origin_does_not_reuse_pooled_connection()
- {
- using var serverA = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.KeepAlive("a", "text/plain"));
- using var serverB = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.KeepAlive("b", "text/plain"));
-
- using var client = new StarlingHttpClient();
-
- var r1 = await client.GetAsync(
- $"http://localhost:{serverA.Port}/", CancellationToken.None);
- var r2 = await client.GetAsync(
- $"http://localhost:{serverB.Port}/", CancellationToken.None);
-
- r1.IsOk.Should().BeTrue();
- r2.IsOk.Should().BeTrue();
-
- serverA.AcceptCount.Should().Be(1);
- serverB.AcceptCount.Should().Be(1, "different origin must not pull from the other origin's pool");
-
- var originA = OriginKey.Create("http", "localhost", serverA.Port);
- var originB = OriginKey.Create("http", "localhost", serverB.Port);
- client.ConnectionPool.IdleCountFor(originA).Should().Be(1);
- client.ConnectionPool.IdleCountFor(originB).Should().Be(1);
- }
-
- [TestMethod]
- public async Task Connection_close_response_is_not_pooled()
- {
- using var server = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.Close("bye", "text/plain"));
-
- using var client = new StarlingHttpClient();
- var origin = OriginKey.Create("http", "localhost", server.Port);
-
- var r1 = await client.GetAsync(
- $"http://localhost:{server.Port}/", CancellationToken.None);
- r1.IsOk.Should().BeTrue();
-
- client.ConnectionPool.IdleCountFor(origin).Should().Be(0,
- "Connection: close responses are never returned to the pool");
-
- var r2 = await client.GetAsync(
- $"http://localhost:{server.Port}/", CancellationToken.None);
- r2.IsOk.Should().BeTrue();
-
- server.AcceptCount.Should().Be(2, "the second request must open a new connection");
- }
-
- [TestMethod]
- public async Task Idle_timeout_evicts_pooled_connection_on_next_acquire()
- {
- using var server = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.KeepAlive("ok", "text/plain"));
-
- // Tiny idle timeout so we can age the entry out without waiting.
- var pool = new ConnectionPool(maxPerOrigin: 6, idleTimeout: TimeSpan.FromMilliseconds(50));
- using var client = new StarlingHttpClient(
- new StarlingHttpClientOptions { ConnectionPool = pool });
- var origin = OriginKey.Create("http", "localhost", server.Port);
-
- var r1 = await client.GetAsync(
- $"http://localhost:{server.Port}/", CancellationToken.None);
- r1.IsOk.Should().BeTrue();
- pool.IdleCountFor(origin).Should().Be(1);
-
- // Age the idle entry past the timeout, then trigger drain. After that
- // a subsequent request must open a fresh socket.
- await Task.Delay(150, CancellationToken.None);
- var drained = await pool.DrainExpiredAsync();
- drained.Should().Be(1, "the idle entry exceeded the configured idle timeout");
- pool.IdleCountFor(origin).Should().Be(0);
-
- var r2 = await client.GetAsync(
- $"http://localhost:{server.Port}/", CancellationToken.None);
- r2.IsOk.Should().BeTrue();
- server.AcceptCount.Should().Be(2, "expired connection must not be reused");
- }
-
- [TestMethod]
- public async Task Disposing_client_disposes_pooled_connections()
- {
- var server = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.KeepAlive("ok", "text/plain"));
-
- var client = new StarlingHttpClient();
- var origin = OriginKey.Create("http", "localhost", server.Port);
-
- try
- {
- var r1 = await client.GetAsync(
- $"http://localhost:{server.Port}/", CancellationToken.None);
- r1.IsOk.Should().BeTrue();
- client.ConnectionPool.IdleCountFor(origin).Should().Be(1);
- }
- finally
- {
- client.Dispose();
- server.Dispose();
- }
-
- // After disposal the pool's idle queues should be empty.
- client.ConnectionPool.IdleCount.Should().Be(0);
- }
-
- [TestMethod]
- public async Task Concurrent_same_origin_requests_open_separate_connections_then_pool_both()
- {
- // Two requests fired off in parallel against an empty pool must each
- // open their own socket (no serialization). When both complete and the
- // server kept them alive, both should land in the pool.
- using var server = await KeepAliveStubServer.StartAsync(_ =>
- ResponseBuilder.KeepAlive("ok", "text/plain"));
-
- using var client = new StarlingHttpClient();
- var url = $"http://localhost:{server.Port}/";
-
- var t1 = client.GetAsync(url, CancellationToken.None);
- var t2 = client.GetAsync(url, CancellationToken.None);
-
- var results = await Task.WhenAll(t1, t2);
- results[0].IsOk.Should().BeTrue();
- results[1].IsOk.Should().BeTrue();
-
- server.AcceptCount.Should().Be(2,
- "parallel requests on an empty pool must open separate connections");
-
- var origin = OriginKey.Create("http", "localhost", server.Port);
- client.ConnectionPool.IdleCountFor(origin).Should().Be(2,
- "both kept-alive connections return to the pool when finished");
- }
-
- private static class ResponseBuilder
- {
- public static byte[] KeepAlive(string body, string contentType)
- {
- var bytes = Encoding.UTF8.GetBytes(body);
- var head = Encoding.ASCII.GetBytes(
- "HTTP/1.1 200 OK\r\n" +
- $"Content-Type: {contentType}\r\n" +
- $"Content-Length: {bytes.Length}\r\n" +
- "Connection: keep-alive\r\n\r\n");
- return Concat(head, bytes);
- }
-
- public static byte[] Close(string body, string contentType)
- {
- var bytes = Encoding.UTF8.GetBytes(body);
- var head = Encoding.ASCII.GetBytes(
- "HTTP/1.1 200 OK\r\n" +
- $"Content-Type: {contentType}\r\n" +
- $"Content-Length: {bytes.Length}\r\n" +
- "Connection: close\r\n\r\n");
- return Concat(head, bytes);
- }
-
- private static byte[] Concat(byte[] a, byte[] b)
- {
- var c = new byte[a.Length + b.Length];
- Buffer.BlockCopy(a, 0, c, 0, a.Length);
- Buffer.BlockCopy(b, 0, c, a.Length, b.Length);
- return c;
- }
- }
-}
-
-///
-/// Multi-request HTTP/1.1 stub. Unlike this one
-/// keeps each accepted socket open after writing a response so it can service
-/// further requests over the same TCP connection (true HTTP/1.1 keep-alive).
-/// A connection terminates when the handler returns a response that asks for
-/// Connection: close, the peer closes, or the server is disposed.
-///
-internal sealed class KeepAliveStubServer : IDisposable
-{
- private readonly TcpListener _listener;
- private readonly CancellationTokenSource _cts = new();
- private readonly Task _accept;
- private readonly Func _handler;
- private int _accepts;
- private int _requests;
-
- public int Port { get; }
- public int AcceptCount => Volatile.Read(ref _accepts);
- public int RequestCount => Volatile.Read(ref _requests);
-
- private KeepAliveStubServer(TcpListener listener, Func handler)
- {
- _listener = listener;
- _handler = handler;
- Port = ((IPEndPoint)listener.LocalEndpoint).Port;
- _accept = Task.Run(AcceptLoop);
- }
-
- public static Task StartAsync(Func handler)
- {
- var listener = new TcpListener(IPAddress.Loopback, 0);
- listener.Start();
- return Task.FromResult(new KeepAliveStubServer(listener, handler));
- }
-
- private async Task AcceptLoop()
- {
- try
- {
- while (!_cts.IsCancellationRequested)
- {
- var client = await _listener.AcceptTcpClientAsync(_cts.Token);
- Interlocked.Increment(ref _accepts);
- _ = Task.Run(() => ServeAsync(client));
- }
- }
- catch (OperationCanceledException) { }
- catch (ObjectDisposedException) { }
- catch (SocketException) { }
- }
-
- private async Task ServeAsync(TcpClient client)
- {
- using (client)
- {
- using var stream = client.GetStream();
- var buffer = new byte[8192];
-
- try
- {
- while (!_cts.IsCancellationRequested)
- {
- var pos = 0;
- while (pos < buffer.Length)
- {
- var n = await stream.ReadAsync(buffer.AsMemory(pos), _cts.Token);
- if (n == 0)
- {
- // Peer closed between requests — normal end of life.
- return;
- }
- pos += n;
- if (ContainsCrLfCrLf(buffer.AsSpan(0, pos)))
- {
- break;
- }
- }
- if (pos == 0)
- {
- return;
- }
-
- var req = Encoding.ASCII.GetString(buffer, 0, pos);
- Interlocked.Increment(ref _requests);
-
- var response = _handler(req);
- await stream.WriteAsync(response, _cts.Token);
- await stream.FlushAsync(_cts.Token);
-
- if (ResponseClosesConnection(response))
- {
- return; // honor server-side close
- }
- }
- }
- catch (OperationCanceledException) { }
- catch (IOException) { }
- catch (ObjectDisposedException) { }
- }
- }
-
- private static bool ContainsCrLfCrLf(ReadOnlySpan data)
- {
- for (var i = 0; i + 3 < data.Length; i++)
- {
- if (data[i] == 0x0D && data[i + 1] == 0x0A &&
- data[i + 2] == 0x0D && data[i + 3] == 0x0A)
- {
- return true;
- }
- }
- return false;
- }
-
- private static bool ResponseClosesConnection(byte[] response)
- {
- var text = Encoding.ASCII.GetString(response);
- var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
- if (headEnd < 0)
- {
- return true;
- }
-
- var head = text[..headEnd];
- foreach (var line in head.Split("\r\n"))
- {
- if (!line.StartsWith("Connection:", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- return line.Contains("close", StringComparison.OrdinalIgnoreCase);
- }
- return false;
- }
-
- public void Dispose()
- {
- _cts.Cancel();
- _listener.Stop();
- try { _accept.Wait(TimeSpan.FromSeconds(2)); } catch { }
- _cts.Dispose();
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs b/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs
deleted file mode 100644
index 34cc5658..00000000
--- a/tests/Starling.Net.Tests/Http/ConnectionPoolTests.cs
+++ /dev/null
@@ -1,212 +0,0 @@
-using AwesomeAssertions;
-using Starling.Net.Http;
-namespace Starling.Net.Tests.Http;
-
-[TestClass]
-public class ConnectionPoolTests
-{
- private static OriginKey Origin(string host = "example.test", int port = 443)
- => OriginKey.Create("https", host, port);
-
- [TestMethod]
- public async Task TryAcquire_returns_the_same_transport_after_release()
- {
- var pool = new ConnectionPool();
- var origin = Origin();
- var fake = new FakeTransport(origin);
-
- await pool.ReleaseAsync(fake);
- var acquired = pool.TryAcquire(origin);
-
- acquired.Should().BeSameAs(fake);
- pool.IdleCount.Should().Be(0);
- fake.Disposed.Should().BeFalse("the transport was acquired by the caller, not discarded");
- }
-
- [TestMethod]
- public async Task TryAcquire_returns_null_when_pool_is_empty()
- {
- var pool = new ConnectionPool();
- pool.TryAcquire(Origin()).Should().BeNull();
- await pool.DisposeAsync();
- }
-
- [TestMethod]
- public async Task TryAcquire_keys_on_origin_distinguishes_scheme_host_port()
- {
- var pool = new ConnectionPool();
- var http = OriginKey.Create("http", "example.test", 80);
- var https = OriginKey.Create("https", "example.test", 443);
- var altPort = OriginKey.Create("https", "example.test", 8443);
- var altHost = OriginKey.Create("https", "other.test", 443);
-
- var t1 = new FakeTransport(http);
- var t2 = new FakeTransport(https);
- var t3 = new FakeTransport(altPort);
- var t4 = new FakeTransport(altHost);
- await pool.ReleaseAsync(t1);
- await pool.ReleaseAsync(t2);
- await pool.ReleaseAsync(t3);
- await pool.ReleaseAsync(t4);
-
- pool.TryAcquire(http).Should().BeSameAs(t1);
- pool.TryAcquire(https).Should().BeSameAs(t2);
- pool.TryAcquire(altPort).Should().BeSameAs(t3);
- pool.TryAcquire(altHost).Should().BeSameAs(t4);
-
- await pool.DisposeAsync();
- }
-
- [TestMethod]
- public async Task Disposed_transport_is_not_returned_from_acquire()
- {
- var pool = new ConnectionPool();
- var origin = Origin();
- var fake = new FakeTransport(origin);
- await pool.ReleaseAsync(fake);
-
- fake.SimulatePeerClose();
- pool.TryAcquire(origin).Should().BeNull("a stale entry must be dropped silently");
- pool.IdleCount.Should().Be(0);
- fake.Disposed.Should().BeTrue("stale entries are discarded when encountered");
- }
-
- [TestMethod]
- public async Task Releasing_a_closed_transport_does_not_pool_it()
- {
- var pool = new ConnectionPool();
- var origin = Origin();
- var fake = new FakeTransport(origin);
- fake.SimulatePeerClose();
-
- await pool.ReleaseAsync(fake);
-
- pool.IdleCount.Should().Be(0);
- fake.Disposed.Should().BeTrue();
- }
-
- [TestMethod]
- public async Task DrainExpired_disposes_entries_older_than_idle_timeout()
- {
- var pool = new ConnectionPool(maxPerOrigin: 6, idleTimeout: TimeSpan.FromMilliseconds(50));
- var origin = Origin();
- var stale = new FakeTransport(origin);
- var fresh = new FakeTransport(origin);
-
- await pool.ReleaseAsync(stale);
- // Make sure the second release is later than the first.
- await Task.Delay(100, CancellationToken.None);
- await pool.ReleaseAsync(fresh);
-
- // Pretend "now" is 75ms after the stale entry but before fresh's expiry.
- var now = DateTimeOffset.UtcNow;
- var drained = await pool.DrainExpiredAsync(now);
-
- drained.Should().Be(1);
- stale.Disposed.Should().BeTrue("stale entry expired and was disposed");
- fresh.Disposed.Should().BeFalse("fresh entry is still within the idle window");
- pool.IdleCount.Should().Be(1);
- }
-
- [TestMethod]
- public async Task Pool_capacity_is_bounded_and_oldest_is_evicted()
- {
- var pool = new ConnectionPool(maxPerOrigin: 2, idleTimeout: TimeSpan.FromMinutes(5));
- var origin = Origin();
- var t1 = new FakeTransport(origin) { Tag = "first" };
- var t2 = new FakeTransport(origin) { Tag = "second" };
- var t3 = new FakeTransport(origin) { Tag = "third" };
-
- await pool.ReleaseAsync(t1);
- await pool.ReleaseAsync(t2);
- await pool.ReleaseAsync(t3); // should evict t1 (oldest)
-
- pool.IdleCount.Should().Be(2);
- t1.Disposed.Should().BeTrue("oldest entry must be LRU-evicted to make room");
- t2.Disposed.Should().BeFalse();
- t3.Disposed.Should().BeFalse();
-
- // MRU acquisition order: t3, then t2.
- pool.TryAcquire(origin).Should().BeSameAs(t3);
- pool.TryAcquire(origin).Should().BeSameAs(t2);
- pool.TryAcquire(origin).Should().BeNull();
- }
-
- [TestMethod]
- public async Task DisposeAll_disposes_every_entry_across_origins()
- {
- var pool = new ConnectionPool();
- var a = new FakeTransport(Origin("a.test"));
- var b = new FakeTransport(Origin("b.test"));
- var c = new FakeTransport(Origin("c.test"));
- await pool.ReleaseAsync(a);
- await pool.ReleaseAsync(b);
- await pool.ReleaseAsync(c);
-
- await pool.DisposeAllAsync();
-
- pool.IdleCount.Should().Be(0);
- a.Disposed.Should().BeTrue();
- b.Disposed.Should().BeTrue();
- c.Disposed.Should().BeTrue();
- }
-
- [TestMethod]
- public async Task Release_on_disposed_pool_closes_the_transport()
- {
- var pool = new ConnectionPool();
- await pool.DisposeAsync();
- var fake = new FakeTransport(Origin());
-
- await pool.ReleaseAsync(fake);
-
- pool.IdleCount.Should().Be(0);
- fake.Disposed.Should().BeTrue("disposed pools refuse new releases and clean up the transport");
- }
-
- [TestMethod]
- public void Constructor_rejects_zero_capacity_or_non_positive_timeout()
- {
- Action zero = () => new ConnectionPool(0, TimeSpan.FromSeconds(1));
- Action negative = () => new ConnectionPool(2, TimeSpan.Zero);
- zero.Should().Throw();
- negative.Should().Throw();
- }
-
- [TestMethod]
- public void OriginKey_normalises_scheme_and_host_to_lowercase()
- {
- var a = OriginKey.Create("HTTPS", "Example.COM", 443);
- var b = OriginKey.Create("https", "example.com", 443);
- a.Should().Be(b);
- }
-
- ///
- /// In-memory stand-in used by the pool tests.
- /// Exposes a flag so assertions can verify the pool disposed (or didn't
- /// dispose) the entry, plus a way to simulate the peer closing the
- /// connection while the transport was idle.
- ///
- private sealed class FakeTransport : IHttpTransport
- {
- public OriginKey Origin { get; }
- public Stream Stream { get; } = new MemoryStream();
- public string? Alpn => null;
- public Starling.Net.Tls.CertificateSummary? PeerCertificate => null;
- public bool Disposed { get; private set; }
- public bool Open { get; private set; } = true;
- public string Tag { get; init; } = "";
-
- public FakeTransport(OriginKey origin) => Origin = origin;
-
- public bool IsOpen => Open && !Disposed;
-
- public void SimulatePeerClose() => Open = false;
-
- public ValueTask DisposeAsync()
- {
- Disposed = true;
- return ValueTask.CompletedTask;
- }
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs b/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs
deleted file mode 100644
index 69665702..00000000
--- a/tests/Starling.Net.Tests/Http/H1RequestWriterTests.cs
+++ /dev/null
@@ -1,183 +0,0 @@
-using System.Text;
-using AwesomeAssertions;
-using Starling.Net.Http;
-using Starling.Net.Http.H1;
-using StarlingUrl = global::Starling.Url.Url;
-using StarlingUrlParser = global::Starling.Url.UrlParser;
-
-namespace Starling.Net.Tests.Http;
-
-[TestClass]
-public class H1RequestWriterTests
-{
- private static StarlingUrl ParseUrl(string s)
- {
- var r = StarlingUrlParser.Parse(s);
- r.IsOk.Should().BeTrue($"failed to parse {s}");
- return r.Value;
- }
-
- [TestMethod]
- public void Writes_request_line_with_origin_form_path()
- {
- var req = HttpRequest.Get(ParseUrl("https://example.com/foo/bar?x=1"));
- var writer = new H1RequestWriter();
-
- var bytes = writer.SerializeHead(req);
- var text = Encoding.ASCII.GetString(bytes);
-
- text.Should().StartWith("GET /foo/bar?x=1 HTTP/1.1\r\n");
- }
-
- [TestMethod]
- public void Defaults_path_to_slash_when_url_path_is_empty()
- {
- var req = HttpRequest.Get(ParseUrl("https://example.com"));
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
- text.Should().StartWith("GET / HTTP/1.1\r\n");
- }
-
- [TestMethod]
- public void Adds_default_headers_when_missing()
- {
- var req = HttpRequest.Get(ParseUrl("https://example.com/"));
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().Contain("Host: example.com\r\n");
- text.Should().Contain("User-Agent: Starling/0.1");
- text.Should().Contain("Accept: text/html");
- text.Should().Contain("Accept-Encoding: gzip, br, deflate\r\n");
- text.Should().Contain("Connection: keep-alive\r\n");
- text.Should().EndWith("\r\n\r\n");
- }
-
- [TestMethod]
- public void Includes_explicit_port_in_host_header_when_not_default()
- {
- var req = HttpRequest.Get(ParseUrl("http://example.com:8080/"));
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
- text.Should().Contain("Host: example.com:8080\r\n");
- }
-
- [TestMethod]
- public void Omits_default_port_in_host_header()
- {
- var req = HttpRequest.Get(ParseUrl("https://example.com:443/"));
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
- text.Should().Contain("Host: example.com\r\n");
- text.Should().NotContain(":443");
- }
-
- [TestMethod]
- public void User_provided_headers_override_defaults()
- {
- var headers = new HttpHeaders();
- headers.Add("Accept-Encoding", "identity");
- headers.Add("User-Agent", "Custom/1.0");
-
- var req = HttpRequest.Get(ParseUrl("https://example.com/"), headers);
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().Contain("Accept-Encoding: identity\r\n");
- text.Should().Contain("User-Agent: Custom/1.0\r\n");
- text.Should().NotContain("Accept-Encoding: gzip");
- text.Should().NotContain("Starling/0.1");
- }
-
- [TestMethod]
- public void Adds_content_length_when_body_present_and_caller_did_not_specify()
- {
- var url = ParseUrl("https://example.com/post");
- var body = Encoding.UTF8.GetBytes("hello=world");
- var req = new HttpRequest("POST", url, headers: null, body: body);
-
- var writer = new H1RequestWriter();
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().Contain($"Content-Length: {body.Length}\r\n");
- }
-
- [TestMethod]
- public void Adds_content_length_zero_for_empty_body_post()
- {
- // An empty-body POST must still carry Content-Length: 0 — servers reject
- // a bodyless POST without it (411 Length Required). Regression for the
- // McMaster token-authorization POST that 411'd before this fix.
- var url = ParseUrl("https://example.com/tokenauthorization.aspx");
- var req = new HttpRequest("POST", url, headers: null, body: default);
-
- var writer = new H1RequestWriter();
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().Contain("Content-Length: 0\r\n");
- }
-
- [TestMethod]
- public void Omits_content_length_for_empty_body_get()
- {
- // GET/HEAD carry no body, so no Content-Length is emitted for an empty one.
- var req = HttpRequest.Get(ParseUrl("https://example.com/"));
-
- var writer = new H1RequestWriter();
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().NotContain("Content-Length:");
- }
-
- [TestMethod]
- public void Omits_content_length_when_caller_specified_transfer_encoding()
- {
- var url = ParseUrl("https://example.com/post");
- var body = Encoding.UTF8.GetBytes("ignored");
- var headers = new HttpHeaders();
- headers.Add("Transfer-Encoding", "chunked");
- var req = new HttpRequest("POST", url, headers, body);
-
- var writer = new H1RequestWriter();
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
-
- text.Should().NotContain("Content-Length:");
- text.Should().Contain("Transfer-Encoding: chunked\r\n");
- }
-
- [TestMethod]
- public async Task WriteAsync_emits_header_block_then_body()
- {
- var url = ParseUrl("https://example.com/api");
- var body = Encoding.UTF8.GetBytes("{}");
- var headers = new HttpHeaders();
- headers.Add("Content-Type", "application/json");
-
- var req = new HttpRequest("POST", url, headers, body);
-
- using var ms = new MemoryStream();
- await new H1RequestWriter().WriteAsync(req, ms, CancellationToken.None);
-
- var text = Encoding.UTF8.GetString(ms.ToArray());
-
- text.Should().StartWith("POST /api HTTP/1.1\r\n");
- text.Should().Contain("Content-Type: application/json\r\n");
- text.Should().Contain($"Content-Length: {body.Length}\r\n");
- text.Should().EndWith("\r\n\r\n{}");
- }
-
- [TestMethod]
- public void Sends_query_string_when_present()
- {
- var req = HttpRequest.Get(ParseUrl("https://example.com/search?q=hello+world&n=10"));
- var writer = new H1RequestWriter();
-
- var text = Encoding.ASCII.GetString(writer.SerializeHead(req));
- text.Should().StartWith("GET /search?q=hello+world&n=10 HTTP/1.1\r\n");
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs b/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs
deleted file mode 100644
index 36f4b248..00000000
--- a/tests/Starling.Net.Tests/Http/H1ResponseParserTests.cs
+++ /dev/null
@@ -1,296 +0,0 @@
-using System.IO.Compression;
-using System.Text;
-using AwesomeAssertions;
-using Starling.Net.Http;
-using Starling.Net.Http.H1;
-namespace Starling.Net.Tests.Http;
-
-[TestClass]
-public class H1ResponseParserTests
-{
- private static MemoryStream Bytes(string s) => new(Encoding.ASCII.GetBytes(s));
-
- private static async Task Parse(string text, H1ResponseParser? parser = null)
- {
- var r = await (parser ?? new H1ResponseParser()).ParseAsync(Bytes(text), CancellationToken.None);
- r.IsOk.Should().BeTrue($"parser returned {(r.IsOk ? "Ok" : r.Error.ToString())}");
- return r.Value;
- }
-
- [TestMethod]
- public async Task Parses_minimal_response_with_content_length()
- {
- var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello");
- resp.HttpVersion.Should().Be("HTTP/1.1");
- resp.StatusCode.Should().Be(200);
- resp.ReasonPhrase.Should().Be("OK");
- resp.Headers.GetFirst("Content-Length").Should().Be("5");
- Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hello");
- }
-
- [TestMethod]
- public async Task Parses_response_with_empty_body()
- {
- var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
- resp.Body.Length.Should().Be(0);
- }
-
- [TestMethod]
- public async Task Handles_status_204_with_no_body_even_without_content_length()
- {
- var resp = await Parse("HTTP/1.1 204 No Content\r\n\r\n");
- resp.StatusCode.Should().Be(204);
- resp.Body.Length.Should().Be(0);
- }
-
- [TestMethod]
- public async Task Handles_status_304_with_no_body()
- {
- var resp = await Parse("HTTP/1.1 304 Not Modified\r\nETag: \"abc\"\r\n\r\n");
- resp.StatusCode.Should().Be(304);
- resp.Body.Length.Should().Be(0);
- }
-
- [TestMethod]
- public async Task Skips_1xx_informational_responses()
- {
- var raw =
- "HTTP/1.1 100 Continue\r\n\r\n" +
- "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi";
- var resp = await Parse(raw);
- resp.StatusCode.Should().Be(200);
- Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hi");
- }
-
- [TestMethod]
- public async Task Reads_chunked_body()
- {
- var raw =
- "HTTP/1.1 200 OK\r\n" +
- "Transfer-Encoding: chunked\r\n" +
- "\r\n" +
- "5\r\nhello\r\n" +
- "6\r\n world\r\n" +
- "1\r\n!\r\n" +
- "0\r\n\r\n";
- var resp = await Parse(raw);
- Encoding.ASCII.GetString(resp.Body.Span).Should().Be("hello world!");
- }
-
- [TestMethod]
- public async Task Reads_close_delimited_body_when_no_framing_headers()
- {
- var raw = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nclose-delimited body";
- var resp = await Parse(raw);
- Encoding.ASCII.GetString(resp.Body.Span).Should().Be("close-delimited body");
- }
-
- [TestMethod]
- public async Task Decodes_gzip_content_encoding()
- {
- var payload = Encoding.UTF8.GetBytes("compressed text body");
- using var compressedStream = new MemoryStream();
- using (var gz = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true))
- {
- gz.Write(payload);
- }
-
- var compressed = compressedStream.ToArray();
-
- var head = $"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {compressed.Length}\r\n\r\n";
- var headBytes = Encoding.ASCII.GetBytes(head);
-
- var combined = new byte[headBytes.Length + compressed.Length];
- Buffer.BlockCopy(headBytes, 0, combined, 0, headBytes.Length);
- Buffer.BlockCopy(compressed, 0, combined, headBytes.Length, compressed.Length);
-
- var parser = new H1ResponseParser();
- var result = await parser.ParseAsync(new MemoryStream(combined), CancellationToken.None);
- result.IsOk.Should().BeTrue();
- result.Value.Body.ToArray().Should().Equal(payload);
- }
-
- [TestMethod]
- public async Task Decodes_chunked_then_gzip()
- {
- var payload = Encoding.UTF8.GetBytes("the standard chunked + gzip combo most CDNs send");
- using var ms = new MemoryStream();
- using (var gz = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true))
- {
- gz.Write(payload);
- }
-
- var compressed = ms.ToArray();
-
- // Chunk the compressed bytes in two halves.
- var half = compressed.Length / 2;
- var sb = new StringBuilder();
- sb.Append("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Encoding: gzip\r\n\r\n");
-
- var head = Encoding.ASCII.GetBytes(sb.ToString());
- var first = new byte[half];
- Buffer.BlockCopy(compressed, 0, first, 0, half);
- var second = new byte[compressed.Length - half];
- Buffer.BlockCopy(compressed, half, second, 0, second.Length);
-
- using var stream = new MemoryStream();
- stream.Write(head);
- stream.Write(Encoding.ASCII.GetBytes($"{first.Length:x}\r\n"));
- stream.Write(first);
- stream.Write(Encoding.ASCII.GetBytes("\r\n"));
- stream.Write(Encoding.ASCII.GetBytes($"{second.Length:x}\r\n"));
- stream.Write(second);
- stream.Write(Encoding.ASCII.GetBytes("\r\n"));
- stream.Write(Encoding.ASCII.GetBytes("0\r\n\r\n"));
- stream.Position = 0;
-
- var parser = new H1ResponseParser();
- var result = await parser.ParseAsync(stream, CancellationToken.None);
- result.IsOk.Should().BeTrue();
- result.Value.Body.ToArray().Should().Equal(payload);
- }
-
- [TestMethod]
- public async Task Multivalued_set_cookie_headers_are_all_visible()
- {
- var raw =
- "HTTP/1.1 200 OK\r\n" +
- "Set-Cookie: a=1\r\n" +
- "Set-Cookie: b=2\r\n" +
- "Content-Length: 0\r\n\r\n";
- var resp = await Parse(raw);
- resp.Headers.GetAll("Set-Cookie").Should().Equal(new[] { "a=1", "b=2" });
- }
-
- [TestMethod]
- public async Task Bad_status_line_returns_error()
- {
- var parser = new H1ResponseParser();
- var r = await parser.ParseAsync(Bytes("nope nope nope\r\n\r\n"), CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.BadStatusLine);
- }
-
- [TestMethod]
- public async Task Missing_colon_in_header_returns_BadHeader()
- {
- var parser = new H1ResponseParser();
- var r = await parser.ParseAsync(
- Bytes("HTTP/1.1 200 OK\r\nNoColonHere\r\n\r\n"),
- CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.BadHeader);
- }
-
- [TestMethod]
- public async Task Header_block_too_large_returns_HeadersTooLarge()
- {
- var giantHeader = "X-Big: " + new string('a', 70_000) + "\r\n";
- var raw = "HTTP/1.1 200 OK\r\n" + giantHeader + "Content-Length: 0\r\n\r\n";
- var parser = new H1ResponseParser { MaxHeaderBlockBytes = 32 * 1024 };
- var r = await parser.ParseAsync(Bytes(raw), CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.HeadersTooLarge);
- }
-
- [TestMethod]
- public async Task Truncated_body_returns_UnexpectedEof()
- {
- var parser = new H1ResponseParser();
- // Promise 10 bytes, deliver 3.
- var r = await parser.ParseAsync(
- Bytes("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"),
- CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.UnexpectedEof);
- }
-
- [TestMethod]
- public async Task Body_size_cap_returns_BodyTooLarge_for_content_length()
- {
- var parser = new H1ResponseParser { MaxBodyBytes = 4 };
- var r = await parser.ParseAsync(
- Bytes("HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n12345678"),
- CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.BodyTooLarge);
- }
-
- [TestMethod]
- public async Task Unknown_content_encoding_returns_UnsupportedEncoding()
- {
- var parser = new H1ResponseParser();
- var r = await parser.ParseAsync(
- Bytes("HTTP/1.1 200 OK\r\nContent-Encoding: compress\r\nContent-Length: 3\r\n\r\nabc"),
- CancellationToken.None);
- r.IsErr.Should().BeTrue();
- r.Error.Should().Be(HttpError.UnsupportedEncoding);
- }
-
- [TestMethod]
- public async Task Reason_phrase_can_be_empty()
- {
- var resp = await Parse("HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n");
- resp.StatusCode.Should().Be(200);
- resp.ReasonPhrase.Should().Be("");
- }
-
- [TestMethod]
- public async Task IndicatesKeepAlive_defaults_to_true_for_HTTP11_without_Connection_header()
- {
- var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
- H1ResponseParser.IndicatesKeepAlive(resp).Should().BeTrue();
- }
-
- [TestMethod]
- public async Task IndicatesKeepAlive_false_when_HTTP11_response_has_Connection_close()
- {
- var resp = await Parse("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
- H1ResponseParser.IndicatesKeepAlive(resp).Should().BeFalse();
- }
-
- [TestMethod]
- public async Task IndicatesKeepAlive_false_for_HTTP10_without_explicit_keep_alive()
- {
- var resp = await Parse("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n");
- H1ResponseParser.IndicatesKeepAlive(resp).Should().BeFalse();
- }
-
- [TestMethod]
- public async Task IndicatesKeepAlive_true_for_HTTP10_with_explicit_keep_alive()
- {
- var resp = await Parse("HTTP/1.0 200 OK\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n");
- H1ResponseParser.IndicatesKeepAlive(resp).Should().BeTrue();
- }
-
- [TestMethod]
- public async Task HasDefiniteBodyFraming_true_for_Content_Length()
- {
- var resp = await Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello");
- H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue();
- }
-
- [TestMethod]
- public async Task HasDefiniteBodyFraming_true_for_chunked()
- {
- var resp = await Parse(
- "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n");
- H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue();
- }
-
- [TestMethod]
- public async Task HasDefiniteBodyFraming_true_for_204()
- {
- var resp = await Parse("HTTP/1.1 204 No Content\r\n\r\n");
- H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeTrue();
- }
-
- [TestMethod]
- public async Task HasDefiniteBodyFraming_false_for_close_delimited_body()
- {
- // No Content-Length, no Transfer-Encoding, non-204/304 status.
- // The parser reads to EOF; the connection cannot be safely pooled.
- var resp = await Parse("HTTP/1.0 200 OK\r\n\r\nhello");
- H1ResponseParser.HasDefiniteBodyFraming(resp).Should().BeFalse();
- }
-}
diff --git a/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs b/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs
deleted file mode 100644
index 9f23aa79..00000000
--- a/tests/Starling.Net.Tests/Http/H2/H2ConnectionTests.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-using AwesomeAssertions;
-using Starling.Net.Http;
-using Starling.Net.Http.H2;
-using Starling.Net.Http.H2.Hpack;
-using UrlParser = global::Starling.Url.UrlParser;
-
-namespace Starling.Net.Tests.Http.H2;
-
-///
-/// End-to-end