Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frameworks/touchsocket/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bin/
obj/
162 changes: 162 additions & 0 deletions frameworks/touchsocket/ArenaPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System.Text;

using TouchSocket.Core;
using TouchSocket.Http;

using HttpMethod = TouchSocket.Http.HttpMethod;

namespace TouchSocketArena;

/// <summary>
/// The arena routes, answered from TouchSocket's HTTP plugin pipeline.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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];
}
}
134 changes: 134 additions & 0 deletions frameworks/touchsocket/Dataset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System.Buffers;
using System.IO.Compression;
using System.Text.Json;

namespace TouchSocketArena;

/// <summary>
/// The dataset behind /json, parsed once at startup and serialized on every request.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class Dataset
{
private readonly List<Item>? _items;

[ThreadStatic] private static ArrayBufferWriter<byte>? _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;

/// <summary>
/// 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.
/// </summary>
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<byte>(32 * 1024);
buffer.ResetWrittenCount();

using (var writer = new Utf8JsonWriter(buffer))
{
JsonSerializer.Serialize(writer, new ItemsResponse<ProcessedItem>(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<byte> Compress(ReadOnlySpan<byte> 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);
}
}
11 changes: 11 additions & 0 deletions frameworks/touchsocket/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
29 changes: 29 additions & 0 deletions frameworks/touchsocket/Program.cs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading