From f77977c64db032c5abb0273ce9d753c234ff94df Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 5 Sep 2026 22:06:41 +0100 Subject: [PATCH] Add a TouchSocket entry, disabled pending a close-path fix TouchSocket.Http is the HTTP/1.1 server component of the TouchSocket networking framework. Requests are answered from an IHttpPlugin, which is how the library exposes its HTTP pipeline: the plugin sees each request, answers the ones it recognises and passes the rest along. h1 cleartext on :8080, plus the async delay. It is committed with enabled: false, because it does not pass validation and the reason is in the library rather than in the entry. 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. Sweeping every split offset of one request: Connection: keep-alive 54/54 offsets answered correctly Connection: close 20/73 offsets answered with nothing So request reassembly is fine; it is the close path. Ordinary curl traffic never shows it, which is why the entry looks healthy by hand and fails the harness - the fragmentation checks send exactly that shape. The handler does no parsing of its own, so nothing between the socket and the plugin is this entry's code. Repro and measurements are in the README. /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. 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. --- frameworks/touchsocket/.dockerignore | 2 + frameworks/touchsocket/ArenaPlugin.cs | 162 ++++++++++++++++++ frameworks/touchsocket/Dataset.cs | 134 +++++++++++++++ frameworks/touchsocket/Dockerfile | 11 ++ frameworks/touchsocket/Program.cs | 29 ++++ frameworks/touchsocket/README.md | 57 ++++++ frameworks/touchsocket/Types.cs | 48 ++++++ frameworks/touchsocket/meta.json | 22 +++ .../touchsocket/touchsocketarena.csproj | 15 ++ 9 files changed, 480 insertions(+) create mode 100644 frameworks/touchsocket/.dockerignore create mode 100644 frameworks/touchsocket/ArenaPlugin.cs create mode 100644 frameworks/touchsocket/Dataset.cs create mode 100644 frameworks/touchsocket/Dockerfile create mode 100644 frameworks/touchsocket/Program.cs create mode 100644 frameworks/touchsocket/README.md create mode 100644 frameworks/touchsocket/Types.cs create mode 100644 frameworks/touchsocket/meta.json create mode 100644 frameworks/touchsocket/touchsocketarena.csproj 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 + + + + +