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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>Playground.Http3.ManagedStreamed</RootNamespace>
<AssemblyName>Playground.Http3.ManagedStreamed</AssemblyName>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../Shared/Playground.Shared.csproj" />
<ProjectReference Include="../../../src/ioxide/ioxide.csproj" />
<ProjectReference Include="../../../src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj" />
<ProjectReference Include="../../../src/protocols/ioxide.http3/ioxide.http3.csproj" />
</ItemGroup>

</Project>
166 changes: 166 additions & 0 deletions Playground/Http3/ManagedStreamed/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Text;
using ioxide;
using ioxide.http3;
using ioxide.ngtcp2;
using Playground.Shared;

// ─────────────────────────────────────────────────────────────────────────────────────────────
// http3-managed-streamed - HTTP/3 in pure C# with BOTH directions streamed.
//
// The request body arrives through Http3Request.BodyReader, pulled a chunk at a time under
// flow control, so a large upload is never held whole. The response body goes out through an
// Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole.
//
// "/echo" runs both at once - read a chunk, write a chunk - which is what a proxy does and the
// case that shows the two halves are independent. Memory stays flat regardless of size, because
// each side blocks the other: ReadAsync waits on the peer, FlushAsync waits on the connection.
//
// Because ioxide.http3 owns the framing, sending is a push: build [0x00][varint len][payload]
// and hand it to the QUIC stream. There is no data-reader callback to answer and nothing to
// defer - which is the difference from the nghttp3 version of this in Playground/Http3/Streamed.
//
// dotnet run -c Release --project Playground/Http3/ManagedStreamed
// curl --http3-only -k https://127.0.0.1:8443/ # chunked download
// curl --http3-only -kN https://127.0.0.1:8443/feed # endless; ctrl-c to stop
// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/upload
// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo # both ways
//
// Needs: ioxide, ioxide.ngtcp2, ioxide.http3
// ─────────────────────────────────────────────────────────────────────────────────────────────

// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
// Edit these. That is the whole mechanism - there is no config file and nothing else to find.

ushort quicPort = 8443;
int reactors = Environment.ProcessorCount;

Env.OverrideQuic(ref quicPort, ref reactors);

// Chunks written per response on "/", and the size of each. Their product is never held at once.
int chunkCount = 64;
int chunkBytes = 16 * 1024;

Env.Override(ref chunkCount, "PLAYGROUND_CHUNKS");
Env.Override(ref chunkBytes, "PLAYGROUND_CHUNK_BYTES");

int udpRecvSlots = 16;

string? certOverride = null;
string? keyOverride = null;

Env.OverrideCert(ref certOverride, ref keyOverride);
// ─────────────────────────────────────────────────────────────────────────────────────────────

(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride);

using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]);

var config = new ServerConfig
{
ReactorCount = reactors,
Tcp = null,
Udp = new UdpOptions { RecvSlots = udpRecvSlots },
Quic = new QuicOptions
{
Port = quicPort,
LocalCidLength = 8,
ConnectionFactory = engine.CreateFactory(),
},
};

byte[] chunk = Encoding.ASCII.GetBytes(new string('x', chunkBytes - 1) + "\n");

var threads = new Thread[config.ReactorCount];

for (int i = 0; i < threads.Length; i++)
{
var reactor = new Reactor(i, config);

reactor.QuicHandle = (r, conn) =>
new Http3Connection(conn).RunStreamedResponseAsync(async (request, writer) =>
{
bool endless = request.Path.Span.SequenceEqual("/feed"u8);
bool upload = request.Path.Span.SequenceEqual("/upload"u8);
bool echo = request.Path.Span.SequenceEqual("/echo"u8);

if (echo)
{
// BOTH directions at once, which is the shape a proxy actually needs: read a
// chunk, write a chunk, and never hold more than one. Neither side can run away
// from the other - ReadAsync waits for the peer to send, FlushAsync waits for the
// connection to have room - so memory stays flat however large the exchange is.
writer.WriteHeaders(Plain());

if (request.BodyReader is { } duplex)
{
while (true)
{
ReadOnlyMemory<byte> part = await duplex.ReadAsync();
if (part.IsEmpty)
{
break; // end of the request body
}

part.Span.CopyTo(writer.GetSpan(part.Length));
writer.Advance(part.Length);
await writer.FlushAsync();
}
}

return;
}

if (upload)
{
// The other direction: pull the body a chunk at a time rather than waiting for
// all of it, so memory is bound by one chunk however large the upload is.
long total = 0;
if (request.BodyReader is { } body)
{
while (true)
{
ReadOnlyMemory<byte> part = await body.ReadAsync();
if (part.IsEmpty) break;
total += part.Length;
}
}

writer.WriteHeaders(Plain());
Encoding.ASCII.GetBytes($"{total}\n").CopyTo(writer.GetSpan(24));
writer.Advance(Encoding.ASCII.GetByteCount($"{total}\n"));
await writer.FlushAsync();
return;
}

writer.WriteHeaders(Plain(endless));

for (int n = 0; endless || n < chunkCount; n++)
{
chunk.CopyTo(writer.GetSpan(chunk.Length));
writer.Advance(chunk.Length);

// Returns once the chunk is queued, and waits when the connection is at its
// send-retention high-water. That await IS the backpressure.
await writer.FlushAsync();
}
});

threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
threads[i].Start();
}

Console.WriteLine($"[h3-managed-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port} "
+ $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks, cert {certPath}");

foreach (Thread thread in threads)
{
thread.Join();
}

static Http3Response Plain(bool eventStream = false)
{
var response = new Http3Response { Status = 200 };
response.Headers.Add(("content-type"u8.ToArray(),
eventStream ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray()));
return response;
}
10 changes: 10 additions & 0 deletions bench/results/20260810T001948Z.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"stamp": "20260810T001948Z",
"host": "diogo-System-Product-Name",
"kernel": "6.17.0-22-generic",
"reactors": 2, "conns": 64, "threads": 8, "seconds": 10,
"commit": "3c9a208",
"samples": [
{"sample":"Http3/ManagedStreamed","proto":"h3","rps":28906,"cpu_us_per_req":68.95,"util_pct":100,"note":""}
]
}
6 changes: 3 additions & 3 deletions bench/results/latest.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"stamp": "20260810T001150Z",
"stamp": "20260810T001948Z",
"host": "diogo-System-Product-Name",
"kernel": "6.17.0-22-generic",
"reactors": 2, "conns": 64, "threads": 8, "seconds": 10,
"commit": "96137b9",
"commit": "3c9a208",
"samples": [
{"sample":"Http3/Nghttp3","proto":"h3","rps":480371,"cpu_us_per_req":4.17,"util_pct":100,"note":""},{"sample":"Http3/Buffered","proto":"h3","rps":516707,"cpu_us_per_req":3.87,"util_pct":100,"note":""},{"sample":"Http3/Managed","proto":"h3","rps":665936,"cpu_us_per_req":2.96,"util_pct":98,"note":""},{"sample":"Http3/Streamed","proto":"h3","rps":89906,"cpu_us_per_req":22.03,"util_pct":99,"note":""}
{"sample":"Http3/ManagedStreamed","proto":"h3","rps":28906,"cpu_us_per_req":68.95,"util_pct":100,"note":""}
]
}
1 change: 1 addition & 0 deletions bench/samples.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Http3/Nghttp3 h3 8443 / - -
Http3/Buffered h3 8443 / - -
Http3/Managed h3 8443 / - -
Http3/Streamed h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024
Http3/ManagedStreamed h3 8443 / - PLAYGROUND_CHUNKS=8 PLAYGROUND_CHUNK_BYTES=1024

Quic/Alpn echo 8443 / - -
Quic/Pipe echo 8443 / - -
Expand Down
5 changes: 4 additions & 1 deletion docs/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ nav.top .links a.gh svg { display: block; }
#tab-taskrun:checked ~ .ex-menu label[for="tab-taskrun"],
#tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"],
#tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"],
#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"],
#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"],
#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"],
#tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"],
Expand Down Expand Up @@ -348,6 +349,7 @@ nav.top .links a.gh svg { display: block; }
#tab-taskrun:checked ~ .pane-taskrun { display: block; }
#tab-h2cstls:checked ~ .pane-h2cstls { display: block; }
#tab-h2bcl:checked ~ .pane-h2bcl { display: block; }
#tab-h3csstream:checked ~ .pane-h3csstream { display: block; }
#tab-h3cs:checked ~ .pane-h3cs { display: block; }
#tab-h3stream:checked ~ .pane-h3stream { display: block; }
#tab-h3buf:checked ~ .pane-h3buf { display: block; }
Expand Down Expand Up @@ -482,7 +484,8 @@ nav.top .links a.gh svg { display: block; }
#tab-taskrun:checked ~ .ex-menu label[for="tab-taskrun"],
#tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"],
#tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"],
#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"],
#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"],
#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"],
#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"],
#tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"],
#tab-quicalpn:checked ~ .ex-menu label[for="tab-quicalpn"],
Expand Down
Loading
Loading