diff --git a/frameworks/touchsocket/.dockerignore b/frameworks/touchsocket/.dockerignore new file mode 100644 index 000000000..cd42ee34e --- /dev/null +++ b/frameworks/touchsocket/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/frameworks/touchsocket/ArenaPlugin.cs b/frameworks/touchsocket/ArenaPlugin.cs new file mode 100644 index 000000000..72159943d --- /dev/null +++ b/frameworks/touchsocket/ArenaPlugin.cs @@ -0,0 +1,162 @@ +using System.Text; + +using TouchSocket.Core; +using TouchSocket.Http; + +using HttpMethod = TouchSocket.Http.HttpMethod; + +namespace TouchSocketArena; + +/// +/// The arena routes, answered from TouchSocket's HTTP plugin pipeline. +/// +/// +/// A plugin is how TouchSocket exposes HTTP: it sees each request, answers the ones it recognises +/// and calls the next one for the rest. Dispatch is on the path's first segment, which is what the +/// component gives you - there is no router above it to declare routes with. +/// +internal sealed class ArenaPlugin(Dataset dataset) : PluginBase, IHttpPlugin +{ + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + public async Task OnHttpRequest(IHttpSessionClient client, HttpContextEventArgs e) + { + var request = e.Context.Request; + var response = e.Context.Response; + + var url = request.RelativeURL; + + // Strip the query; TouchSocket parses it separately into Query. + var question = url.IndexOf('?'); + var path = question < 0 ? url : url[..question]; + + switch (First(path, out var rest)) + { + case "baseline11": + case "baseline2": + await BaselineAsync(request, response); + break; + + case "json": + await JsonAsync(request, response, rest); + break; + + case "pipeline": + await TextAsync(response, "ok"); + break; + + case "delay": + await DelayAsync(response, rest); + break; + + default: + response.StatusCode = 404; + await response.AnswerAsync(); + break; + } + + e.Handled = true; + } + + // GET /baseline11?a=1&b=2 - the sum as text. POST adds the body to it. + private async Task BaselineAsync(HttpRequest request, HttpResponse response) + { + var total = Number(request, "a") + Number(request, "b"); + + if (request.Method == HttpMethod.Post) + { + var body = await request.GetContentAsync(); + + if (body.Length > 0 && int.TryParse(Utf8.GetString(body.Span), out var fromBody)) + { + total += fromBody; + } + } + + await TextAsync(response, total.ToString()); + } + + // GET /delay/{ms} - answer after the wait, echoing the value back. + private async Task DelayAsync(HttpResponse response, string rest) + { + if (!int.TryParse(rest, out var ms) || ms < 0) + { + response.StatusCode = 404; + await response.AnswerAsync(); + return; + } + + if (ms > 0) + { + // Registers a timer and yields rather than holding a thread, so the waits in flight are + // bounded by memory instead of the pool. + await Task.Delay(ms); + } + + await TextAsync(response, ms.ToString()); + } + + // GET /json/{count}?m={multiplier} + private async Task JsonAsync(HttpRequest request, HttpResponse response, string rest) + { + if (!int.TryParse(rest, out var count)) + { + response.StatusCode = 404; + await response.AnswerAsync(); + return; + } + + var multiplier = int.TryParse(request.Query["m"], out var m) ? m : 1; + + var accepted = request.Headers["Accept-Encoding"].ToString() ?? string.Empty; + + var body = dataset.Render(count, multiplier, + accepted.Contains("br", StringComparison.OrdinalIgnoreCase), + accepted.Contains("gzip", StringComparison.OrdinalIgnoreCase), + out var encoding); + + if (body is null) + { + response.StatusCode = 503; + await response.AnswerAsync(); + return; + } + + response.StatusCode = 200; + response.Headers.Add("Vary", "Accept-Encoding"); + + if (encoding is not null) + { + response.Headers.Add("Content-Encoding", encoding); + } + + response.Content = new ReadonlyMemoryHttpContent(body, "application/json"); + await response.AnswerAsync(); + } + + private static async Task TextAsync(HttpResponse response, string value) + { + response.StatusCode = 200; + response.Content = new StringHttpContent(value, Utf8, "text/plain"); + await response.AnswerAsync(); + } + + private static int Number(HttpRequest request, string name) + => int.TryParse(request.Query[name], out var value) ? value : 0; + + // The first path segment, with whatever follows it in `rest`. + private static string First(string path, out string rest) + { + var start = path.Length > 0 && path[0] == '/' ? 1 : 0; + var end = path.IndexOf('/', start); + + if (end < 0) + { + rest = ""; + return path[start..]; + } + + rest = path[(end + 1)..]; + return path[start..end]; + } +} diff --git a/frameworks/touchsocket/Dataset.cs b/frameworks/touchsocket/Dataset.cs new file mode 100644 index 000000000..89d6ca947 --- /dev/null +++ b/frameworks/touchsocket/Dataset.cs @@ -0,0 +1,134 @@ +using System.Buffers; +using System.IO.Compression; +using System.Text.Json; + +namespace TouchSocketArena; + +/// +/// The dataset behind /json, parsed once at startup and serialized on every request. +/// +/// +/// No precomputed responses: the workload asks for the same few shapes millions of times, so +/// caching the finished bytes would turn the endpoint into a lookup table and stop measuring the +/// server at all. Only scratch is reused - the serializer's buffer and the compressor's, both per +/// thread and grown to their high-water mark. +/// +public sealed class Dataset +{ + private readonly List? _items; + + [ThreadStatic] private static ArrayBufferWriter? _json; + [ThreadStatic] private static byte[]? _compressed; + + public Dataset() + { + var path = Environment.GetEnvironmentVariable("DATASET_PATH") ?? "/data/dataset.json"; + + if (File.Exists(path)) + { + _items = JsonSerializer.Deserialize(File.ReadAllText(path), AppJsonContext.Default.ListItem); + } + } + + public bool IsAvailable => _items is not null; + + public int Count => _items?.Count ?? 0; + + /// + /// Serializes the response for this count and multiplier, compressing it only when the client + /// said it would take one. Watson's Send takes a byte[], so the result is copied out of the + /// per-thread scratch rather than handed over as a span. + /// + public byte[]? Render(int count, int multiplier, bool wantsBrotli, bool wantsGzip, out string? encoding) + { + encoding = null; + + if (_items is null) + { + return null; + } + + var source = _items; + + if (count > source.Count) count = source.Count; + if (count < 0) count = 0; + + var items = new ProcessedItem[count]; + + for (var i = 0; i < count; i++) + { + var item = source[i]; + + items[i] = new ProcessedItem + { + Id = item.Id, + Name = item.Name, + Category = item.Category, + Price = item.Price, + Quantity = item.Quantity, + Active = item.Active, + Tags = item.Tags, + Rating = item.Rating, + Total = (long)item.Price * item.Quantity * multiplier + }; + } + + var buffer = _json ??= new ArrayBufferWriter(32 * 1024); + buffer.ResetWrittenCount(); + + using (var writer = new Utf8JsonWriter(buffer)) + { + JsonSerializer.Serialize(writer, new ItemsResponse(items, count), + AppJsonContext.Default.ItemsResponseProcessedItem); + } + + var body = buffer.WrittenSpan; + + // Never compressed unless asked for: a response that arrives encoded without + // Accept-Encoding is exactly what the board's anti-cheat check looks for. + if (wantsBrotli) + { + encoding = "br"; + return Compress(body, brotli: true).ToArray(); + } + + if (wantsGzip) + { + encoding = "gzip"; + return Compress(body, brotli: false).ToArray(); + } + + return body.ToArray(); + } + + // Quality 1: the profile measures a server compressing a response, not the ratio a slow + // encoder can reach. + private static ReadOnlySpan Compress(ReadOnlySpan body, bool brotli) + { + var max = brotli + ? BrotliEncoder.GetMaxCompressedLength(body.Length) + : body.Length + (body.Length >> 2) + 64; + + var scratch = _compressed; + + if (scratch is null || scratch.Length < max) + { + _compressed = scratch = new byte[Math.Max(max, 32 * 1024)]; + } + + if (brotli) + { + BrotliEncoder.TryCompress(body, scratch, out var written, quality: 1, window: 22); + return scratch.AsSpan(0, written); + } + + using var output = new MemoryStream(scratch, 0, scratch.Length, writable: true); + + using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(body); + } + + return scratch.AsSpan(0, (int)output.Position); + } +} diff --git a/frameworks/touchsocket/Dockerfile b/frameworks/touchsocket/Dockerfile new file mode 100644 index 000000000..6cf350feb --- /dev/null +++ b/frameworks/touchsocket/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app +COPY . . +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/runtime:10.0 +WORKDIR /app +COPY --from=build /app/out . +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "touchsocketarena.dll"] diff --git a/frameworks/touchsocket/Program.cs b/frameworks/touchsocket/Program.cs new file mode 100644 index 000000000..1d640cdd3 --- /dev/null +++ b/frameworks/touchsocket/Program.cs @@ -0,0 +1,29 @@ +using TouchSocketArena; + +using TouchSocket.Core; +using TouchSocket.Http; +using TouchSocket.Sockets; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// touchsocket - TouchSocket.Http, the framework's HTTP/1.1 server component. +// +// Requests are answered from an IHttpPlugin, which is how TouchSocket exposes its HTTP pipeline: +// the plugin sees each request, answers the ones it recognises and passes the rest along. h1 +// cleartext on :8080. +// ───────────────────────────────────────────────────────────────────────────────────────────── + +var dataset = new Dataset(); + +var port = int.TryParse(Environment.GetEnvironmentVariable("PORT"), out var p) ? p : 8080; + +var service = new HttpService(); + +await service.SetupAsync(new TouchSocketConfig() + .SetListenIPHosts(port) + .ConfigurePlugins(a => a.Add(new ArenaPlugin(dataset)))); + +await service.StartAsync(); + +Console.WriteLine($"[touchsocket] :{port}, dataset={(dataset.IsAvailable ? dataset.Count + " items" : "absent")}"); + +await Task.Delay(Timeout.Infinite); diff --git a/frameworks/touchsocket/README.md b/frameworks/touchsocket/README.md new file mode 100644 index 000000000..0b2585ba1 --- /dev/null +++ b/frameworks/touchsocket/README.md @@ -0,0 +1,57 @@ +# touchsocket + +[TouchSocket](https://github.com/RRQM/TouchSocket) — `TouchSocket.Http`, the HTTP/1.1 server +component of the TouchSocket networking framework. + +Requests are answered from an `IHttpPlugin`, which is how TouchSocket exposes its HTTP pipeline: +the plugin sees each request, answers the ones it recognises and passes the rest along. Dispatch is +on the path's first segment — the component gives you the request, not a router to declare routes +with. + +## Scope + +HTTP/1.1 cleartext on `:8080`, plus the async delay. + +| Profiles | +|---| +| baseline, pipelined, limited-conn, async, latency-1m, latency-10k, json-comp | + +## Notes + +- `/json` is serialized from the parsed model on **every** request and compressed only when the + client asked for it, at brotli quality 1. Nothing is answered from a precomputed body. +- `/delay/{ms}` uses `Task.Delay`, which registers a timer and yields rather than holding a thread, + so waits in flight are bounded by memory. +- The project is named `touchsocketarena` rather than `touchsocket`: a project named for the + package it depends on makes NuGet resolve a dependency cycle on itself (NU1108). + +## Status: disabled + +The entry is `enabled: false` pending a fix in TouchSocket. + +A request that arrives **split across TCP segments** *and* asks for `Connection: close` is answered +with nothing: the socket is closed instead of the response being flushed. The same requests pass at +every split offset when the connection is keep-alive, so it is the close path specifically, not +request reassembly. + +Measured against `TouchSocket.Http` 4.3.6 on `.NET 10`, sweeping every split offset of one request: + +| request | result | +|---------|--------| +| `Connection: keep-alive` | 54/54 offsets answered correctly | +| `Connection: close` | 20/73 offsets answered with nothing | + +Repro — no framework beyond TouchSocket is involved, and the handler does no parsing of its own: + +```python +import socket, time +req = b"GET /baseline11?a=13&b=42 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" +for split in (4, 34, 41): + s = socket.create_connection(("127.0.0.1", 8080)) + s.sendall(req[:split]); time.sleep(0.01); s.sendall(req[split:]) + print(split, s.recv(4096)[:40]) # b'' on the failing offsets + s.close() +``` + +The arena's fragmentation checks send exactly this shape, which is why they fail while ordinary +`curl` traffic does not. diff --git a/frameworks/touchsocket/Types.cs b/frameworks/touchsocket/Types.cs new file mode 100644 index 000000000..49821955e --- /dev/null +++ b/frameworks/touchsocket/Types.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace TouchSocketArena; + +/// An item as stored in the dataset file. +public sealed class Item +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public int Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); +} + +/// A dataset item with the computed total the /json workload asks for. +public sealed class ProcessedItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public int Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); + public long Total { get; set; } +} + +public sealed class RatingInfo +{ + public int Score { get; set; } + public int Count { get; set; } +} + +public sealed record ItemsResponse(IReadOnlyList Items, int Count); + +// Source-generated so the JSON path costs no reflection at runtime, and the same +// camelCase shape the other entries answer with. +[JsonSerializable(typeof(ItemsResponse))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] +public partial class AppJsonContext : JsonSerializerContext { } diff --git a/frameworks/touchsocket/meta.json b/frameworks/touchsocket/meta.json new file mode 100644 index 000000000..bcce1be87 --- /dev/null +++ b/frameworks/touchsocket/meta.json @@ -0,0 +1,22 @@ +{ + "display_name": "touchsocket", + "language": "C#", + "type": "engine", + "engine": "touchsocket", + "description": "TouchSocket.Http, the HTTP/1.1 server component of the TouchSocket networking framework. Requests are answered from an IHttpPlugin, which is how TouchSocket exposes its HTTP pipeline: the plugin sees each request, answers the ones it recognises and passes the rest along. HTTP/1.1 cleartext on :8080. /json is serialized from the parsed model on every request and compressed only when the client asked for it, so nothing is answered from a precomputed body. Disabled pending a fix: a request that arrives split across TCP segments AND asks for Connection: close is answered with nothing - the socket is closed instead of the response being flushed. Fragmented requests with keep-alive pass at every offset, so it is the close path specifically.", + "repo": "https://github.com/RRQM/TouchSocket", + "enabled": false, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "async", + "latency-1m", + "latency-10k", + "json-comp" + ], + "maintainers": [ + "MDA2AV", + "RRQM" + ] +} diff --git a/frameworks/touchsocket/touchsocketarena.csproj b/frameworks/touchsocket/touchsocketarena.csproj new file mode 100644 index 000000000..1d43aa65c --- /dev/null +++ b/frameworks/touchsocket/touchsocketarena.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + true + true + touchsocketarena + TouchSocketArena + + + + +