diff --git a/frameworks/aspnet-minimal-iouring/AppData.cs b/frameworks/aspnet-minimal-iouring/AppData.cs deleted file mode 100644 index 40db06841..000000000 --- a/frameworks/aspnet-minimal-iouring/AppData.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json; -using Npgsql; -using StackExchange.Redis; - -static class AppData -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - public static List? DatasetItems; - - public static NpgsqlDataSource? PgDataSource; - - // Optional Redis cache for the crud profile. When REDIS_URL is set, - // crud handlers use Redis as a shared cache; otherwise they use the - // in-process IMemoryCache. Mirrors hono-bun's pattern so frameworks - // can be compared apples-to-apples on the same cache topology. - public static IDatabase? RedisDb; - - public static void Load() - { - LoadDataset(); - OpenPgPool(); - OpenRedis(); - } - - static void LoadDataset() - { - var path = Environment.GetEnvironmentVariable("DATASET_PATH") ?? "/data/dataset.json"; - if (!File.Exists(path)) return; - DatasetItems = JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions); - } - - static void OpenPgPool() - { - var dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL"); - if (string.IsNullOrEmpty(dbUrl)) return; - try - { - var uri = new Uri(dbUrl); - var userInfo = uri.UserInfo.Split(':'); - var maxConn = int.TryParse(Environment.GetEnvironmentVariable("DATABASE_MAX_CONN"), out var p) && p > 0 ? p : 256; - var minConn = Math.Min(64, maxConn); - var connStr = $"Host={uri.Host};Port={uri.Port};Username={userInfo[0]};Password={userInfo[1]};Database={uri.AbsolutePath.TrimStart('/')};Maximum Pool Size={maxConn};Minimum Pool Size={minConn};Multiplexing=true;No Reset On Close=true;Max Auto Prepare=20;Auto Prepare Min Usages=1"; - var builder = new NpgsqlDataSourceBuilder(connStr); - PgDataSource = builder.Build(); - } - catch { } - } - - static void OpenRedis() - { - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - if (string.IsNullOrEmpty(redisUrl)) return; - try - { - // REDIS_URL is "redis://host:port" — convert to StackExchange's - // "host:port" configuration string. - var uri = new Uri(redisUrl); - var config = ConfigurationOptions.Parse($"{uri.Host}:{uri.Port}"); - config.AbortOnConnectFail = false; - var muxer = ConnectionMultiplexer.Connect(config); - RedisDb = muxer.GetDatabase(); - } - catch { } - } -} diff --git a/frameworks/aspnet-minimal-iouring/Dockerfile b/frameworks/aspnet-minimal-iouring/Dockerfile deleted file mode 100644 index eafc27cce..000000000 --- a/frameworks/aspnet-minimal-iouring/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# Stage 1: Build the custom .NET runtime with io_uring socket engine -FROM mcr.microsoft.com/dotnet/sdk:11.0-preview AS runtime-build -RUN apt-get update && apt-get install -y --no-install-recommends \ - git cmake clang lld llvm python3 ninja-build \ - libicu-dev libssl-dev libkrb5-dev zlib1g-dev \ - liblttng-ust-dev libunwind-dev \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /runtime -COPY frameworks/aspnet-minimal-iouring/patch-iouring.py /tmp/patch-iouring.py -RUN git clone --depth 1 --branch io_uring https://github.com/benaadams/runtime.git . \ - && python3 /tmp/patch-iouring.py \ - && ./build.sh -subset clr+libs -c Release -bl \ - && echo "=== Build artifacts ===" \ - && ls -la /runtime/artifacts/bin/runtime/ \ - && echo "=== System.Net.Sockets.dll location ===" \ - && find /runtime/artifacts -name "System.Net.Sockets.dll" -type f 2>/dev/null - -# Stage 2: Build the app using the official SDK -FROM mcr.microsoft.com/dotnet/sdk:11.0-preview AS app-build -WORKDIR /app -COPY frameworks/aspnet-minimal-iouring/*.csproj ./ -RUN dotnet restore -COPY frameworks/aspnet-minimal-iouring/ . -# MapStaticAssets() bakes files from wwwroot/ into a manifest at publish time. -# aspnet-minimal serves /static/{filename} from there, so data/static/ must be -# staged into wwwroot/static/ before `dotnet publish` runs. -COPY data/static/ wwwroot/static/ -RUN dotnet publish -c Release -o out - -# Stage 3: Final image — patch the runtime with io_uring binaries -FROM mcr.microsoft.com/dotnet/aspnet:11.0-preview - -# Install libmsquic for HTTP/3 -ADD https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb /packages-microsoft-prod.deb -RUN dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb \ - && apt-get update \ - && apt-get install -y --no-install-recommends libmsquic \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Overlay custom runtime libraries (io_uring socket engine) -COPY --from=runtime-build /runtime/artifacts/bin/runtime/net11.0-linux-Release-x64/ /tmp/iouring-runtime/ -RUN cp -f /tmp/iouring-runtime/* /usr/share/dotnet/shared/Microsoft.NETCore.App/11.0.0-preview.*/ \ - && rm -rf /tmp/iouring-runtime \ - && echo "=== Overlay applied to ===" && ls -d /usr/share/dotnet/shared/Microsoft.NETCore.App/11.0.0-preview.* - -WORKDIR /app -COPY --from=app-build /app/out . -EXPOSE 8080 8443/tcp 8443/udp - -# Enable io_uring socket engine with SQPOLL (kernel-side polling) -ENV DOTNET_SYSTEM_NET_SOCKETS_IO_URING=1 -ENV DOTNET_SYSTEM_NET_SOCKETS_IO_URING_SQPOLL=1 - -ENTRYPOINT ["dotnet", "aspnet-minimal-iouring.dll"] diff --git a/frameworks/aspnet-minimal-iouring/Handlers.cs b/frameworks/aspnet-minimal-iouring/Handlers.cs deleted file mode 100644 index 856c6e638..000000000 --- a/frameworks/aspnet-minimal-iouring/Handlers.cs +++ /dev/null @@ -1,319 +0,0 @@ -using System.Text.Json; -using System.Buffers; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.Extensions.Caching.Memory; -using StackExchange.Redis; - - -[JsonSerializable(typeof(ResponseDto))] -[JsonSerializable(typeof(ResponseDto))] -[JsonSerializable(typeof(DbResponseItemDto))] -[JsonSerializable(typeof(ProcessedItem))] -[JsonSerializable(typeof(RatingInfo))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(CrudListResponse))] -[JsonSerializable(typeof(CrudWriteResponse))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -partial class AppJsonContext : JsonSerializerContext { } - -static class Handlers -{ - // Returning `string` makes ASP.NET minimal APIs set Content-Type to - // text/plain automatically. Returning `int` defaults to JSON and - // serializes the bare number — which violates the baseline contract. - public static string Sum(int a, int b) => (a + b).ToString(); - - public static async ValueTask SumBody(int a, int b, HttpRequest req) - { - using var reader = new StreamReader(req.Body); - return (a + b + int.Parse(await reader.ReadToEndAsync())).ToString(); - } - - public static string Text() => "ok"; - - public static async ValueTask Upload(HttpRequest req) - { - long size = 0; - var buffer = ArrayPool.Shared.Rent(65536); - try - { - int read; - while ((read = await req.Body.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) - { - size += read; - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - - return size.ToString(); - } - - public static Results>, ProblemHttpResult> Json(int count, HttpRequest req) - { - var source = AppData.DatasetItems; - if (source == null) - return TypedResults.Problem("Dataset not loaded"); - - if (count > source.Count) count = source.Count; - if (count < 0) count = 0; - - int m = 1; - if (req.Query.TryGetValue("m", out var mVal) && int.TryParse(mVal, out var pm)) m = pm; - - var items = new ProcessedItem[count]; - - for (int 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 = item.Price * item.Quantity * m - }; - } - - return TypedResults.Json(new ResponseDto(items, count), AppJsonContext.Default.ResponseDtoProcessedItem); - } - - public static async Task>, ProblemHttpResult>> AsyncDatabase(HttpRequest req) - { - if (AppData.PgDataSource == null) - return TypedResults.Problem("DB not available"); - - // Query Parsing - double min = 10, max = 50; - int limit = 50; - var query = req.Query; - if (query.TryGetValue("min", out var minVal) && double.TryParse(minVal, out var pmin)) min = pmin; - if (query.TryGetValue("max", out var maxVal) && double.TryParse(maxVal, out var pmax)) max = pmax; - if (query.TryGetValue("limit", out var limVal) && int.TryParse(limVal, out var plim)) limit = Math.Clamp(plim, 1, 50); - - await using var cmd = AppData.PgDataSource.CreateCommand( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"); - - cmd.Parameters.AddWithValue(min); - cmd.Parameters.AddWithValue(max); - cmd.Parameters.AddWithValue(limit); - - await using var reader = await cmd.ExecuteReaderAsync(); - - var items = new List(limit); - - while (await reader.ReadAsync()) - { - items.Add(new DbResponseItemDto - { - Id = reader.GetInt32(0), - Name = reader.GetString(1), - Category = reader.GetString(2), - Price = (int)reader.GetDouble(3), - Quantity = reader.GetInt32(4), - Active = reader.GetBoolean(5), - Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, - Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } - }); - } - - return TypedResults.Json(new ResponseDto(items, items.Count), AppJsonContext.Default.ResponseDtoDbResponseItemDto); - } - - // ── CRUD handlers ────────────────────────────────────────────────── - // - // Realistic REST API with paginated list, cached single-item read, - // create, and update. Cache-aside on single-item reads with 200ms TTL, - // invalidated on PUT. List queries always hit Postgres. - - private static readonly MemoryCacheEntryOptions _crudCacheOpts = - new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(200) }; - - private static readonly JsonSerializerOptions _crudJsonOpts = - new(JsonSerializerDefaults.Web); - - // GET /crud/items?category=X&page=N&limit=M — paginated list (always DB, never cached) - public static async Task CrudList(HttpRequest req) - { - if (AppData.PgDataSource is null) - return TypedResults.Problem("DB not available"); - - var query = req.Query; - var category = query["category"].ToString(); - if (string.IsNullOrEmpty(category)) category = "electronics"; - int.TryParse(query["page"], out var page); - if (page < 1) page = 1; - int.TryParse(query["limit"], out var limit); - if (limit < 1 || limit > 50) limit = 10; - var offset = (page - 1) * limit; - - // Single data query. The previous COUNT(*) pass was 90%+ of PG CPU - // because concurrent writes kept the visibility map dirty, forcing - // heap fetches on every index-only scan. "Load more" pagination - // (return page size, no total) is a realistic alternative that - // removes that dominant cost. - await using var cmd = AppData.PgDataSource.CreateCommand( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + - "FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"); - cmd.Parameters.AddWithValue(category); - cmd.Parameters.AddWithValue(limit); - cmd.Parameters.AddWithValue(offset); - - await using var reader = await cmd.ExecuteReaderAsync(); - var items = new List(); - while (await reader.ReadAsync()) - { - items.Add(new DbResponseItemDto - { - Id = reader.GetInt32(0), - Name = reader.GetString(1), - Category = reader.GetString(2), - Price = reader.GetInt32(3), - Quantity = reader.GetInt32(4), - Active = reader.GetBoolean(5), - Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, - Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } - }); - } - - return TypedResults.Json(new CrudListResponse { Items = items, Total = items.Count, Page = page, Limit = limit }, - AppJsonContext.Default.CrudListResponse); - } - - // GET /crud/items/{id} — single item, cached with 200ms TTL. - // Redis when REDIS_URL is set (cache stores pre-serialized JSON string so - // HIT path skips a Serialize+Deserialize round trip); else in-process - // IMemoryCache (caches the typed DTO). - public static async Task CrudRead(int id, IMemoryCache cache, HttpContext ctx) - { - if (AppData.PgDataSource is null) - return TypedResults.Problem("DB not available"); - - var cacheKey = $"crud:{id}"; - - if (AppData.RedisDb is not null) - { - var cachedJson = await AppData.RedisDb.StringGetAsync(cacheKey); - if (cachedJson.HasValue) - { - ctx.Response.Headers["X-Cache"] = "HIT"; - return Results.Content((string)cachedJson!, "application/json"); - } - - var item = await FetchItemByIdAsync(id); - if (item is null) return TypedResults.NotFound(); - - var json = JsonSerializer.Serialize(item, AppJsonContext.Default.DbResponseItemDto); - await AppData.RedisDb.StringSetAsync(cacheKey, json, TimeSpan.FromMilliseconds(200)); - ctx.Response.Headers["X-Cache"] = "MISS"; - return Results.Content(json, "application/json"); - } - - if (cache.TryGetValue(cacheKey, out DbResponseItemDto? cached)) - { - ctx.Response.Headers["X-Cache"] = "HIT"; - return TypedResults.Json(cached, AppJsonContext.Default.DbResponseItemDto); - } - - var dto = await FetchItemByIdAsync(id); - if (dto is null) return TypedResults.NotFound(); - - cache.Set(cacheKey, dto, _crudCacheOpts); - ctx.Response.Headers["X-Cache"] = "MISS"; - return TypedResults.Json(dto, AppJsonContext.Default.DbResponseItemDto); - } - - private static async Task FetchItemByIdAsync(int id) - { - await using var cmd = AppData.PgDataSource!.CreateCommand( - "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + - "FROM items WHERE id = $1 LIMIT 1"); - cmd.Parameters.AddWithValue(id); - - await using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) return null; - - return new DbResponseItemDto - { - Id = reader.GetInt32(0), - Name = reader.GetString(1), - Category = reader.GetString(2), - Price = reader.GetInt32(3), - Quantity = reader.GetInt32(4), - Active = reader.GetBoolean(5), - Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, - Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } - }; - } - - // POST /crud/items — create item, return 201 - public static async Task CrudCreate(HttpRequest req) - { - if (AppData.PgDataSource is null) - return TypedResults.Problem("DB not available"); - - using var sr = new StreamReader(req.Body); - var body = await sr.ReadToEndAsync(); - var input = JsonSerializer.Deserialize(body, _crudJsonOpts); - if (input is null) - return TypedResults.BadRequest(); - - await using var cmd = AppData.PgDataSource.CreateCommand( - "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) " + - "VALUES ($1, $2, $3, $4, $5, true, '[\"bench\"]', 0, 0) " + - "ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 " + - "RETURNING id"); - cmd.Parameters.AddWithValue(input.Id); - cmd.Parameters.AddWithValue(input.Name ?? "New Product"); - cmd.Parameters.AddWithValue(input.Category ?? "test"); - cmd.Parameters.AddWithValue(input.Price); - cmd.Parameters.AddWithValue(input.Quantity); - - var newId = (int)(await cmd.ExecuteScalarAsync())!; - return TypedResults.Json( - new CrudWriteResponse { Id = newId, Name = input.Name, Category = input.Category, Price = input.Price, Quantity = input.Quantity }, - AppJsonContext.Default.CrudWriteResponse, statusCode: 201); - } - - // PUT /crud/items/{id} — update item, invalidate cache - public static async Task CrudUpdate(int id, HttpRequest req, IMemoryCache cache) - { - if (AppData.PgDataSource is null) - return TypedResults.Problem("DB not available"); - - using var sr = new StreamReader(req.Body); - var body = await sr.ReadToEndAsync(); - var input = JsonSerializer.Deserialize(body, _crudJsonOpts); - if (input is null) - return TypedResults.BadRequest(); - - await using var cmd = AppData.PgDataSource.CreateCommand( - "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4"); - cmd.Parameters.AddWithValue(input.Name ?? "Updated"); - cmd.Parameters.AddWithValue(input.Price); - cmd.Parameters.AddWithValue(input.Quantity); - cmd.Parameters.AddWithValue(id); - - var affected = await cmd.ExecuteNonQueryAsync(); - if (affected == 0) return TypedResults.NotFound(); - - var cacheKey = $"crud:{id}"; - if (AppData.RedisDb is not null) - await AppData.RedisDb.KeyDeleteAsync(cacheKey); - else - cache.Remove(cacheKey); - return TypedResults.Json( - new CrudWriteResponse { Id = id, Name = input.Name, Price = input.Price, Quantity = input.Quantity }, - AppJsonContext.Default.CrudWriteResponse); - } -} - -record CrudItemInput(int Id, string? Name, string? Category, int Price, int Quantity); \ No newline at end of file diff --git a/frameworks/aspnet-minimal-iouring/Models.cs b/frameworks/aspnet-minimal-iouring/Models.cs deleted file mode 100644 index 3f12c8ea4..000000000 --- a/frameworks/aspnet-minimal-iouring/Models.cs +++ /dev/null @@ -1,62 +0,0 @@ -sealed record ResponseDto(IReadOnlyList Items, int Count); - - -sealed class DbResponseItemDto -{ - 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(); -} - -sealed class DatasetItem -{ - 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(); -} - -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; } -} - -sealed class RatingInfo -{ - public int Score { get; set; } - public int Count { get; set; } -} - -sealed class CrudListResponse -{ - public List Items { get; set; } = []; - public long Total { get; set; } - public int Page { get; set; } - public int Limit { get; set; } -} - -sealed class CrudWriteResponse -{ - 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; } -} diff --git a/frameworks/aspnet-minimal-iouring/Program.cs b/frameworks/aspnet-minimal-iouring/Program.cs deleted file mode 100644 index add2723ac..000000000 --- a/frameworks/aspnet-minimal-iouring/Program.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Security.Cryptography.X509Certificates; - -using Microsoft.AspNetCore.ResponseCompression; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Caching.Memory; - -// Turn on the io_uring socket engine with kernel-side SQPOLL before the -// runtime wires up System.Net.Sockets. This AppContext switch must be -// set before any Socket is created — doing it at the very top of Main -// guarantees ordering. -AppContext.SetSwitch("System.Net.Sockets.UseIoUringSqPoll", true); - -var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); -builder.Services.AddMemoryCache(); - -var certPath = Environment.GetEnvironmentVariable("TLS_CERT") ?? "/certs/server.crt"; -var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key"; -var hasCert = File.Exists(certPath) && File.Exists(keyPath); - -builder.WebHost.ConfigureKestrel(options => -{ - options.Limits.Http2.MaxStreamsPerConnection = 256; - options.Limits.Http2.InitialConnectionWindowSize = 2 * 1024 * 1024; - options.Limits.Http2.InitialStreamWindowSize = 1024 * 1024; - - options.ListenAnyIP(8080, lo => - { - lo.Protocols = HttpProtocols.Http1; - }); - - if (hasCert) - { - options.ListenAnyIP(8443, lo => - { - lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; - lo.UseHttps(X509Certificate2.CreateFromPemFile(certPath, keyPath)); - }); - - // HTTP/1.1-only TLS listener for the json-tls profile. Kestrel - // advertises http/1.1 via ALPN so HTTP/1.1-only clients (wrk) negotiate - // correctly and never upgrade to h2. - options.ListenAnyIP(8081, lo => - { - lo.Protocols = HttpProtocols.Http1; - lo.UseHttps(X509Certificate2.CreateFromPemFile(certPath, keyPath)); - }); - } -}); - -// Explicit provider registration — the custom benaadams/runtime io_uring -// branch is based on a .NET 11 snapshot that predates ZstandardCompressionOptions -// in System.IO.Compression. AddResponseCompression()'s default factory tries -// to instantiate all three providers (Brotli, Gzip, Zstandard) and throws -// TypeLoadException at startup when Zstandard's type is missing. Register -// only the two providers the json-comp profile exercises to avoid that. -builder.Services.AddResponseCompression(options => -{ - options.Providers.Add(); - options.Providers.Add(); -}); - -var app = builder.Build(); - -app.UseResponseCompression(); - -app.Use((ctx, next) => -{ - ctx.Response.Headers.Server = "aspnet-minimal-iouring"; - return next(); -}); - -AppData.Load(); - -// Report whether the custom io_uring runtime overlay is actually in effect, -// visible in `docker logs httparena-bench-aspnet-minimal-iouring` for sanity. -var ioUringEnv = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_NET_SOCKETS_IO_URING"); -Console.WriteLine($"[io_uring] DOTNET_SYSTEM_NET_SOCKETS_IO_URING={ioUringEnv ?? "(not set)"}"); -var socketAsm = typeof(System.Net.Sockets.Socket).Assembly; -var ioUringType = socketAsm.GetTypes() - .FirstOrDefault(t => t.Name.Contains("IOUring", StringComparison.OrdinalIgnoreCase)); -Console.WriteLine(ioUringType != null - ? $"[io_uring] Runtime type found: {ioUringType.FullName} — io_uring is ACTIVE" - : "[io_uring] No io_uring types in runtime — falling back to epoll"); - -app.MapGet("/pipeline", Handlers.Text); - -app.MapGet("/baseline11", Handlers.Sum); -app.MapPost("/baseline11", Handlers.SumBody); -app.MapGet("/baseline2", Handlers.Sum); - -app.MapPost("/upload", Handlers.Upload); -app.MapGet("/json/{count}", Handlers.Json); -app.MapGet("/async-db", Handlers.AsyncDatabase); - -// ── CRUD endpoints ───────────────────────────────────────────────────────── -// Realistic REST API: paginated list, cached single-item read, create, update. -// In-process IMemoryCache with 1s TTL on single-item reads, invalidated on PUT. - -app.MapGet("/crud/items", Handlers.CrudList); -app.MapGet("/crud/items/{id:int}", Handlers.CrudRead); -app.MapPost("/crud/items", Handlers.CrudCreate); -app.MapPut("/crud/items/{id:int}", Handlers.CrudUpdate); - -app.MapStaticAssets(); - -app.Run(); diff --git a/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj b/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj deleted file mode 100644 index a0df238dc..000000000 --- a/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net11.0 - enable - enable - true - - - - - - diff --git a/frameworks/aspnet-minimal-iouring/build.sh b/frameworks/aspnet-minimal-iouring/build.sh deleted file mode 100755 index 6462b022b..000000000 --- a/frameworks/aspnet-minimal-iouring/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" -docker build -t httparena-aspnet-minimal-iouring -f "$SCRIPT_DIR/Dockerfile" "$ROOT_DIR" diff --git a/frameworks/aspnet-minimal-iouring/meta.json b/frameworks/aspnet-minimal-iouring/meta.json deleted file mode 100644 index 425d9014d..000000000 --- a/frameworks/aspnet-minimal-iouring/meta.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "display_name": "aspnet-minimal-iouring", - "language": "C#", - "type": "flagship", - "mode": "tuned", - "engine": "io_uring", - "description": "ASP.NET Core minimal API with experimental io_uring socket engine (dotnet/runtime#124374). Replaces epoll with io_uring for socket I/O on Linux 6.1+.", - "repo": "https://github.com/benaadams/runtime/tree/io_uring", - "enabled": true, - "tests": [ - "baseline", - "pipelined", - "limited-conn", - "json", - "json-comp", - "json-tls", - "upload", - "api-4", - "api-16", - "static", - "async-db", - "crud", - "baseline-h2", - "static-h2", - "baseline-h3", - "static-h3" - ], - "maintainers": [] -} \ No newline at end of file diff --git a/frameworks/aspnet-minimal-iouring/patch-iouring.py b/frameworks/aspnet-minimal-iouring/patch-iouring.py deleted file mode 100644 index 6a20aa918..000000000 --- a/frameworks/aspnet-minimal-iouring/patch-iouring.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""Patch io_uring branch for build errors.""" - -# Patch 1: Fix csproj — add Intrinsics reference + suppress warnings -csproj = 'src/libraries/System.Net.Sockets/src/System.Net.Sockets.csproj' -with open(csproj) as f: - text = f.read() - -if 'System.Runtime.Intrinsics' not in text: - text = text.replace( - 'System.Runtime.InteropServices.csproj" />', - 'System.Runtime.InteropServices.csproj" />\n ', - 1) - print('Patched: added System.Runtime.Intrinsics reference') - -if 'IDE0059' not in text: - text = text.replace( - '', - ' $(NoWarn);IDE0059;CA1822;CA1823;CS0219\n ', - 1) - print('Patched: suppressed analyzer warnings') - -with open(csproj, 'w') as f: - f.write(text) - -# Patch 2: Fix CS0212 in SocketAsyncEngine.Linux.cs -# The 4 errors are all `&result` used in methods with `out int result` parameter. -# Fix: change `out int result` to a local variable pattern. -# Simple approach: find every line with `&result` and replace with `&_result`, -# then find the method bodies and add the local var + assignment. -src_file = 'src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Linux.cs' -with open(src_file) as f: - lines = f.readlines() - -# First pass: find all line numbers that contain '&result' (the problematic ones) -problem_lines = set() -for i, line in enumerate(lines): - if '&result)' in line or '&result ;' in line: - problem_lines.add(i) - -print(f'Found {len(problem_lines)} lines with &result') - -# Find the methods containing these lines by scanning backwards for 'out int result' -method_starts = {} # line_num -> list of problem lines in that method -for pl in problem_lines: - # Scan backwards to find 'out int result)' - for j in range(pl, max(pl - 30, 0), -1): - if 'out int result)' in lines[j]: - if j not in method_starts: - method_starts[j] = [] - method_starts[j].append(pl) - break - -print(f'Found {len(method_starts)} methods to patch') - -# For each method, find its opening brace, add local var, replace &result, add assignment -# Work backwards to preserve line numbers -for sig_line in sorted(method_starts.keys(), reverse=True): - # Find opening brace after signature - brace_line = None - for j in range(sig_line, min(sig_line + 10, len(lines))): - stripped = lines[j].strip() - if stripped == '{': - brace_line = j - break - if '{' in stripped and 'out int result)' not in stripped: - brace_line = j - break - - if brace_line is None: - print(f' WARNING: could not find opening brace for method at line {sig_line}') - continue - - # Find closing brace - depth = 0 - close_line = None - for j in range(brace_line, len(lines)): - depth += lines[j].count('{') - lines[j].count('}') - if depth == 0: - close_line = j - break - - if close_line is None: - print(f' WARNING: could not find closing brace for method at line {sig_line}') - continue - - # Get indentation - indent = ' ' * (len(lines[brace_line + 1]) - len(lines[brace_line + 1].lstrip())) - - # Replace &result with &_result in the method body - for j in range(brace_line + 1, close_line): - if '&result' in lines[j]: - lines[j] = lines[j].replace('&result', '&_result') - - # Add 'result = _result;' before each 'return err;' in the method - for j in range(close_line - 1, brace_line, -1): - if 'return err;' in lines[j]: - ret_indent = ' ' * (len(lines[j]) - len(lines[j].lstrip())) - lines.insert(j, f'{ret_indent}result = _result;\n') - - # Add local variable after opening brace - lines.insert(brace_line + 1, f'{indent}int _result = 0;\n') - - print(f' Patched method at line {sig_line + 1}') - -with open(src_file, 'w') as f: - f.writelines(lines) -print('Done patching SocketAsyncEngine') - -# Patch 3: Fix "Destination is too short" in FinishOperationAccept -# io_uring accept returns full sockaddr_storage (128 bytes) but the -# remoteSocketAddress buffer is sized for the specific address family. -# Fix: clamp copy length to destination size. -saea_file = 'src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEventArgs.Unix.cs' -with open(saea_file) as f: - saea_text = f.read() - -old_copy = 'new ReadOnlySpan(_acceptBuffer, 0, _acceptAddressBufferCount).CopyTo(remoteSocketAddress.Buffer.Span);' -new_copy = ('int _copyLen = Math.Min(_acceptAddressBufferCount, remoteSocketAddress.Buffer.Length);\n' - ' new ReadOnlySpan(_acceptBuffer, 0, _copyLen).CopyTo(remoteSocketAddress.Buffer.Span);') - -if old_copy in saea_text: - saea_text = saea_text.replace(old_copy, new_copy) - # Also fix the Size assignment to use clamped length - saea_text = saea_text.replace( - 'remoteSocketAddress.Size = _acceptAddressBufferCount;', - 'remoteSocketAddress.Size = _copyLen;') - with open(saea_file, 'w') as f: - f.write(saea_text) - print('Patched: FinishOperationAccept buffer overflow fix') -else: - print('WARNING: could not find FinishOperationAccept CopyTo pattern') diff --git a/frameworks/bananabread/.dockerignore b/frameworks/bananabread/.dockerignore deleted file mode 100644 index f9b345977..000000000 --- a/frameworks/bananabread/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -seagreen/ -node_modules/ -bun.lockb diff --git a/frameworks/bananabread/.gitignore b/frameworks/bananabread/.gitignore deleted file mode 100644 index 5fc766605..000000000 --- a/frameworks/bananabread/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -seagreen/ -node_modules/ -bun.lockb -.DS_Store diff --git a/frameworks/bananabread/Dockerfile b/frameworks/bananabread/Dockerfile deleted file mode 100644 index 7bf70c21f..000000000 --- a/frameworks/bananabread/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# The oven/bun x64 images ship the "baseline" (non-AVX2) Bun build for CPU portability. In Bun -# 1.3.x that baseline build has a code-generation bug that miscompiles Seagreen's layout router: -# request dispatch for routes past roughly the fifth entry in a layout 404s even though the -# handlers are registered (/async-db and the deeper /crud routes break). The AVX2 build — the -# flavor `bun.sh/install` selects on an AVX2 CPU, and the one that runs correctly on the host — -# routes them correctly and is faster besides. So we take the Debian (glibc) image for its -# tooling and replace bun with the AVX2 build. glibc, not alpine/musl, because the prebuilt -# AVX2 binary the installer fetches here is glibc-linked. -FROM oven/bun:1 -RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates curl unzip \ - && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" \ - && cp -f /root/.bun/bin/bun "$(command -v bun)" \ - && rm -rf /root/.bun \ - && echo "bun build flavor: $(bun -e 'throw 0' 2>&1 | grep -o 'Linux x64[^)]*')" -WORKDIR /app - -# App deps (reflect-metadata powers the decorator-based webservice routing). -COPY package.json ./ -RUN bun install - -COPY tsconfig.json ./ -COPY src ./src - -# Seagreen (the TypeScript GenHTTP port) is consumed by relative import from ./seagreen. -# Cloned at build time from its public repo; bare imports (reflect-metadata) resolve from /app. -RUN git clone --depth 1 https://github.com/dotnet-web-stack/Seagreen.git seagreen - -EXPOSE 8080 -# Bun runs the TypeScript directly (no build step); main.ts forks one worker per core. -ENTRYPOINT ["bun", "run", "src/main.ts"] diff --git a/frameworks/bananabread/README.md b/frameworks/bananabread/README.md deleted file mode 100644 index 97ac431d4..000000000 --- a/frameworks/bananabread/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# bananabread - -A HttpArena entry for **Seagreen** — a TypeScript port of [GenHTTP](https://github.com/Kaliumhexacyanoferrat/GenHTTP) -on the [Bun](https://bun.sh) runtime (the TypeScript sibling of the Kotlin port, `fishcake`/CodeGreen). -It runs GenHTTP's own internal HTTP/1.1 engine, built on `Bun.listen` raw TCP (not `Bun.serve`). - -## Stack - -- **Language:** TypeScript / Bun -- **Framework:** Seagreen (TypeScript port of GenHTTP) -- **Engine:** Seagreen internal engine (raw TCP via `Bun.listen`) -- **Database:** Bun's built-in SQL client (`Bun.SQL`) for PostgreSQL -- **Multi-core:** one worker process per core, sharing the port via `SO_REUSEPORT` -- **Build:** none — Bun runs the TypeScript directly; Seagreen is cloned at image build time - -## Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/pipeline` | GET | Returns `ok` | -| `/baseline11` | GET / POST | Sums `a` + `b` (POST adds a body value) | -| `/baseline2` | GET | Sums `a` + `b` | -| `/json/{count}` | GET | Processes `count` dataset items; `?m=` scales the total (default 1) | -| `/upload` | POST | Streams the body, returns the byte count | -| `/async-db` | GET | `price BETWEEN min AND max` range query | -| `/crud/items` | GET | Paged listing by category | -| `/crud/items/{id}` | GET | Cached read (`X-Cache: HIT\|MISS`) | -| `/crud/items` | POST | Upsert → `201 Created` | -| `/crud/items/{id}` | PUT | Update, `404` when unknown | - -Declared profiles (`meta.json`): `baseline`, `pipelined`, `limited-conn`, `json`, `upload`, -`async-db`, `crud`, `api-4`, `api-16`. TLS/HTTP-2, compression, static files, websockets and -gRPC are omitted (those Seagreen modules are not ported yet). - -## Notes - -- The single-item CRUD read uses an in-process 200 ms TTL cache (mirrors GenHTTP's `MemoryCache`). -- `DATABASE_URL`, `DATASET_PATH` follow the standard HttpArena contract; without `DATABASE_URL` - the database-backed endpoints degrade gracefully (empty results / `404`). -- The image clones Seagreen from `main` (public repo `dotnet-web-stack/Seagreen`), so that branch - must contain the current engine (including the `workers()` API and the multi-worker spawn fix). diff --git a/frameworks/bananabread/bun.lock b/frameworks/bananabread/bun.lock deleted file mode 100644 index 8dd8e31b8..000000000 --- a/frameworks/bananabread/bun.lock +++ /dev/null @@ -1,18 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "bananabread", - "dependencies": { - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0", - }, - }, - }, - "packages": { - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - } -} diff --git a/frameworks/bananabread/meta.json b/frameworks/bananabread/meta.json deleted file mode 100644 index e0a536103..000000000 --- a/frameworks/bananabread/meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "display_name": "bananabread", - "language": "TypeScript", - "type": "emerging", - "mode": "standard", - "engine": "seagreen", - "description": "Seagreen — a TypeScript port of GenHTTP — on Bun's raw-TCP engine, with kotlinx-style decorators and Bun's built-in SQL.", - "repo": "https://github.com/dotnet-web-stack/Seagreen", - "enabled": true, - "tests": [ - "baseline", - "pipelined", - "limited-conn", - "json", - "upload", - "async-db", - "crud", - "api-4", - "api-16" - ], - "maintainers": [] -} diff --git a/frameworks/bananabread/package.json b/frameworks/bananabread/package.json deleted file mode 100644 index 356a59e13..000000000 --- a/frameworks/bananabread/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bananabread", - "private": true, - "type": "module", - "description": "HttpArena entry: Seagreen (TypeScript GenHTTP port) on Bun.", - "dependencies": { - "reflect-metadata": "^0.2.2", - "zod": "^3.24.0" - } -} diff --git a/frameworks/bananabread/seagreen b/frameworks/bananabread/seagreen deleted file mode 120000 index 92efc2840..000000000 --- a/frameworks/bananabread/seagreen +++ /dev/null @@ -1 +0,0 @@ -/home/diogo/Desktop/Socket/Seagreen \ No newline at end of file diff --git a/frameworks/bananabread/src/data.ts b/frameworks/bananabread/src/data.ts deleted file mode 100644 index 37cd8c74f..000000000 --- a/frameworks/bananabread/src/data.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Data layer: the `/json` dataset (from DATASET_PATH) and PostgreSQL access via Bun's built-in - * SQL client (Bun.SQL). When DATABASE_URL is unset, the DB queries return empty so the - * database-backed endpoints degrade gracefully. - */ -import { SQL } from "bun"; -import type { CrudCreateRequest, CrudUpdateRequest, DatasetItem, DbItem } from "./model.ts"; - -const datasetFile = Bun.file(process.env.DATASET_PATH ?? "/data/dataset.json"); -export const dataset: DatasetItem[] = (await datasetFile.exists()) ? ((await datasetFile.json()) as DatasetItem[]) : []; - -// One Bun.SQL pool lives per worker process (Seagreen forks one worker per core), and the pools -// must collectively stay under PostgreSQL's max_connections (the harness caps it at 256). The -// default per-pool size (~10) × ~64 workers ≈ 640 connections blows past that, and the overflow -// fails to open under load — surfacing as 5xx (and reconnect storms that wreck throughput). Size -// each worker's pool so workers × poolMax stays well under 256 (~200, leaving headroom for -// reserved/health connections); this matches the worker count main.ts derives. -const workerCount = Number(process.env.WORKERS) || navigator.hardwareConcurrency || 1; -const poolMax = Math.max(1, Math.floor(200 / workerCount)); -const sql = process.env.DATABASE_URL ? new SQL(process.env.DATABASE_URL, { max: poolMax }) : null; -export const dbAvailable = sql !== null; - -// biome-ignore lint/suspicious/noExplicitAny: Bun.SQL rows are untyped records -function toDbItem(r: any): DbItem { - return { - id: r.id, - name: r.name, - category: r.category, - price: r.price, - quantity: r.quantity, - active: r.active, - tags: r.tags ?? [], - rating: { score: r.rating_score, count: r.rating_count }, - }; -} - -export async function rangeByPrice(min: number, max: number, limit: number): Promise { - if (!sql) return []; - const rows = await sql`SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count - FROM items WHERE price BETWEEN ${min} AND ${max} LIMIT ${limit}`; - return rows.map(toDbItem); -} - -export async function listByCategory(category: string, limit: number, offset: number): Promise { - if (!sql) return []; - const rows = await sql`SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count - FROM items WHERE category = ${category} ORDER BY id LIMIT ${limit} OFFSET ${offset}`; - return rows.map(toDbItem); -} - -export async function findById(id: number): Promise { - if (!sql) return null; - const rows = await sql`SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count - FROM items WHERE id = ${id} LIMIT 1`; - return rows.length ? toDbItem(rows[0]) : null; -} - -export async function upsert(req: CrudCreateRequest): Promise { - if (!sql) return; - const tags = JSON.stringify(req.tags ?? ["bench"]); - await sql`INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) - VALUES (${req.id}, ${req.name}, ${req.category}, ${req.price}, ${req.quantity}, ${req.active ?? true}, ${tags}::jsonb, 0, 0) - ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, - price = EXCLUDED.price, quantity = EXCLUDED.quantity, active = EXCLUDED.active, tags = EXCLUDED.tags`; -} - -export async function update(id: number, req: CrudUpdateRequest): Promise { - if (!sql) return false; - const rows = await sql`UPDATE items SET - name = COALESCE(${req.name ?? null}, name), - price = COALESCE(${req.price ?? null}, price), - quantity = COALESCE(${req.quantity ?? null}, quantity) - WHERE id = ${id} RETURNING id`; - return rows.length > 0; -} diff --git a/frameworks/bananabread/src/main.ts b/frameworks/bananabread/src/main.ts deleted file mode 100644 index 670f22dab..000000000 --- a/frameworks/bananabread/src/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Host } from "../seagreen/src/engine/internal/index.ts"; -import { dbAvailable } from "./data.ts"; -import { createApp } from "./project.ts"; - -const port = Number(process.env.PORT ?? 8080); -const workers = Number(process.env.WORKERS) || navigator.hardwareConcurrency || 1; - -if (!process.env.SEAGREEN_WORKER) { - console.log(`bananabread (Seagreen) → :${port}, ${workers} workers, database: ${dbAvailable ? "connected" : "disabled"}`); -} - -await Host.create().handler(createApp()).port(port).workers(workers).run(); diff --git a/frameworks/bananabread/src/model.ts b/frameworks/bananabread/src/model.ts deleted file mode 100644 index e5063b5a1..000000000 --- a/frameworks/bananabread/src/model.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface RatingInfo { - score: number; - count: number; -} - -export interface DatasetItem { - id: number; - name: string; - category: string; - price: number; - quantity: number; - active: boolean; - tags: string[]; - rating: RatingInfo; -} - -export interface ProcessedItem extends DatasetItem { - total: number; -} - -export interface DbItem { - id: number; - name: string; - category: string; - price: number; - quantity: number; - active: boolean; - tags: string[]; - rating: RatingInfo; -} - -export interface CrudCreateRequest { - id: number; - name: string; - category: string; - price: number; - quantity: number; - active?: boolean; - tags?: string[]; -} - -export interface CrudUpdateRequest { - name?: string; - price?: number; - quantity?: number; -} - -/** Serialized as `{ "items": [...], "count": N }`. */ -export class ListWithCount { - readonly count: number; - constructor(readonly items: T[]) { - this.count = items.length; - } -} diff --git a/frameworks/bananabread/src/project.ts b/frameworks/bananabread/src/project.ts deleted file mode 100644 index cbbf92ee2..000000000 --- a/frameworks/bananabread/src/project.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Content, Resource } from "../seagreen/src/modules/io/index.ts"; -import { Layout, type LayoutBuilder } from "../seagreen/src/modules/layouting/index.ts"; -import { Service } from "../seagreen/src/modules/webservices/index.ts"; -import { AsyncDatabase, Baseline, Crud, JsonService, Upload } from "./services.ts"; - -export function createApp(): LayoutBuilder { - return Layout.create() - .add("pipeline", Content.from(Resource.fromString("ok"))) - .add("baseline11", Service.from(Baseline)) - .add("baseline2", Service.from(Baseline)) - .add("upload", Service.from(Upload)) - .add("json", Service.from(JsonService)) - .add("async-db", Service.from(AsyncDatabase)) - .add("crud", Layout.create().add("items", Service.from(Crud))); -} diff --git a/frameworks/bananabread/src/services.ts b/frameworks/bananabread/src/services.ts deleted file mode 100644 index 8b5526acf..000000000 --- a/frameworks/bananabread/src/services.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Webservices for the HttpArena workloads, on Seagreen's decorator-based reflection stack. - */ -import { RedisClient } from "bun"; -import { - ContentType, - ProviderException, - type Request, - type RequestBody, - type Response, - ResponseStatus, -} from "../seagreen/src/api/index.ts"; -import { StringContent } from "../seagreen/src/modules/io/index.ts"; -import { - FromBody, - FromContent, - FromPath, - FromQuery, - FromStream, - Inject, - ResourceMethod, - Result, -} from "../seagreen/src/modules/webservices/index.ts"; -import * as Data from "./data.ts"; -import { type CrudCreateRequest, type CrudUpdateRequest, type DbItem, ListWithCount, type ProcessedItem } from "./model.ts"; - -/** - * Single-item read cache for the cache-aside workload. The validation harness fires its two - * cache probes on separate connections, which `SO_REUSEPORT` may route to different worker - * processes — so a per-process map would report MISS twice. When REDIS_URL is provided the cache - * is backed by Redis (Bun's built-in client) and therefore shared across workers; otherwise it - * falls back to an in-process TTL map (correct for a single worker). - */ -interface ItemCache { - get(id: number): Promise; - set(id: number, body: string): Promise; - invalidate(id: number): Promise; -} - -class InProcessCache implements ItemCache { - private readonly map = new Map(); - constructor(private readonly ttlMs: number) {} - async get(id: number): Promise { - const e = this.map.get(id); - if (!e) return undefined; - if (e.expiresAt <= performance.now()) { - this.map.delete(id); - return undefined; - } - return e.body; - } - async set(id: number, body: string): Promise { - this.map.set(id, { body, expiresAt: performance.now() + this.ttlMs }); - } - async invalidate(id: number): Promise { - this.map.delete(id); - } -} - -class RedisCache implements ItemCache { - private readonly client: RedisClient; - constructor( - url: string, - private readonly ttlMs: number, - ) { - this.client = new RedisClient(url); - } - private key(id: number): string { - return `crud:item:${id}`; - } - async get(id: number): Promise { - return (await this.client.get(this.key(id))) ?? undefined; - } - async set(id: number, body: string): Promise { - await this.client.send("SET", [this.key(id), body, "PX", String(this.ttlMs)]); - } - async invalidate(id: number): Promise { - await this.client.del(this.key(id)); - } -} - -const CACHE_TTL_MS = 1000; -const cache: ItemCache = process.env.REDIS_URL - ? new RedisCache(process.env.REDIS_URL, CACHE_TTL_MS) - : new InProcessCache(CACHE_TTL_MS); - -export class Baseline { - @ResourceMethod("GET") - sum(@FromQuery("a") a: number, @FromQuery("b") b: number): number { - return a + b; - } - - @ResourceMethod("POST") - sumBody(@FromQuery("a") a: number, @FromQuery("b") b: number, @FromBody() c: number): number { - return a + b + c; - } -} - -export class Upload { - @ResourceMethod("POST") - async compute(@FromStream() body: RequestBody): Promise { - let total = 0; - for await (const chunk of body.chunks()) total += chunk.length; - return total; - } -} - -export class JsonService { - @ResourceMethod("GET", ":count") - compute(@FromPath("count") count: number, @FromQuery("m") m = 1): ListWithCount { - if (Data.dataset.length === 0) throw new ProviderException(ResponseStatus.InternalServerError, "No dataset"); - const take = Math.max(0, Math.min(count, Data.dataset.length)); - const items = Data.dataset.slice(0, take).map((d) => ({ ...d, total: d.price * d.quantity * m })); - return new ListWithCount(items); - } -} - -export class AsyncDatabase { - @ResourceMethod("GET") - async compute(@FromQuery("min") min = 10, @FromQuery("max") max = 50, @FromQuery("limit") limit = 50): Promise> { - return new ListWithCount(await Data.rangeByPrice(min, max, Math.min(50, Math.max(1, limit)))); - } -} - -export class Crud { - @ResourceMethod("GET") - async list(@FromQuery("category") category = "electronics", @FromQuery("page") page = 1, @FromQuery("limit") limit = 10) { - const p = Math.max(1, page); - const l = Math.min(50, Math.max(1, limit)); - const items = await Data.listByCategory(category, l, (p - 1) * l); - return { items, total: items.length, page: p, limit: l }; - } - - @ResourceMethod("GET", ":id") - async get(@FromPath("id") id: number, @Inject() request: Request): Promise { - const cached = await cache.get(id); - if (cached !== undefined) { - return request.respond().content(new StringContent(cached, ContentType.ApplicationJson)).header("X-Cache", "HIT").build(); - } - const item = await Data.findById(id); - if (!item) throw new ProviderException(ResponseStatus.NotFound, `Item with ID ${id} does not exist`); - const json = JSON.stringify(item); - await cache.set(id, json); - return request.respond().content(new StringContent(json, ContentType.ApplicationJson)).header("X-Cache", "MISS").build(); - } - - @ResourceMethod("POST") - async create(@FromContent() item: CrudCreateRequest): Promise> { - await Data.upsert(item); - await cache.invalidate(item.id); - const created: DbItem = { - id: item.id, - name: item.name, - category: item.category, - price: item.price, - quantity: item.quantity, - active: item.active ?? true, - tags: item.tags ?? ["bench"], - rating: { score: 0, count: 0 }, - }; - return new Result(created).status(ResponseStatus.Created); - } - - @ResourceMethod("PUT", ":id") - async update(@FromPath("id") id: number, @FromContent() item: CrudUpdateRequest): Promise { - const ok = await Data.update(id, item); - await cache.invalidate(id); - if (!ok) throw new ProviderException(ResponseStatus.NotFound, `Item with ID ${id} does not exist`); - const updated = await Data.findById(id); - if (!updated) throw new ProviderException(ResponseStatus.NotFound, `Item with ID ${id} does not exist`); - return updated; - } -} diff --git a/frameworks/bananabread/tsconfig.json b/frameworks/bananabread/tsconfig.json deleted file mode 100644 index c6eec70ba..000000000 --- a/frameworks/bananabread/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "types": ["bun"], - "strict": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "skipLibCheck": true, - "noEmit": true - } -} diff --git a/scripts/lib/validate/assert.sh b/scripts/lib/validate/assert.sh new file mode 100644 index 000000000..2d707d9f7 --- /dev/null +++ b/scripts/lib/validate/assert.sh @@ -0,0 +1,331 @@ +# scripts/lib/validate/assert.sh — DOCS_BASE, check helpers, wait_h2, static-freshness probe +# Part of the validate.sh suite — sourced by scripts/validate.sh, not run directly. + +# ───── Helpers ───── + +DOCS_BASE="https://www.http-arena.com/#doc=test-profiles" + +fail_with_link() { + local msg="$1" + local docs_url="$2" + echo " FAIL $msg" + if [ -n "$docs_url" ]; then + echo " → $docs_url" + fi + FAIL=$((FAIL + 1)) +} + +dump_debug() { + local trace="$1" + local response="$2" + if [ -n "$trace" ] && [ -s "$trace" ]; then + echo " ─── wire trace ───" + sed 's/^/ /' "$trace" + fi + if [ -n "$response" ]; then + echo " ─── response ───" + printf '%s\n' "$response" | sed 's/^/ /' + fi + [ -n "$trace" ] && rm -f "$trace" +} + +check() { + local label="$1" + local expected_body="$2" + local docs_url="$3" + shift 3 + local response trace + trace=$(mktemp) + response=$(curl -s --max-time 30 -D- --trace-ascii "$trace" "$@" || true) + local body + body=$(echo "$response" | tail -1) + + if [ "$body" = "$expected_body" ]; then + echo " PASS [$label]" + PASS=$((PASS + 1)) + rm -f "$trace" + else + fail_with_link "[$label]: expected body '$expected_body', got '$body'" "$docs_url" + dump_debug "$trace" "$response" + fi +} + +check_status() { + local label="$1" + local expected_status="$2" + local docs_url="$3" + shift 3 + local http_code trace body_file + trace=$(mktemp) + body_file=$(mktemp) + http_code=$(curl -s --max-time 30 -o "$body_file" -D "$body_file.hdr" -w '%{http_code}' --trace-ascii "$trace" "$@" || true) + + if [ "$http_code" = "$expected_status" ]; then + echo " PASS [$label] (HTTP $http_code)" + PASS=$((PASS + 1)) + rm -f "$trace" "$body_file" "$body_file.hdr" + else + fail_with_link "[$label]: expected HTTP $expected_status, got HTTP $http_code" "$docs_url" + local response="" + [ -s "$body_file.hdr" ] && response=$(cat "$body_file.hdr") + [ -s "$body_file" ] && response="${response}$(cat "$body_file")" + dump_debug "$trace" "$response" + rm -f "$body_file" "$body_file.hdr" + fi +} + +check_fragmented() { + # Send an HTTP request in multiple TCP writes with small pauses between + # them so the server's read loop sees partial, incomplete buffers and + # must reassemble across recv() calls. Exercises HTTP parser correctness + # under realistic network fragmentation (slow clients, small MTU, etc.). + # + # Usage: check_fragmented