diff --git a/frameworks/zerg/Dockerfile b/frameworks/zerg/Dockerfile new file mode 100644 index 00000000..61cc6129 --- /dev/null +++ b/frameworks/zerg/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build + +# Install clang for NativeAOT linking +RUN apt-get update && apt-get install -y --no-install-recommends clang zlib1g-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . . + +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-preview +WORKDIR /app +COPY --from=build /app/out . +EXPOSE 8080 +ENTRYPOINT ["./zerg-httparena"] diff --git a/frameworks/zerg/Program.cs b/frameworks/zerg/Program.cs new file mode 100644 index 00000000..17de2956 --- /dev/null +++ b/frameworks/zerg/Program.cs @@ -0,0 +1,800 @@ +using System.Buffers; +using System.Buffers.Text; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Sqlite; +using Zerg.Core; +using zerg; +using zerg.Engine; +using zerg.Engine.Configs; + + +// ── Entry point (top-level statements) ── + +AppData.Load(); + +int reactorCount = Environment.ProcessorCount; +if (args.Length > 0 && int.TryParse(args[0], out int rc)) + reactorCount = rc; + +Console.WriteLine($"zerg HttpArena server starting on :8080 with {reactorCount} reactors"); + +var engine = new Engine(new EngineOptions +{ + Ip = "0.0.0.0", + Port = 8080, + Backlog = 65535, + ReactorCount = reactorCount, + AcceptorConfig = new AcceptorConfig( + RingFlags: 0, + SqCpuThread: -1, + SqThreadIdleMs: 100, + RingEntries: 8 * 1024, + BatchSqes: 4096, + CqTimeout: 100_000_000, + IPVersion: IPVersion.IPv6DualStack + ), + ReactorConfigs = Enumerable.Range(0, reactorCount).Select(_ => new ReactorConfig( + RingFlags: (1u << 12) | (1u << 13), // SINGLE_ISSUER | DEFER_TASKRUN + SqCpuThread: -1, + SqThreadIdleMs: 100, + RingEntries: 8 * 1024, + RecvBufferSize: 16 * 1024, + BufferRingEntries: 16 * 1024, + BatchCqes: 4096, + MaxConnectionsPerReactor: 8 * 1024, + CqTimeout: 1_000_000, + ConnectionBufferRingEntries: 32, + IncrementalBufferConsumption: false + )).ToArray() +}); + +engine.Listen(); + +var cts = new CancellationTokenSource(); + +try +{ + while (engine.ServerRunning) + { + var connection = await engine.AcceptAsync(cts.Token); + if (connection is null) continue; + _ = ConnectionHandler.HandleAsync(connection); + } +} +catch (OperationCanceledException) { } + +Console.WriteLine("Server stopped."); + + +// ── Data models ── + +public class DatasetItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = new(); + public RatingInfo Rating { get; set; } = new(); +} + +public class RatingInfo +{ + public double Score { get; set; } + public int Count { get; set; } +} + +public class ProcessedItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = new(); + public RatingInfo Rating { get; set; } = new(); + public double Total { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + GenerationMode = JsonSourceGenerationMode.Default)] +[JsonSerializable(typeof(JsonResponse))] +[JsonSerializable(typeof(DbResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +partial class AppJsonContext : JsonSerializerContext { } + +public class JsonResponse +{ + public List Items { get; set; } = new(); + public int Count { get; set; } +} + +public class DbResponse +{ + public List Items { get; set; } = new(); + public int Count { get; set; } +} + +public class DbItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = new(); + public DbRating Rating { get; set; } = new(); +} + +public class DbRating +{ + public double Score { get; set; } + public int Count { get; set; } +} + +// ── Shared app data ── + +static class AppData +{ + public static List Dataset = new(); + public static byte[] JsonCache = Array.Empty(); + public static byte[] LargeJsonCache = Array.Empty(); + public static Dictionary StaticFiles = new(); + public static SqliteConnection? Db; + + public static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static void Load() + { + // Dataset + var path = Environment.GetEnvironmentVariable("DATASET_PATH") ?? "/data/dataset.json"; + if (File.Exists(path)) + { + Dataset = JsonSerializer.Deserialize(File.ReadAllText(path), AppJsonContext.Default.ListDatasetItem) ?? new(); + JsonCache = BuildJsonCache(Dataset); + } + + // Large dataset for compression + var largePath = "/data/dataset-large.json"; + if (File.Exists(largePath)) + { + var largeItems = JsonSerializer.Deserialize(File.ReadAllText(largePath), AppJsonContext.Default.ListDatasetItem) ?? new(); + LargeJsonCache = BuildJsonCache(largeItems); + } + + // Static files + if (Directory.Exists("/data/static")) + { + var mimeTypes = new Dictionary + { + {".css","text/css"},{".js","application/javascript"},{".html","text/html"}, + {".woff2","font/woff2"},{".svg","image/svg+xml"},{".webp","image/webp"},{".json","application/json"} + }; + foreach (var file in Directory.GetFiles("/data/static")) + { + var name = Path.GetFileName(file); + var ext = Path.GetExtension(file); + var ct = mimeTypes.GetValueOrDefault(ext, "application/octet-stream"); + StaticFiles[name] = (File.ReadAllBytes(file), ct); + } + } + + // Database + var dbPath = "/data/benchmark.db"; + if (File.Exists(dbPath)) + { + Db = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + Db.Open(); + using var pragma = Db.CreateCommand(); + pragma.CommandText = "PRAGMA mmap_size=268435456"; + pragma.ExecuteNonQuery(); + } + } + + static byte[] BuildJsonCache(List items) + { + var processed = items.Select(d => new ProcessedItem + { + Id = d.Id, Name = d.Name, Category = d.Category, + Price = d.Price, Quantity = d.Quantity, Active = d.Active, + Tags = d.Tags, Rating = d.Rating, + Total = Math.Round(d.Price * d.Quantity, 2) + }).ToList(); + var resp = new JsonResponse { Items = processed, Count = processed.Count }; + return JsonSerializer.SerializeToUtf8Bytes(resp, AppJsonContext.Default.JsonResponse); + } +} + +// ── Date header (updated every second) ── + +static class DateHelper +{ + private const int PrefixLen = 6; + private const int DateLen = 29; + private static byte[] _master = new byte[PrefixLen + DateLen + 4]; + private static byte[] _scratch = new byte[PrefixLen + DateLen + 4]; + private static readonly Timer _timer; + + static DateHelper() + { + "Date: "u8.CopyTo(_master); + "Date: "u8.CopyTo(_scratch); + _master[PrefixLen + DateLen] = (byte)'\r'; + _master[PrefixLen + DateLen + 1] = (byte)'\n'; + _master[PrefixLen + DateLen + 2] = (byte)'\r'; + _master[PrefixLen + DateLen + 3] = (byte)'\n'; + _scratch[PrefixLen + DateLen] = (byte)'\r'; + _scratch[PrefixLen + DateLen + 1] = (byte)'\n'; + _scratch[PrefixLen + DateLen + 2] = (byte)'\r'; + _scratch[PrefixLen + DateLen + 3] = (byte)'\n'; + SetDate(DateTimeOffset.UtcNow); + _timer = new Timer(_ => SetDate(DateTimeOffset.UtcNow), null, 1000, 1000); + } + + private static void SetDate(DateTimeOffset value) + { + lock (_scratch) + { + Utf8Formatter.TryFormat(value, _scratch.AsSpan(PrefixLen), out _, 'R'); + (_scratch, _master) = (_master, _scratch); + } + } + + public static ReadOnlySpan HeaderBytes => _master; +} + +// ── HTTP request parsing ── + +readonly struct HttpRequest +{ + public readonly ReadOnlyMemory Method; + public readonly ReadOnlyMemory Path; + public readonly ReadOnlyMemory Query; + public readonly ReadOnlyMemory Body; + public readonly int TotalLength; + public readonly int ContentLength; + public readonly bool AcceptsGzip; + public readonly bool IsChunked; + + public HttpRequest(ReadOnlyMemory method, ReadOnlyMemory path, + ReadOnlyMemory query, ReadOnlyMemory body, + int totalLength, int contentLength, bool acceptsGzip, bool isChunked = false) + { + Method = method; Path = path; Query = query; Body = body; + TotalLength = totalLength; ContentLength = contentLength; + AcceptsGzip = acceptsGzip; IsChunked = isChunked; + } +} + +static class HttpParser +{ + // Returns null if buffer doesn't contain a complete request yet + public static HttpRequest? TryParse(ReadOnlySequence buffer) + { + // Find end of headers + var span = buffer.IsSingleSegment ? buffer.FirstSpan : buffer.ToArray().AsSpan(); + return TryParse(span, buffer); + } + + public static HttpRequest? TryParse(ReadOnlySpan span, ReadOnlySequence buffer) + { + int headerEnd = span.IndexOf("\r\n\r\n"u8); + if (headerEnd < 0) return null; + + int headersLen = headerEnd + 4; + + // Parse request line: METHOD SP PATH[?QUERY] SP HTTP/x.x\r\n + int firstSpace = span.IndexOf((byte)' '); + if (firstSpace < 0) return null; + var method = span[..firstSpace]; + + int pathStart = firstSpace + 1; + int secondSpace = span[pathStart..].IndexOf((byte)' '); + if (secondSpace < 0) return null; + var uri = span[pathStart..(pathStart + secondSpace)]; + + ReadOnlySpan path; + ReadOnlySpan query = default; + int qmark = uri.IndexOf((byte)'?'); + if (qmark >= 0) + { + path = uri[..qmark]; + query = uri[(qmark + 1)..]; + } + else + { + path = uri; + } + + // Parse headers — include the full header block up to the double CRLF + int contentLength = 0; + bool acceptsGzip = false; + bool isChunked = false; + + // Skip the request line first + int reqLineEnd = span.IndexOf("\r\n"u8); + if (reqLineEnd < 0) return null; + + // Parse each header line between request line and header end + int pos = reqLineEnd + 2; + while (pos < headerEnd) + { + int lineEnd = span[pos..headerEnd].IndexOf("\r\n"u8); + ReadOnlySpan line; + if (lineEnd < 0) + { + // Last header line before \r\n\r\n + line = span[pos..headerEnd]; + pos = headerEnd; + } + else + { + line = span[pos..(pos + lineEnd)]; + pos += lineEnd + 2; + } + + if (line.Length < 2) continue; + + byte first = (byte)(line[0] | 0x20); // lowercase + + if (first == (byte)'c') + { + // Content-Length or Content-Type + int colon = line.IndexOf((byte)':'); + if (colon >= 0) + { + var headerName = line[..colon]; + if (headerName.Length >= 14) + { + // Check for Content-Length (case insensitive) + bool isContentLength = true; + ReadOnlySpan cl = "content-length"u8; + if (headerName.Length >= cl.Length) + { + for (int i = 0; i < cl.Length; i++) + { + if ((headerName[i] | 0x20) != cl[i]) { isContentLength = false; break; } + } + } + else isContentLength = false; + + if (isContentLength) + { + var val = line[(colon + 1)..]; + while (val.Length > 0 && val[0] == (byte)' ') val = val[1..]; + if (Utf8Parser.TryParse(val, out int clv, out _)) + contentLength = clv; + } + } + } + } + else if (first == (byte)'a') + { + // Accept-Encoding + if (line.IndexOf("gzip"u8) >= 0) + acceptsGzip = true; + } + else if (first == (byte)'t') + { + // Transfer-Encoding: chunked + int colon = line.IndexOf((byte)':'); + if (colon >= 0) + { + var headerName = line[..colon]; + ReadOnlySpan te = "transfer-encoding"u8; + bool isTE = headerName.Length >= te.Length; + if (isTE) + { + for (int i = 0; i < te.Length; i++) + { + if ((headerName[i] | 0x20) != te[i]) { isTE = false; break; } + } + } + if (isTE) + { + var val = line[(colon + 1)..]; + if (val.IndexOf("chunked"u8) >= 0) + isChunked = true; + } + } + } + } + + // Handle chunked transfer encoding + if (isChunked) + { + // Parse chunked body: read chunks until 0\r\n + var remaining = span[headersLen..]; + int bodyStart = 0; + using var bodyStream = new MemoryStream(); + + while (bodyStart < remaining.Length) + { + // Find chunk size line + int chunkLineEnd = remaining[bodyStart..].IndexOf("\r\n"u8); + if (chunkLineEnd < 0) return null; // incomplete + + var chunkSizeLine = remaining[bodyStart..(bodyStart + chunkLineEnd)]; + // Parse hex chunk size + int chunkSize = 0; + for (int i = 0; i < chunkSizeLine.Length; i++) + { + byte b = chunkSizeLine[i]; + if (b >= (byte)'0' && b <= (byte)'9') + chunkSize = chunkSize * 16 + (b - '0'); + else if (b >= (byte)'a' && b <= (byte)'f') + chunkSize = chunkSize * 16 + (b - 'a' + 10); + else if (b >= (byte)'A' && b <= (byte)'F') + chunkSize = chunkSize * 16 + (b - 'A' + 10); + else if (b == (byte)';') + break; // chunk extension + else + break; + } + + bodyStart += chunkLineEnd + 2; // skip size line + CRLF + + if (chunkSize == 0) + { + // Terminal chunk — skip trailing CRLF + if (bodyStart + 2 <= remaining.Length) + bodyStart += 2; + break; + } + + // Ensure we have enough data for the chunk + trailing CRLF + if (bodyStart + chunkSize + 2 > remaining.Length) + return null; // incomplete + + bodyStream.Write(remaining.Slice(bodyStart, chunkSize)); + bodyStart += chunkSize + 2; // skip chunk data + CRLF + } + + int totalLen = headersLen + bodyStart; + var bodyBytes = bodyStream.ToArray(); + + return new HttpRequest( + method.ToArray(), + path.ToArray(), + query.Length > 0 ? query.ToArray() : ReadOnlyMemory.Empty, + bodyBytes, + totalLen, + bodyBytes.Length, + acceptsGzip, + isChunked: true); + } + + int totalLength = headersLen + contentLength; + if (span.Length < totalLength) return null; // body not yet complete + + ReadOnlyMemory body = default; + if (contentLength > 0) + { + body = buffer.Slice(headersLen, contentLength).ToArray(); + } + + return new HttpRequest( + method.ToArray(), + path.ToArray(), + query.Length > 0 ? query.ToArray() : ReadOnlyMemory.Empty, + body, + totalLength, + contentLength, + acceptsGzip); + } +} + +// ── Response writing ── + +static class HttpResponse +{ + static ReadOnlySpan ServerHeader => "Server: zerg\r\n"u8; + + public static void WriteText(Connection conn, ReadOnlySpan body, int statusCode = 200) + { + Span lenBuf = stackalloc byte[16]; + Utf8Formatter.TryFormat(body.Length, lenBuf, out int lenWritten); + + conn.Write(statusCode == 200 + ? "HTTP/1.1 200 OK\r\n"u8 + : "HTTP/1.1 404 Not Found\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Type: text/plain\r\nContent-Length: "u8); + conn.Write(lenBuf[..lenWritten]); + conn.Write("\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write(body); + } + + public static void WriteJson(Connection conn, byte[] body, bool compress, bool acceptsGzip) + { + if (compress && acceptsGzip && body.Length > 256) + { + using var ms = new MemoryStream(); + using (var gz = new GZipStream(ms, CompressionLevel.Fastest, true)) + gz.Write(body); + var compressed = ms.ToArray(); + + Span lenBuf = stackalloc byte[16]; + Utf8Formatter.TryFormat(compressed.Length, lenBuf, out int lenWritten); + + conn.Write("HTTP/1.1 200 OK\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Length: "u8); + conn.Write(lenBuf[..lenWritten]); + conn.Write("\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write((ReadOnlySpan)compressed); + } + else + { + Span lenBuf = stackalloc byte[16]; + Utf8Formatter.TryFormat(body.Length, lenBuf, out int lenWritten); + + conn.Write("HTTP/1.1 200 OK\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Type: application/json\r\nContent-Length: "u8); + conn.Write(lenBuf[..lenWritten]); + conn.Write("\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write((ReadOnlySpan)body); + } + } + + public static void WriteBytes(Connection conn, byte[] body, string contentType) + { + Span lenBuf = stackalloc byte[16]; + Utf8Formatter.TryFormat(body.Length, lenBuf, out int lenWritten); + + conn.Write("HTTP/1.1 200 OK\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Type: "u8); + conn.Write((ReadOnlySpan)Encoding.UTF8.GetBytes(contentType)); + conn.Write("\r\nContent-Length: "u8); + conn.Write(lenBuf[..lenWritten]); + conn.Write("\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write((ReadOnlySpan)body); + } + + public static void Write404(Connection conn) + { + conn.Write("HTTP/1.1 404 Not Found\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Length: 9\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write("Not Found"u8); + } + + public static void Write500(Connection conn, string msg) + { + var body = Encoding.UTF8.GetBytes(msg); + Span lenBuf = stackalloc byte[16]; + Utf8Formatter.TryFormat(body.Length, lenBuf, out int lenWritten); + + conn.Write("HTTP/1.1 500 Internal Server Error\r\n"u8); + conn.Write(ServerHeader); + conn.Write("Content-Type: text/plain\r\nContent-Length: "u8); + conn.Write(lenBuf[..lenWritten]); + conn.Write("\r\n"u8); + conn.Write(DateHelper.HeaderBytes); + conn.Write((ReadOnlySpan)body); + } +} + +// ── Route handling ── + +static class Router +{ + public static void Handle(Connection conn, in HttpRequest req) + { + var path = req.Path.Span; + + if (path.SequenceEqual("/pipeline"u8)) + HandlePipeline(conn); + else if (path.SequenceEqual("/baseline11"u8)) + HandleBaseline11(conn, req); + else if (path.SequenceEqual("/baseline2"u8)) + HandleBaseline2(conn, req); + else if (path.SequenceEqual("/json"u8)) + HandleJson(conn); + else if (path.SequenceEqual("/compression"u8)) + HandleCompression(conn, req); + else if (path.SequenceEqual("/db"u8)) + HandleDb(conn, req); + else if (path.SequenceEqual("/upload"u8)) + HandleUpload(conn, req); + else if (path.Length > 8 && path[..8].SequenceEqual("/static/"u8)) + HandleStatic(conn, path); + else + HttpResponse.Write404(conn); + } + + static void HandlePipeline(Connection conn) + { + HttpResponse.WriteText(conn, "ok"u8); + } + + static void HandleBaseline11(Connection conn, in HttpRequest req) + { + long sum = SumQuery(req.Query.Span); + + // POST: add body value + if (req.Method.Span.SequenceEqual("POST"u8) && req.Body.Length > 0) + { + var bodyStr = Encoding.UTF8.GetString(req.Body.Span).Trim(); + if (long.TryParse(bodyStr, out long bval)) + sum += bval; + } + + HttpResponse.WriteText(conn, Encoding.UTF8.GetBytes(sum.ToString())); + } + + static void HandleBaseline2(Connection conn, in HttpRequest req) + { + long sum = SumQuery(req.Query.Span); + HttpResponse.WriteText(conn, Encoding.UTF8.GetBytes(sum.ToString())); + } + + static void HandleJson(Connection conn) + { + if (AppData.JsonCache.Length == 0) + { + HttpResponse.Write500(conn, "Dataset not loaded"); + return; + } + HttpResponse.WriteJson(conn, AppData.JsonCache, false, false); + } + + static void HandleCompression(Connection conn, in HttpRequest req) + { + if (AppData.LargeJsonCache.Length == 0) + { + HttpResponse.Write500(conn, "Dataset not loaded"); + return; + } + HttpResponse.WriteJson(conn, AppData.LargeJsonCache, true, req.AcceptsGzip); + } + + static void HandleDb(Connection conn, in HttpRequest req) + { + if (AppData.Db == null) + { + HttpResponse.Write500(conn, "Database not available"); + return; + } + + double min = 10, max = 50; + var query = req.Query.Span; + if (query.Length > 0) + { + var qs = Encoding.UTF8.GetString(query); + foreach (var pair in qs.Split('&')) + { + if (pair.StartsWith("min=") && double.TryParse(pair[4..], out double pmin)) + min = pmin; + else if (pair.StartsWith("max=") && double.TryParse(pair[4..], out double pmax)) + max = pmax; + } + } + + lock (AppData.Db) + { + using var cmd = AppData.Db.CreateCommand(); + cmd.CommandText = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN @min AND @max LIMIT 50"; + cmd.Parameters.AddWithValue("@min", min); + cmd.Parameters.AddWithValue("@max", max); + using var reader = cmd.ExecuteReader(); + + var items = new List(); + while (reader.Read()) + { + items.Add(new DbItem + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = reader.GetDouble(3), + Quantity = reader.GetInt32(4), + Active = reader.GetInt32(5) == 1, + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString) ?? new(), + Rating = new DbRating { Score = reader.GetDouble(7), Count = reader.GetInt32(8) } + }); + } + + var resp = new DbResponse { Items = items, Count = items.Count }; + var body = JsonSerializer.SerializeToUtf8Bytes(resp, AppJsonContext.Default.DbResponse); + HttpResponse.WriteJson(conn, body, false, false); + } + } + + static void HandleUpload(Connection conn, in HttpRequest req) + { + HttpResponse.WriteText(conn, Encoding.UTF8.GetBytes(req.ContentLength.ToString())); + } + + static void HandleStatic(Connection conn, ReadOnlySpan path) + { + var filename = Encoding.UTF8.GetString(path[8..]); + if (AppData.StaticFiles.TryGetValue(filename, out var sf)) + HttpResponse.WriteBytes(conn, sf.Data, sf.ContentType); + else + HttpResponse.Write404(conn); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static long SumQuery(ReadOnlySpan query) + { + if (query.IsEmpty) return 0; + long sum = 0; + var qs = Encoding.UTF8.GetString(query); + foreach (var pair in qs.Split('&')) + { + var eq = pair.IndexOf('='); + if (eq >= 0 && long.TryParse(pair[(eq + 1)..], out long n)) + sum += n; + } + return sum; + } +} + +// ── Connection handler ── + +static class ConnectionHandler +{ + internal static async Task HandleAsync(Connection connection) + { + var reader = new ConnectionPipeReader(connection); + + try + { + while (true) + { + var result = await reader.ReadAsync(); + if (result.IsCompleted || result.IsCanceled) + break; + + var buffer = result.Buffer; + bool wrote = false; + + while (buffer.Length > 0) + { + var req = HttpParser.TryParse(buffer); + if (req == null) break; + + Router.Handle(connection, req.Value); + wrote = true; + buffer = buffer.Slice(req.Value.TotalLength); + } + + reader.AdvanceTo(buffer.Start, buffer.End); + + if (wrote) + await connection.FlushAsync(); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Connection error: {ex.Message}"); + } + + reader.Complete(); + } +} + + diff --git a/frameworks/zerg/README.md b/frameworks/zerg/README.md new file mode 100644 index 00000000..135a92b4 --- /dev/null +++ b/frameworks/zerg/README.md @@ -0,0 +1,24 @@ +# zerg — C# io_uring TCP server + +[zerg](https://github.com/MDA2AV/zerg) is a low-level TCP server framework for C# built directly on Linux `io_uring`. It provides zero-copy buffer rings, multishot accept/recv, and `DEFER_TASKRUN`/`SINGLE_ISSUER` optimizations — no HTTP abstractions, just raw TCP with async/await. + +This entry builds a full HTTP/1.1 server on top of zerg using its `ConnectionPipeReader` adapter for robust buffer management, with manual HTTP parsing and routing. + +## What makes it interesting + +- **io_uring native:** Direct ring submission via liburing shim — no epoll, no kqueue +- **Zero-copy reads:** Provided buffer rings let the kernel write directly into pre-allocated memory +- **C# without Kestrel:** Shows what .NET can do when you bypass the ASP.NET stack entirely +- **Same language, different I/O:** Direct comparison with `aspnet-minimal` (Kestrel) — same runtime, radically different I/O strategy + +## Configuration + +- Reactor count = CPU count (one io_uring instance per reactor thread) +- 16KB recv buffers, 16K buffer ring entries per reactor +- SINGLE_ISSUER + DEFER_TASKRUN ring flags for minimal kernel transitions +- PipeReader adapter for correct HTTP pipelining support + +## Requirements + +- Linux kernel 6.1+ (io_uring provided buffers) +- .NET 10 preview diff --git a/frameworks/zerg/meta.json b/frameworks/zerg/meta.json new file mode 100644 index 00000000..2c18c5cc --- /dev/null +++ b/frameworks/zerg/meta.json @@ -0,0 +1,19 @@ +{ + "display_name": "zerg", + "language": "C#", + "type": "framework", + "engine": "io_uring", + "description": "Raw C# TCP server on Linux io_uring via zerg, with zero-copy buffer rings and manual HTTP parsing.", + "repo": "https://github.com/MDA2AV/zerg", + "enabled": true, + "tests": [ + "baseline", + "noisy", + "pipelined", + "limited-conn", + "json", + "upload", + "compression", + "mixed" + ] +} diff --git a/frameworks/zerg/zerg-httparena.csproj b/frameworks/zerg/zerg-httparena.csproj new file mode 100644 index 00000000..e4fa4ae5 --- /dev/null +++ b/frameworks/zerg/zerg-httparena.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + true + true + Speed + true + + + + + + diff --git a/scripts/validate.sh b/scripts/validate.sh index 074b8771..459e9632 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -63,6 +63,16 @@ if has_test "static-h2" || has_test "static-h3"; then docker_args+=(-v "$DATA_DIR/static:/data/static:ro") fi +# Allow io_uring syscalls for frameworks that need them (blocked by default seccomp) +ENGINE=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('engine',''))" 2>/dev/null || true) +if [ "$ENGINE" = "io_uring" ]; then + docker_args+=(--security-opt seccomp=unconfined) + docker_args+=(--ulimit memlock=-1:-1) +fi + +# Remove any stale container from a previous run +cleanup + docker run "${docker_args[@]}" "$IMAGE_NAME" # Wait for server to start