diff --git a/Unhinged.Playground/Program.cs b/Unhinged.Playground/Program.cs
index 3692149..3d80591 100644
--- a/Unhinged.Playground/Program.cs
+++ b/Unhinged.Playground/Program.cs
@@ -8,6 +8,8 @@
#pragma warning disable CA2014
+// dotnet publish -c Release /p:PublishAot=true /p:OptimizationPreference=Speed
+
[SkipLocalsInit]
internal static class Program
{
@@ -15,7 +17,7 @@ public static void Main(string[] args)
{
var builder = UnhingedEngine
.CreateBuilder()
- .SetNWorkersSolver(() => Environment.ProcessorCount / 2)
+ .SetNWorkersSolver(() => (Environment.ProcessorCount / 2) - 2)
.SetBacklog(16384)
.SetMaxEventsPerWake(512)
.SetMaxNumberConnectionsPerWorker(512)
@@ -28,13 +30,25 @@ public static void Main(string[] args)
engine.Run();
}
- private static void RequestHandler(Connection connection)
+ private static ValueTask RequestHandler(Connection connection)
{
- if(connection.HashedRoute == 291830056) // /json
+ /*if(connection.HashedRoute == 291830056) // /json
CommitJsonResponse(connection);
else if (connection.HashedRoute == 3454831873) // /plaintext
+ CommitPlainTextResponse(connection);*/
+
+ if (connection.H1HeaderData.Route.Equals("/json"))
+ {
+ CommitJsonResponse(connection);
+ }
+
+ else if (connection.H1HeaderData.Route.Equals("/plaintext"))
+ {
CommitPlainTextResponse(connection);
+ }
+
+ return ValueTask.CompletedTask;
}
[ThreadStatic] private static Utf8JsonWriter? _tUtf8JsonWriter;
diff --git a/Unhinged/ABI/Native.cs b/Unhinged/ABI/Native.cs
index f990d13..421c517 100644
--- a/Unhinged/ABI/Native.cs
+++ b/Unhinged/ABI/Native.cs
@@ -144,9 +144,12 @@ internal static unsafe class Native
///
[DllImport("libc", SetLastError = true)] internal static extern int eventfd(uint initval, int flags);
- [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, nuint cpusetsize, ref ulong mask);
-
-
+ [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, IntPtr cpusetsize, ref ulong mask);
+
+ [DllImport("libc", SetLastError = true)] internal static extern int sched_setaffinity(int pid, IntPtr cpusetsize, ref cpu_set_t mask);
+
+ [DllImport("libc")] internal static extern int gettid(); // Linux thread id
+
// =========================
// Struct definitions
// =========================
@@ -264,4 +267,28 @@ internal struct Linger
internal const int EPIPE = 32;
internal const int ECONNABORTED = 103;
internal const int ECONNRESET = 104;
+
+ public static void PinCurrentThreadToCpu(int cpuIndex)
+ {
+ if (cpuIndex < 0 || cpuIndex >= Environment.ProcessorCount)
+ throw new ArgumentOutOfRangeException(nameof(cpuIndex));
+
+ unsafe
+ {
+ var set = new cpu_set_t();
+ int word = cpuIndex / 64;
+ int bit = cpuIndex % 64;
+ set.Bits[word] = 1UL << bit;
+
+ int tid = gettid();
+ int ret = sched_setaffinity(tid, (IntPtr)sizeof(cpu_set_t), ref set);
+ if (ret != 0)
+ throw new InvalidOperationException($"sched_setaffinity failed with errno {Marshal.GetLastPInvokeError()}");
+ }
+ }
}
+
+internal unsafe struct cpu_set_t
+{
+ public fixed ulong Bits[16]; // 1024 bits (enough for up to 1024 CPUs)
+}
\ No newline at end of file
diff --git a/Unhinged/Engine/Connection.cs b/Unhinged/Engine/Connection.cs
index a8502d0..0f62317 100644
--- a/Unhinged/Engine/Connection.cs
+++ b/Unhinged/Engine/Connection.cs
@@ -24,8 +24,11 @@ public unsafe class Connection : IDisposable
/// Writer over the send slab.
public readonly FixedBufferWriter WriteBuffer;
- // Fnv1a32 hashed route
- public uint HashedRoute { get; set; }
+ ///
+ /// Header data, no allocations
+ ///
+ public BinaryH1HeaderData BinaryH1HeaderData { get; set; }
+ public H1HeaderData H1HeaderData { get; set; }
/// Used to size the slabs (typically per-worker slab size).
/// Bytes per connection for receive.
@@ -40,6 +43,11 @@ public Connection(int maxConnections, int inSlabSize, int outSlabSize)
outSlabSize);
}
+ public void Clear()
+ {
+ H1HeaderData?.Clear();
+ }
+
///
/// Frees the unmanaged slabs. Call exactly once when the connection is permanently done.
///
diff --git a/Unhinged/Engine/UnhingedEngine.Builder.cs b/Unhinged/Engine/UnhingedEngine.Builder.cs
index a1658c6..e7c1d62 100644
--- a/Unhinged/Engine/UnhingedEngine.Builder.cs
+++ b/Unhinged/Engine/UnhingedEngine.Builder.cs
@@ -42,15 +42,17 @@ public sealed partial class UnhingedEngine
private static Func? _calculateNumberWorkers;
// Default request handler (overridden via builder). Writes a minimal plaintext response.
- private static Action _sRequestHandler = DefaultRequestHandler;
+ private static Func _sRequestHandler = DefaultRequestHandler;
- private static void DefaultRequestHandler(Connection connection)
+ private static ValueTask DefaultRequestHandler(Connection connection)
{
connection.WriteBuffer.WriteUnmanaged("HTTP/1.1 200 OK\r\n"u8 +
"Server: W\r\n"u8 +
"Content-Type: text/plain\r\n"u8 +
"Content-Length: 28\r\n\r\n"u8 +
"Request handler was not set!"u8 );
+
+ return ValueTask.CompletedTask;
}
private UnhingedEngine() { }
@@ -134,7 +136,7 @@ public UnhingedBuilder SetNWorkersSolver(Func? solver)
///
/// Inject the request handler used by workers to serve requests.
///
- public UnhingedBuilder InjectRequestHandler(Action requestHandler)
+ public UnhingedBuilder InjectRequestHandler(Func requestHandler)
{
_sRequestHandler = requestHandler;
return this;
diff --git a/Unhinged/Engine/UnhingedEngine.Runner.cs b/Unhinged/Engine/UnhingedEngine.Runner.cs
index 22eb02b..9d6dc39 100644
--- a/Unhinged/Engine/UnhingedEngine.Runner.cs
+++ b/Unhinged/Engine/UnhingedEngine.Runner.cs
@@ -41,7 +41,14 @@ public void Run()
// - IsBackground=true so the process can exit if only workers remain.
// - Name aids debugging and logs.
// - Stack size is configurable to accommodate stackalloc-heavy hot paths.
- var t = new Thread(() => WorkerLoop(W[iCap]), _maxStackSizePerThread) // 1MB
+ var t = new Thread(() =>
+ {
+ // Performance seems to be lower when pinning threads to cpu core
+ //PinCurrentThreadToCpu(iCap);
+ //Console.WriteLine($"Thread {iCap} pinned to CPU {iCap}");
+
+ WorkerLoop(W[iCap]);
+ }, _maxStackSizePerThread) // 1MB
{
IsBackground = true,
Name = $"worker-{iCap}"
diff --git a/Unhinged/Engine/UnhingedEngine.Worker.cs b/Unhinged/Engine/UnhingedEngine.Worker.cs
index a1661af..855b7d4 100644
--- a/Unhinged/Engine/UnhingedEngine.Worker.cs
+++ b/Unhinged/Engine/UnhingedEngine.Worker.cs
@@ -4,11 +4,13 @@
// ReSharper disable always StackAllocInsideLoop
// ReSharper disable always ClassCannotBeInstantiated
+using System.Text;
+
#pragma warning disable CA2014
namespace Unhinged;
-public sealed unsafe partial class UnhingedEngine
+public sealed partial class UnhingedEngine
{
///
/// Result code for attempts to flush the connection's write buffer.
@@ -35,9 +37,11 @@ private class ConnectionPoolPolicy : PooledObjectPolicy
///
/// Return a Connection to the pool. Consider resetting/clearing per-request state here.
///
- public override bool Return(Connection context)
+ public override bool Return(Connection connection)
{
// Potentially reset buffers here (e.g., context.Reset()) to avoid data leaks across usages.
+ connection.Clear();
+
return true;
}
}
@@ -56,7 +60,7 @@ public override bool Return(Connection context)
/// 4) Handle EPOLLOUT (write-ready) to continue flushing responses.
/// 5) On error/hup, close and recycle the connection.
///
- private static void WorkerLoop(Worker W)
+ private static unsafe void WorkerLoop(Worker W)
{
// Per-worker connection table
var connections = new Dictionary(capacity: _maxNumberConnectionsPerWorker);
@@ -134,8 +138,11 @@ private static void WorkerLoop(Worker W)
if (got > 0)
{
c.Tail += (int)got;
- continue;
+ // TODO: Which one, continue or break? break avoids an extra read to get a EAGAIN
+ // TODO: But continue may read more data without the need of an extra epoll event
+ //continue;
+ break;
}
if (got == 0) { CloseConn(fd, connections, W); break; } // peer closed
@@ -153,7 +160,32 @@ private static void WorkerLoop(Worker W)
CloseConn(fd, connections, W); break; // default: close on unexpected errors
}
- var dataToBeFlushedAvailable = TryParseRequests(c);
+ _ = TryParseRequests(c, fd, W, connections);
+
+ /*if (task is { IsCompleted: true, Result: true })
+ {
+ var tryEmptyResult = TryEmptyWriteBuffer(c, ref fd);
+ if (tryEmptyResult == EmptyAttemptResult.Complete) // Hot path
+ {
+ // All requests were flushed, stay EPOLLIN
+ //ArmEpollIn(ref fd, W.Ep);
+ // Move on to the next event
+ continue;
+ }
+ if (tryEmptyResult == EmptyAttemptResult.Incomplete)
+ {
+ // There is still data to be flushed in the buffer, arm EPOLLOUT
+ ArmEpollOut(ref fd, W.Ep);
+ continue;
+ }
+ if (tryEmptyResult == EmptyAttemptResult.CloseConnection)
+ {
+ CloseConn(fd, connections, W);
+ continue;
+ }
+ }*/
+
+ /*var dataToBeFlushedAvailable = TryParseRequests(c);
if (dataToBeFlushedAvailable)
{
var tryEmptyResult = TryEmptyWriteBuffer(c, ref fd);
@@ -174,7 +206,7 @@ private static void WorkerLoop(Worker W)
CloseConn(fd, connections, W);
continue;
}
- }
+ }*/
// Move on to the next event...
continue;
@@ -216,23 +248,107 @@ private static void WorkerLoop(Worker W)
/// The receive window is compacted when partial data remains.
/// Returns true if any response data was staged and should be flushed.
///
- private static bool TryParseRequests(Connection connection)
+ private static async ValueTask TryParseRequests(
+ Connection connection,
+ int fd,
+ Worker W,
+ Dictionary connections)
{
bool hasDataToFlush = false;
+ int idx = 0;
+
+ while (true)
+ {
+ unsafe
+ {
+ // Try getting a full request header, if unsuccessful signal caller more data is needed
+ var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, ref idx);
+ if (idx < 0)
+ break;
+
+ // A full request was received, handle it
+
+ // Extract the request Header data
+ connection.H1HeaderData = ExtractH1HeaderData(headerSpan);
+
+ // Advance the pointer after the request was dealt with
+ connection.Head = idx + 4; // advance past CRLFCRLF
+ }
+
+ await _sRequestHandler(connection);
+
+ // Clear pooled dictionaries (query parameters + headers)
+ connection.Clear();
+
+ // Mark that there is data to flush (a request was fully processed)
+ hasDataToFlush = true;
+
+ if (connection.Head == connection.Tail) // No more data to read
+ break;
+ }
+
+ unsafe
+ {
+ // If there is unprocessed data in the receiving buffer (incomplete request) which is not at buffer start
+ // Move the incomplete request to the buffer start and reset head and tail to 0
+ if (connection.Head > 0 && connection.Head < connection.Tail)
+ {
+ Buffer.MemoryCopy(
+ connection.ReceiveBuffer + connection.Head,
+ connection.ReceiveBuffer,
+ _inSlabSize,
+ connection.Tail - connection.Head);
+ }
+ }
+
+ //Reset the receiving buffer
+ connection.Head = connection.Tail = 0;
+
+ if (hasDataToFlush)
+ {
+ var tryEmptyResult = TryEmptyWriteBuffer(connection, ref fd);
+ if (tryEmptyResult == EmptyAttemptResult.Complete) // Hot path
+ {
+ // All requests were flushed, stay EPOLLIN
+ return;
+ }
+ if (tryEmptyResult == EmptyAttemptResult.Incomplete)
+ {
+ // There is still data to be flushed in the buffer, arm EPOLLOUT
+ ArmEpollOut(ref fd, W.Ep);
+ return;
+ }
+ if (tryEmptyResult == EmptyAttemptResult.CloseConnection)
+ {
+ CloseConn(fd, connections, W);
+ return;
+ }
+ }
+
+ }
+
+ private static unsafe bool TryParseRequests(Connection connection)
+ {
+ bool hasDataToFlush = false;
+
+ int idx = 0;
+
while (true)
{
// Try getting a full request header, if unsuccessful signal caller more data is needed
//int idx = FindCrlfCrlf(connection.Buf, connection.Head, connection.Tail);
//int idx = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail);
- var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, out int idx);
+ var headerSpan = FindCrlfCrlf(connection.ReceiveBuffer, connection.Head, connection.Tail, ref idx);
if (idx < 0)
break;
-
+
+
// A full request was received, handle it
-
+
// Extract the route
- connection.HashedRoute = ExtractRoute(headerSpan);
+ connection.H1HeaderData = ExtractH1HeaderData(headerSpan);
+
_sRequestHandler(connection);
// Advance the pointer after the request was dealt with
@@ -250,14 +366,15 @@ private static bool TryParseRequests(Connection connection)
if (connection.Head > 0 && connection.Head < connection.Tail)
{
Buffer.MemoryCopy(
- connection.ReceiveBuffer + connection.Head,
- connection.ReceiveBuffer,
- _inSlabSize,
+ connection.ReceiveBuffer + connection.Head,
+ connection.ReceiveBuffer,
+ _inSlabSize,
connection.Tail - connection.Head);
}
-
+
//Reset the receiving buffer
connection.Head = connection.Tail = 0;
+
return hasDataToFlush;
}
@@ -268,7 +385,7 @@ private static bool TryParseRequests(Connection connection)
/// - Incomplete: partial write or EAGAIN; caller should arm EPOLLOUT
/// - CloseConnection: hard error while sending; caller should close the fd
///
- private static EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref int fd)
+ private static unsafe EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref int fd)
{
while (true)
{
@@ -321,7 +438,7 @@ private static EmptyAttemptResult TryEmptyWriteBuffer(Connection connection, ref
/// Arms EPOLLIN on the given fd (while preserving error/hup interest).
/// Use after fully draining the write buffer to resume read-driven operation.
///
- private static void ArmEpollIn(ref int fd, int ep)
+ private static unsafe void ArmEpollIn(ref int fd, int ep)
{
byte* ev = stackalloc byte[EvSize];
WriteEpollEvent(ev, EPOLLIN | EPOLLRDHUP | EPOLLERR | EPOLLHUP, fd);
@@ -332,7 +449,7 @@ private static void ArmEpollIn(ref int fd, int ep)
/// Arms EPOLLOUT on the given fd (while preserving error/hup interest).
/// Use when a partial flush occurs and more write-ready notifications are required.
///
- private static void ArmEpollOut(ref int fd, int ep)
+ private static unsafe void ArmEpollOut(ref int fd, int ep)
{
byte* ev = stackalloc byte[EvSize];
WriteEpollEvent(ev, EPOLLOUT | EPOLLRDHUP | EPOLLERR | EPOLLHUP, fd);
@@ -364,4 +481,23 @@ private static void CloseConn(int fd, Dictionary map, Worker W)
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CloseQuiet(int fd) { try { close(fd); } catch { } }
-}
\ No newline at end of file
+}
+
+// Some stuff that may be useful if I need to always schedule a task on the threadpool
+
+//_ = Task.Run(() => TryParseRequests(c, fd, W, connections));
+/*ThreadPool.UnsafeQueueUserWorkItem(static state =>
+{
+ var (conn, fd, w, dict) =
+ ((Connection,int,Worker,Dictionary))state!;
+
+ var t = TryParseRequests(conn, fd, w, dict).AsTask();
+
+ if (!t.IsCompletedSuccessfully)
+ {
+ _ = t.ContinueWith(
+ tt => Console.WriteLine(tt.Exception!), // your logging
+ TaskContinuationOptions.OnlyOnFaulted |
+ TaskContinuationOptions.ExecuteSynchronously);
+ }
+}, (c, fd, W, connections));*/
\ No newline at end of file
diff --git a/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs b/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs
new file mode 100644
index 0000000..acecc96
--- /dev/null
+++ b/Unhinged/HttpProtocol/H1/BinaryH1HeaderData.cs
@@ -0,0 +1,14 @@
+namespace Unhinged;
+
+public struct BinaryH1HeaderData
+{
+ public PinnedByteSequence HttpMethod;
+
+ public PinnedByteSequence Route;
+
+ public PinnedByteSequence QueryParameters;
+
+ public bool HasQueryParameters => QueryParameters.Length > 0;
+
+ public PinnedByteSequence Headers;
+}
\ No newline at end of file
diff --git a/Unhinged/HttpProtocol/H1/CachedH1Data.cs b/Unhinged/HttpProtocol/H1/CachedH1Data.cs
new file mode 100644
index 0000000..de3edd4
--- /dev/null
+++ b/Unhinged/HttpProtocol/H1/CachedH1Data.cs
@@ -0,0 +1,38 @@
+namespace Unhinged;
+
+internal static class CachedH1Data
+{
+ internal static readonly StringCache CachedRoutes
+ = new(null, 64);
+
+ internal static readonly StringCache CachedQueryKeys
+ = new(null, 64);
+
+ internal static readonly StringCache CachedHttpMethods
+ = new([
+ "GET",
+ "POST",
+ "PUT",
+ "DELETE",
+ "PATCH",
+ "HEAD",
+ "OPTIONS",
+ "TRACE"],
+ 8);
+
+ internal static readonly StringCache CachedHeaderKeys
+ = new([
+ "Host",
+ "User-Agent",
+ "Cookie",
+ "Accept",
+ "Accept-Language",
+ "Connection"],
+ 64);
+
+ internal static readonly StringCache CachedHeaderValues
+ = new([
+ "keep-alive",
+ "server"],
+ 64);
+}
\ No newline at end of file
diff --git a/Unhinged/HttpProtocol/H1/H1HeaderData.cs b/Unhinged/HttpProtocol/H1/H1HeaderData.cs
new file mode 100644
index 0000000..018868f
--- /dev/null
+++ b/Unhinged/HttpProtocol/H1/H1HeaderData.cs
@@ -0,0 +1,26 @@
+namespace Unhinged;
+
+public class H1HeaderData
+{
+ public string Route { get; internal set; } = null!;
+ public string HttpMethod { get; internal set; } = null!;
+ public PooledDictionary QueryParameters { get; }
+ public PooledDictionary Headers { get; }
+
+ public H1HeaderData()
+ {
+ QueryParameters = new PooledDictionary(
+ capacity: 8,
+ comparer: StringComparer.OrdinalIgnoreCase);
+
+ Headers = new PooledDictionary(
+ capacity: 8,
+ comparer: StringComparer.OrdinalIgnoreCase);
+ }
+
+ public void Clear()
+ {
+ Headers?.Clear();
+ QueryParameters?.Clear();
+ }
+}
\ No newline at end of file
diff --git a/Unhinged/HttpProtocol/HeaderParsing.cs b/Unhinged/HttpProtocol/H1/HeaderParsing.cs
similarity index 51%
rename from Unhinged/HttpProtocol/HeaderParsing.cs
rename to Unhinged/HttpProtocol/H1/HeaderParsing.cs
index 30c914f..6972c4c 100644
--- a/Unhinged/HttpProtocol/HeaderParsing.cs
+++ b/Unhinged/HttpProtocol/H1/HeaderParsing.cs
@@ -3,6 +3,9 @@
// (var is avoided intentionally in this project so that concrete types are visible at call sites.)
// ReSharper disable always StackAllocInsideLoop
// ReSharper disable always ClassCannotBeInstantiated
+
+using System.Text;
+
#pragma warning disable CA2014
namespace Unhinged;
@@ -55,7 +58,7 @@ internal static int FindCrlfCrlf(byte* buf, int head, int tail)
///
/// A span over the provided range [head..tail).
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- internal static ReadOnlySpan FindCrlfCrlf(byte* buf, int head, int tail, out int idx)
+ internal static ReadOnlySpan FindCrlfCrlf(byte* buf, int head, int tail, ref int idx)
{
// Construct a Span view over the raw memory.
// The caller must guarantee that (tail - head) bytes are valid and readable.
@@ -107,6 +110,150 @@ internal static uint ExtractRoute(ReadOnlySpan headerSpan)
return Fnv1a32(url);
}
+ internal static BinaryH1HeaderData ExtractBinaryH1HeaderData(ReadOnlySpan headerSpan)
+ {
+ var headerData = new BinaryH1HeaderData();
+
+ var lineEnd = headerSpan.IndexOf(Crlf);
+ var firstHeader = headerSpan[..lineEnd];
+
+ var firstSpace = firstHeader.IndexOf(Space);
+ if (firstSpace == -1)
+ throw new InvalidOperationException("Invalid request line");
+
+ headerData.HttpMethod = new PinnedByteSequence(firstHeader[..firstSpace]);
+
+ var secondSpaceRelative = firstHeader[(firstSpace + 1)..].IndexOf(Space);
+ if (secondSpaceRelative == -1)
+ throw new InvalidOperationException("Invalid request line");
+
+ var secondSpace = firstSpace + secondSpaceRelative + 1;
+
+ // REQUEST-TARGET slice: may include path + query (e.g., "/foo?bar=baz")
+ var url = firstHeader[(firstSpace + 1)..secondSpace];
+
+ var queryParamSeparator = url.IndexOf(Question);
+
+ if (queryParamSeparator == -1)
+ {
+ headerData.Route = new PinnedByteSequence(url);
+ }
+ else
+ {
+ headerData.Route = new PinnedByteSequence(url[..queryParamSeparator]);
+ headerData.QueryParameters = new PinnedByteSequence(url[(queryParamSeparator + 1)..]);
+ }
+
+ // Get the rest of the headers
+
+ headerData.Headers = new PinnedByteSequence(headerSpan[(lineEnd + 2)..]);
+
+ return headerData;
+ }
+
+ internal static H1HeaderData ExtractH1HeaderData(ReadOnlySpan headerSpan)
+ {
+ var headerData = new H1HeaderData();
+
+ var lineEnd = headerSpan.IndexOf(Crlf);
+ var firstHeader = headerSpan[..lineEnd];
+
+ var firstSpace = firstHeader.IndexOf(Space);
+ if (firstSpace == -1)
+ throw new InvalidOperationException("Invalid request line");
+
+ if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(firstHeader[..firstSpace], out var httpMethod))
+ {
+ headerData.HttpMethod = httpMethod;
+ }
+
+ var secondSpaceRelative = firstHeader[(firstSpace + 1)..].IndexOf(Space);
+ if (secondSpaceRelative == -1)
+ throw new InvalidOperationException("Invalid request line");
+
+ var secondSpace = firstSpace + secondSpaceRelative + 1;
+
+ // REQUEST-TARGET slice: may include path + query (e.g., "/foo?bar=baz")
+ var url = firstHeader[(firstSpace + 1)..secondSpace];
+
+ var queryParamSeparator = url.IndexOf(Question);
+
+ if (queryParamSeparator == -1)
+ {
+ if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(url, out var route))
+ {
+ headerData.Route = route;
+ }
+ }
+ else
+ {
+ if (CachedH1Data.CachedHttpMethods.TryGetOrAdd(url[..queryParamSeparator], out var route))
+ {
+ headerData.Route = route;
+ }
+
+ var querySpan = url[(queryParamSeparator + 1)..];
+ var current = 0;
+
+
+ while (current < querySpan.Length)
+ {
+ var separator = querySpan[current..].IndexOf(QuerySeparator); // (byte)'&'
+ ReadOnlySpan pair;
+
+ if (separator == -1)
+ {
+ pair = querySpan[current..];
+ current = querySpan.Length;
+ }
+ else
+ {
+ pair = querySpan.Slice(current, separator);
+ current += separator + 1;
+ }
+
+ var equalsIndex = pair.IndexOf(Equal);
+ if (equalsIndex == -1)
+ break;
+
+ headerData.QueryParameters!.TryAdd(CachedH1Data.CachedQueryKeys.GetOrAdd(pair[..equalsIndex]),
+ Encoding.UTF8.GetString(pair[(equalsIndex + 1)..]));
+ }
+
+ // Parse remaining headers
+
+ var lineStart = 0;
+ while (true)
+ {
+ lineStart += lineEnd + 2;
+
+ lineEnd = headerSpan[lineStart..].IndexOf("\r\n"u8);
+ if (lineEnd == 0)
+ {
+ // All Headers read
+ break;
+ }
+
+ var header = headerSpan.Slice(lineStart, lineEnd);
+ var colonIndex = header.IndexOf(Colon);
+
+ if (colonIndex == -1)
+ {
+ // Malformed header
+ continue;
+ }
+
+ var headerKey = header[..colonIndex];
+ var headerValue = header[(colonIndex + 2)..];
+
+ headerData.Headers!.TryAdd(CachedH1Data.CachedHeaderKeys.GetOrAdd(headerKey),
+ CachedH1Data.CachedHeaderValues.GetOrAdd(headerValue));
+ }
+ }
+
+ return headerData;
+ }
+
// ===== Common tokens (kept as ReadOnlySpan for zero-allocation literals) =====
private static ReadOnlySpan Crlf => "\r\n"u8;
diff --git a/Unhinged/HttpProtocol/H1/StringCache.cs b/Unhinged/HttpProtocol/H1/StringCache.cs
new file mode 100644
index 0000000..ad5dbaa
--- /dev/null
+++ b/Unhinged/HttpProtocol/H1/StringCache.cs
@@ -0,0 +1,113 @@
+using System.Text;
+
+namespace Unhinged;
+
+internal class StringCache
+{
+ private readonly Dictionary _map;
+
+ private readonly Lock _gate = new();
+
+ public StringCache(List? preCacheableStrings, int capacity = 256)
+ {
+ _map = new Dictionary(capacity, PinnedByteSequenceComparer.Instance);
+
+ if (preCacheableStrings is null)
+ {
+ return;
+ }
+
+ foreach (var preCacheableString in preCacheableStrings)
+ {
+ Add(preCacheableString);
+ }
+ }
+
+ public string? GetOrAdd(ReadOnlySpan bytes)
+ {
+ var seq = new PinnedByteSequence(bytes);
+
+ ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, seq);
+ if (!Unsafe.IsNullRef(ref item))
+ {
+ return item;
+ }
+
+ // Did not find a value, add it
+ var value = Encoding.UTF8.GetString(bytes);
+ if (TryAdd(seq, value))
+ {
+ return value;
+ }
+
+ return null;
+ }
+
+ public bool TryGetOrAdd(ReadOnlySpan bytes, out string value)
+ {
+ var seq = new PinnedByteSequence(bytes);
+
+ ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, seq);
+ if (!Unsafe.IsNullRef(ref item))
+ {
+ value = item;
+ return true;
+ }
+
+ // Did not find a value, add it
+ value = Encoding.UTF8.GetString(bytes);
+ return TryAdd(seq, value);
+ }
+
+ private bool TryAdd(PinnedByteSequence key, string value)
+ {
+ var allocatedKey = AllocateSequence(key);
+
+ lock (_gate)
+ {
+ return _map.TryAdd(allocatedKey, value);
+ }
+ }
+
+ private unsafe PinnedByteSequence AllocateSequence(PinnedByteSequence sequence)
+ {
+ // Allocate pinned unmanaged slab
+ var ptr = (byte*)NativeMemory.AlignedAlloc((nuint)sequence.Length, 64);
+
+ Buffer.MemoryCopy(
+ sequence.Ptr,
+ ptr,
+ sequence.Length,
+ sequence.Length);
+
+ return new PinnedByteSequence(ptr, sequence.Length);
+ }
+
+ private void Add(string item)
+ {
+ lock (_gate)
+ {
+ var bytes = Encoding.UTF8.GetBytes(item);
+ var seq = new PinnedByteSequence(bytes);
+ _map.TryAdd(seq, item);
+ }
+ }
+
+ private sealed class PinnedByteSequenceComparer : IEqualityComparer
+ {
+ public static readonly PinnedByteSequenceComparer Instance = new();
+
+ public bool Equals(PinnedByteSequence x, PinnedByteSequence y)
+ {
+ return x.AsSpan().SequenceEqual(y.AsSpan());
+ }
+
+ public int GetHashCode(PinnedByteSequence mem)
+ {
+ var span = mem.AsSpan();
+ var h = new HashCode();
+ h.AddBytes(span);
+ return h.ToHashCode();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs b/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs
new file mode 100644
index 0000000..288cd00
--- /dev/null
+++ b/Unhinged/HttpProtocol/H1/StringCacheMemoryVariant.cs
@@ -0,0 +1,72 @@
+using System.Text;
+
+namespace Unhinged;
+
+internal class StringCacheMemoryVariant
+{
+ private readonly Dictionary, string> _map;
+
+ private readonly Lock _gate = new();
+
+ public StringCacheMemoryVariant(List? preCacheableStrings, int capacity = 256)
+ {
+ _map = new Dictionary, string>(capacity, ReadOnlyMemoryComparer.Instance);
+
+ if (preCacheableStrings is null)
+ {
+ return;
+ }
+
+ foreach (var preCacheableString in preCacheableStrings)
+ {
+ Add(preCacheableString);
+ }
+ }
+
+ public bool TryGetOrAdd(ReadOnlyMemory bytes, out string value)
+ {
+ ref var item = ref CollectionsMarshal.GetValueRefOrNullRef(_map, bytes);
+ if (!Unsafe.IsNullRef(ref item))
+ {
+ value = item;
+ return true;
+ }
+
+ // Did not find a value, add it
+ value = Encoding.UTF8.GetString(bytes.Span);
+ return TryAdd(bytes, value);
+ }
+
+ private bool TryAdd(ReadOnlyMemory key, string value)
+ {
+ lock (_gate)
+ {
+ return _map.TryAdd(key, value);
+ }
+ }
+
+ private void Add(string item)
+ {
+ lock (_gate)
+ {
+ var bytes = Encoding.UTF8.GetBytes(item);
+ _map.TryAdd(bytes, item);
+ }
+ }
+
+ private sealed class ReadOnlyMemoryComparer : IEqualityComparer>
+ {
+ public static readonly ReadOnlyMemoryComparer Instance = new();
+
+ public bool Equals(ReadOnlyMemory x, ReadOnlyMemory y)
+ => x.Span.SequenceEqual(y.Span);
+
+ public int GetHashCode(ReadOnlyMemory mem)
+ {
+ var span = mem.Span;
+ var h = new HashCode();
+ h.AddBytes(span);
+ return h.ToHashCode();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Unhinged/Unhinged.csproj b/Unhinged/Unhinged.csproj
index 226a219..0e358eb 100644
--- a/Unhinged/Unhinged.csproj
+++ b/Unhinged/Unhinged.csproj
@@ -36,4 +36,8 @@
+
+
+
+
diff --git a/Unhinged/Utilities/PinnedByteSequence.cs b/Unhinged/Utilities/PinnedByteSequence.cs
new file mode 100644
index 0000000..4b31012
--- /dev/null
+++ b/Unhinged/Utilities/PinnedByteSequence.cs
@@ -0,0 +1,39 @@
+namespace Unhinged;
+
+public readonly unsafe struct PinnedByteSequence : IEquatable
+{
+ private readonly byte* _ptr { get; }
+
+ public readonly byte* Ptr => _ptr;
+
+ public int Length { get; }
+
+ public PinnedByteSequence(ReadOnlySpan span)
+ {
+ _ptr = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(span));
+ Length = span.Length;
+ }
+
+ public PinnedByteSequence(byte* ptr, int length)
+ {
+ _ptr = ptr;
+ Length = length;
+ }
+
+ internal unsafe ReadOnlySpan AsSpan() => new(_ptr, Length);
+
+ public bool Equals(PinnedByteSequence other)
+ {
+ return _ptr == other._ptr && Length == other.Length;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is PinnedByteSequence other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(unchecked((int)(long)_ptr), Length);
+ }
+}
\ No newline at end of file
diff --git a/Unhinged/Utilities/PooledDictionary.cs b/Unhinged/Utilities/PooledDictionary.cs
new file mode 100644
index 0000000..77eebd6
--- /dev/null
+++ b/Unhinged/Utilities/PooledDictionary.cs
@@ -0,0 +1,285 @@
+using System.Collections;
+
+namespace Unhinged;
+
+///
+/// Represents a high-performance, pooled dictionary that minimizes allocations by renting internal arrays from .
+/// This structure is optimized for small, short-lived dictionaries such as HTTP headers or per-request state.
+///
+/// The type of keys in the dictionary. Must implement .
+/// The type of values in the dictionary.
+public class PooledDictionary : IDictionary, IReadOnlyDictionary, IEnumerator> where TKey : IEquatable
+{
+ private static readonly ArrayPool> Pool = ArrayPool>.Shared;
+
+ private short _enumerator = -1;
+ private ushort _index;
+
+ private KeyValuePair[]? _entries;
+
+ private readonly IEqualityComparer _comparer;
+
+ #region Get-/Setters
+
+ private KeyValuePair[] Entries => _entries ??= Pool.Rent(Capacity);
+
+ private bool HasEntries => _entries is not null;
+
+ public virtual TValue this[TKey key]
+ {
+ get
+ {
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ if (_comparer.Equals(Entries[i].Key, key))
+ {
+ return Entries[i].Value;
+ }
+ }
+ }
+
+ throw new KeyNotFoundException();
+ }
+ set
+ {
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ if (_comparer.Equals(Entries[i].Key, key))
+ {
+ Entries[i] = new KeyValuePair(key, value);
+ return;
+ }
+ }
+ }
+
+ Add(key, value);
+ }
+ }
+
+ public ICollection Keys
+ {
+ get
+ {
+ var result = new List(_index);
+
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ result.Add(Entries[i].Key);
+ }
+ }
+
+ return result;
+ }
+ }
+
+ public ICollection Values
+ {
+ get
+ {
+ var result = new List(_index);
+
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ result.Add(Entries[i].Value);
+ }
+ }
+
+ return result;
+ }
+ }
+
+ public int Count => _index;
+
+ public bool IsReadOnly => false;
+
+ IEnumerable IReadOnlyDictionary.Keys => Keys;
+
+ IEnumerable IReadOnlyDictionary.Values => Values;
+
+ public KeyValuePair Current => Entries[_enumerator];
+
+ object IEnumerator.Current => Entries[_enumerator];
+
+ public int Capacity { get; private set; }
+
+ #endregion
+
+ #region Initialization
+
+ public PooledDictionary() : this(4, EqualityComparer.Default)
+ {
+
+ }
+
+ public PooledDictionary(int capacity, IEqualityComparer comparer)
+ {
+ Capacity = capacity;
+
+ _comparer = comparer;
+ }
+
+ #endregion
+
+ #region Functionality
+
+ public virtual void Add(TKey key, TValue value)
+ {
+ CheckResize();
+ Entries[_index++] = new KeyValuePair(key, value);
+ }
+
+ public virtual void Add(KeyValuePair item)
+ {
+ CheckResize();
+ Entries[_index++] = item;
+ }
+
+ public void Clear()
+ {
+ _index = 0;
+ }
+
+ public bool Contains(KeyValuePair item)
+ {
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ if (_comparer.Equals(Entries[i].Key, item.Key))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public bool ContainsKey(TKey key)
+ {
+ if (HasEntries)
+ {
+ for (var i = 0; i < _index; i++)
+ {
+ if (_comparer.Equals(Entries[i].Key, key))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public void CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ throw new NotSupportedException();
+ }
+
+ public IEnumerator> GetEnumerator()
+ {
+ _enumerator = -1;
+ return this;
+ }
+
+ public bool Remove(TKey key) => throw new NotSupportedException();
+
+ public bool Remove(KeyValuePair item) => throw new NotSupportedException();
+
+ public bool TryGetValue(TKey key, out TValue value)
+ {
+ if (ContainsKey(key))
+ {
+ value = this[key];
+ return true;
+ }
+
+#pragma warning disable CS8653, CS8601
+ value = default;
+#pragma warning restore
+
+ return false;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => this;
+
+ public bool MoveNext()
+ {
+ _enumerator++;
+ return _enumerator < _index;
+ }
+
+ public void Reset()
+ {
+ _enumerator = -1;
+ }
+
+ private void CheckResize()
+ {
+ if (_index >= Entries.Length)
+ {
+ var oldEntries = Entries;
+
+ try
+ {
+ if (oldEntries.Length > Capacity)
+ {
+ Capacity = oldEntries.Length * 2;
+ }
+ else
+ {
+ Capacity *= 2;
+ }
+
+ _entries = Pool.Rent(Capacity);
+
+ for (var i = 0; i < _index; i++)
+ {
+ Entries[i] = oldEntries[i];
+ }
+ }
+ finally
+ {
+ Pool.Return(oldEntries);
+ }
+ }
+ }
+
+ #endregion
+
+ #region IDisposable Support
+
+ private bool _disposed;
+
+ private void Dispose(bool disposing)
+ {
+ if (_disposed)
+ return;
+
+ if (disposing)
+ {
+ if (HasEntries)
+ {
+ Pool.Return(Entries);
+ }
+ }
+
+ _disposed = true;
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ }
+
+ #endregion
+
+}
\ No newline at end of file
diff --git a/Unhinged/Writers/FixedBufferWriter.cs b/Unhinged/Writers/FixedBufferWriter.cs
index 9d62499..8e918a0 100644
--- a/Unhinged/Writers/FixedBufferWriter.cs
+++ b/Unhinged/Writers/FixedBufferWriter.cs
@@ -3,6 +3,9 @@
// (var is avoided intentionally in this project so that concrete types are visible at call sites.)
// ReSharper disable always StackAllocInsideLoop
// ReSharper disable always ClassCannotBeInstantiated
+
+using System.Text;
+
#pragma warning disable CA2014
namespace Unhinged;
@@ -160,6 +163,13 @@ public void WriteUnmanaged(ReadOnlySpan source)
Tail += len;
}
+ public void WriteUnmanaged(string source)
+ {
+ var span = new Span(Ptr + Tail, _capacity - Tail);
+ var bytesWritten = Encoding.UTF8.GetBytes(source, span);
+ Tail += bytesWritten;
+ }
+
///
/// Copies data from a managed into the unmanaged buffer.
/// This version uses which performs bounds checks