Skip to content
Merged
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
21 changes: 21 additions & 0 deletions frameworks/aspnet-minimal-satorigc/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /app
COPY frameworks/aspnet-minimal-satorigc/ .
RUN dotnet publish -c Release -r linux-x64 --self-contained -o out

FROM mcr.microsoft.com/dotnet/aspnet:10.0


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/*

WORKDIR /app
COPY --from=build /app/out .
EXPOSE 8080 8081 8082 8443/tcp 8443/udp

ENTRYPOINT ["dotnet", "aspnet-minimal-satoriGC.dll"]
136 changes: 136 additions & 0 deletions frameworks/aspnet-minimal-satorigc/Handlers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@

using HttpArena.Services;
using HttpArena.Types;
using System.Buffers;

static class Handlers
{

public static string Sum(int a, int b) => (a + b).ToString();

public static async ValueTask<string> 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";

// GET /delay/{ms} - answer after ms milliseconds. Task.Delay registers a
// timer and yields; the thread goes back to the pool instead of sitting on
// the request, so the number of waits in flight is bounded by memory rather
// than by the pool size.
public static async ValueTask<string> Delay(int ms)
{
if (ms > 0) await Task.Delay(ms);
return ms.ToString();
}

// Echo: the bytes that arrived go back unchanged.
//
// When the request declares its length - which it does on this profile - the
// body is read once into a pooled buffer of exactly that size and written
// once. The obvious `new MemoryStream()` costs twice over: it doubles as it
// grows, so reaching 100 KB takes ~10 allocations and a full recopy, and its
// final buffer lands above the 85,000-byte Large Object Heap threshold, which
// is collected as gen2 and not compacted. ArrayPool hands back the same
// buffer instead of allocating one per request. Measured at -12% CPU on the
// 8Gbit profile, with a tighter p99.
//
// A chunked request carries no length to size against, so it keeps the
// collect-then-write path - which is also what makes that case correct.
public static async Task EchoBody(HttpContext ctx)
{
ctx.Response.ContentType = "application/octet-stream";

if (ctx.Request.ContentLength is long cl && cl > 0)
{
int len = (int)cl;
byte[] buf = ArrayPool<byte>.Shared.Rent(len);
try
{
await ctx.Request.Body.ReadExactlyAsync(buf.AsMemory(0, len));
ctx.Response.ContentLength = len;
await ctx.Response.Body.WriteAsync(buf.AsMemory(0, len));
}
finally
{
ArrayPool<byte>.Shared.Return(buf);
}
return;
}

using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
var body = ms.GetBuffer().AsMemory(0, (int)ms.Length);
ctx.Response.ContentLength = body.Length;
await ctx.Response.Body.WriteAsync(body);
}

public static IResult Json(int count, DatasetService dataset, int m = 1)
{
var response = dataset.GetItems(count, m);

if (response is null)
return TypedResults.Problem("Dataset not loaded");

return TypedResults.Ok(response);
}

public static async Task<IResult> AsyncDatabase(ItemService items, double min = 10, double max = 50, int limit = 50)
{
if (!items.IsAvailable)
return TypedResults.Problem("DB not available");

var response = await items.QueryAsync(min, max, limit);

return TypedResults.Ok(response);
}

public static async Task<IResult> CrudList(ItemService items, string? category = null, int page = 0, int limit = 0)
{
if (!items.IsAvailable)
return TypedResults.Problem("DB not available");

var response = await items.ListAsync(category, page, limit);

return TypedResults.Ok(response);
}

public static async Task<IResult> CrudRead(int id, ItemService items, HttpContext ctx)
{
if (!items.IsAvailable)
return TypedResults.Problem("DB not available");

var result = await items.ReadAsync(id);
if (result is null) return TypedResults.NotFound();

ctx.Response.Headers["X-Cache"] = result.CacheHit ? "HIT" : "MISS";

return result.Json is not null
? Results.Content(result.Json, "application/json")
: TypedResults.Ok(result.Item!);
}

public static async Task<IResult> CrudCreate(CrudItemInput input, ItemService items)
{
if (!items.IsAvailable)
return TypedResults.Problem("DB not available");

var created = await items.CreateAsync(input);

return TypedResults.Created((string?)null, created);
}

public static async Task<IResult> CrudUpdate(int id, CrudItemInput input, ItemService items)
{
if (!items.IsAvailable)
return TypedResults.Problem("DB not available");

var updated = await items.UpdateAsync(id, input);
if (updated is null) return TypedResults.NotFound();

return TypedResults.Ok(updated);
}

}
16 changes: 16 additions & 0 deletions frameworks/aspnet-minimal-satorigc/Pages/Fortunes.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@page "/fortunes"
@model FortunesModel
@{ Layout = null; }
<!DOCTYPE html>
<html>
<head><title>Fortunes</title></head>
<body>
<table>
<tr><th>id</th><th>message</th></tr>
@foreach (var f in Model.Fortunes)
{
<tr><td>@f.Id</td><td>@f.Message</td></tr>
}
</table>
</body>
</html>
19 changes: 19 additions & 0 deletions frameworks/aspnet-minimal-satorigc/Pages/Fortunes.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using HttpArena.Services;
using HttpArena.Types;

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public sealed class FortunesModel(FortuneService fortuneService) : PageModel
{
public List<Fortune> Fortunes { get; private set; } = [];

public async Task<IActionResult> OnGetAsync()
{
if (!fortuneService.IsAvailable)
return new StatusCodeResult(500);

Fortunes = await fortuneService.GetFortunesAsync();
return Page();
}
}
157 changes: 157 additions & 0 deletions frameworks/aspnet-minimal-satorigc/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using HttpArena;
using HttpArena.Services;
using HttpArena.Types;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using System.Security.Cryptography.X509Certificates;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddRazorPages();

builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddSingleton<DatasetService>();
builder.Services.AddSingleton<ItemService>();
builder.Services.AddSingleton<FortuneService>();

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);

// The opt-in tls_check gets its own listener on :9000 and its own pair at
// /certs-tls. It rotates certificates under a running server, and pointing it
// at /certs would move the ground under json-tls, static-tls and the h2
// profiles in the same validation run.
var tlsCheckCert = "/certs-tls/server.crt";
var tlsCheckKey = "/certs-tls/server.key";
var hasTlsCheck = File.Exists(tlsCheckCert) && File.Exists(tlsCheckKey);

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;
});

// h2c prior-knowledge listener for the baseline-h2c / json-h2c profiles.
// Protocols = Http2 with no UseHttps() gives Kestrel cleartext HTTP/2
// from the first byte. Clients that try HTTP/1.1 on this port get
// rejected, which is what validate.sh's h2c anti-cheat requires.
options.ListenAnyIP(8082, lo =>
{
lo.Protocols = HttpProtocols.Http2;
});

if (hasCert)
{
// Re-read when the files change, so a rotation lands without a restart.
// The selector below runs per handshake, which is what makes that
// visible to the next connection rather than the next process.
var cert = new RotatingCertificate(certPath, keyPath);

options.ListenAnyIP(8443, lo =>
{
lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => cert.Current);
});

// 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(https => https.ServerCertificateSelector = (_, _) => cert.Current);
});
}

if (hasTlsCheck)
{
// Same rotating handle, a different pair. The selector runs per
// handshake, which is what lets a replaced file reach the next
// connection instead of the next process.
var checkCert = new RotatingCertificate(tlsCheckCert, tlsCheckKey);

options.ListenAnyIP(9000, lo =>
{
lo.Protocols = HttpProtocols.Http1;
lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => checkCert.Current);
});
}
});

builder.Services.AddResponseCompression();

var app = builder.Build();

app.UseResponseCompression();

app.Use((ctx, next) =>
{
ctx.Response.Headers.Server = "aspnet-minimal";
return next();
});

// Load the dataset and open the Postgres/Redis connections at startup
// instead of on the first request.
_ = app.Services.GetRequiredService<DatasetService>();
_ = app.Services.GetRequiredService<ItemService>();
_ = app.Services.GetRequiredService<FortuneService>();

app.MapGet("/pipeline", Handlers.Text);

app.MapGet("/baseline11", Handlers.Sum);
app.MapPost("/baseline11", Handlers.SumBody);
app.MapGet("/baseline2", Handlers.Sum);

app.MapPost("/echo", Handlers.EchoBody);
app.MapGet("/delay/{ms:int}", Handlers.Delay);
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.
// Cache-aside single-item reads (Redis or in-process), 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);

// /fortunes is served by the Razor page at Pages/Fortunes.cshtml
// (route "/fortunes" declared via the page's @page directive). MapRazorPages
// wires up the MVC/Razor pipeline so the page model can render Razor markup
// — the standard ASP.NET production path for HTML responses.
app.MapRazorPages();

// Served straight out of the directory the profile mounts, rather than a copy
// taken at image build. MapStaticAssets, which this used before, resolves assets
// through a manifest generated at compile time from wwwroot: the container ended
// up holding two copies of the corpus and answering from the one the harness
// cannot touch, so a file replaced on disk was never reflected in a response.
//
// UseStaticFiles reads the file per request through the file provider, so what is
// served follows the mounted directory. Compression is still ASP.NET's own
// response compression middleware, configured above.
var staticContentTypes = new FileExtensionContentTypeProvider();
staticContentTypes.Mappings[".webp"] = "image/webp";
staticContentTypes.Mappings[".woff2"] = "font/woff2";

app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider("/data/static"),
RequestPath = "/static",
ContentTypeProvider = staticContentTypes,
ServeUnknownFileTypes = false
});


app.Run();
40 changes: 40 additions & 0 deletions frameworks/aspnet-minimal-satorigc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# aspnet-minimal-satorigc

Minimal ASP.NET Core HTTP server using .NET 10 with Kestrel, minimal API routing and Satori GC.

## Stack

- **Language:** C# / .NET 10
- **Framework:** ASP.NET Core Minimal APIs
- **Engine:** Kestrel
- **Build:** Framework-dependent publish, `mcr.microsoft.com/dotnet/aspnet:10.0` runtime (Debian 12) with `libmsquic` installed for HTTP/3

## Endpoints

| Endpoint | Method | Description |
| ------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| `/pipeline` | GET | Returns `ok` (plain text) |
| `/baseline11` | GET | Sums query parameter values |
| `/baseline11` | POST | Sums query parameters + request body |
| `/baseline2` | GET | Sums query parameter values (HTTP/2 variant) |
| `/json/{count}` | GET | Returns `count` items from the preloaded dataset; honors `Accept-Encoding: gzip/br/deflate` for the `json-comp` profile |
| `/async-db` | GET | Postgres range query: `SELECT ... WHERE price BETWEEN $min AND $max LIMIT $limit` |
| `/echo` | POST | Returns the request body back verbatim |
| `/crud/items` | GET | Paginated list by category with two queries (data + count) |
| `/crud/items/{id}` | GET | Single item read with `IMemoryCache` (1s TTL), returns `X-Cache: HIT/MISS` |
| `/crud/items` | POST | Create item via INSERT with ON CONFLICT upsert, returns 201 |
| `/crud/items/{id}` | PUT | Update item and invalidate cache entry |
| `/static/*` | GET | Serves files from `/data/static` via `MapStaticAssets` with precomputed ETags + compression |

## Notes

- HTTP/1.1 on port 8080, HTTP/1+2+3 on port 8443 (TCP **and** UDP for QUIC), h1+TLS on port 8081 (`json-tls` profile)
- HTTP/3 via MsQuic (`libmsquic` installed in the runtime image); Kestrel advertises h3 through the default Alt-Svc header so clients upgrade from h2
- TLS certs loaded from `$TLS_CERT` / `$TLS_KEY` (default `/certs/server.crt` + `/certs/server.key`)
- Logging disabled (`ClearProviders()`) for throughput; `Server: aspnet-minimal` header set via a lightweight middleware
- `AddResponseCompression()` + `UseResponseCompression()` drives `/json/{count}` gzip encoding for the `json-comp` profile
- HTTP/2 tuned: 256 max streams per connection, 2 MB initial connection window, 1 MB stream window
- `/echo` reads the request body into a 64 KB pooled buffer (`ArrayPool<byte>.Shared`) and returns those bytes back unchanged — no full-body allocation
- JSON responses use source-generated `JsonSerializerContext` (`AppJsonContext`) so the hot path avoids reflection
- Postgres pooled via `Npgsql.NpgsqlDataSource` built once at startup from `DATABASE_URL`
- Source split: `Program.cs` (startup + Kestrel + routes), `Handlers.cs` (HTTP adapters), `Services/` (framework-agnostic application logic: dataset, Postgres/Redis connections, item queries, fortunes), `Types/` (DTOs + JSON serializer context). `Services/` and `Types/` have no ASP.NET dependencies and can be copied as-is into other framework integrations.
Loading