Skip to content

Write each streamed item to the transport as one write - #434

Merged
ipjohnson merged 3 commits into
mainfrom
stream-item-single-write
Sep 26, 2026
Merged

ipjohnson merged 3 commits into
mainfrom
stream-item-single-write

Conversation

@ipjohnson

@ipjohnson ipjohnson commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Each item of a streamed response now reaches the transport as one write.

SseFraming wrote an event as three writes straight to the response body: data: , the payload and the blank line. Kestrel sends every write to a chunked response as a chunk of its own and flushes its output for each, so an 89-event response went out as 267 chunks. ASP.NET Core's TypedResults.ServerSentEvents sends 89. NDJSON was two writes per row.

RequestBench measured the cost on container-h1 (run 2026-09-25T2041Z, 0.40.0-rc1000). At 5,000 rps, sse.medium p50 was 216 µs against 186 to 200 µs for minimal APIs, Wolverine and MVC. At 1,000 rps it was 242 against 200 to 217. At 5,000 rps json.medium was faster than all three, so the loss was in the streaming path.

Change

  • ItemBufferStream (internal) is the response body for the length of a stream. Each item is written into a MemoryStream reserved from IMemoryStreamPool at the item's first write. The per-item flush the filter already makes hands the item to the transport as one write, then returns the reservation.
  • AsyncEnumerableIoFilter installs it with one using. IOFilterProvider passes the application's IMemoryStreamPool through a new optional constructor parameter. A filter or provider built without one keeps a pool of its own.
  • The public surface changes by those two optional parameters.

How the pooled stream always comes back

  • One owner. The filter holds the body in a using, so it is disposed on every exit, a throw and a cancellation included. Disposal puts the transport back and returns anything still held.
  • Short holds. A reservation lasts from an item's first write to the end of the transport write that sends it. Nothing is held while the filter waits for the handler, so a quiet event stream holds no buffer. The reservation waits for the write to finish because a compressing transport reads the bytes across its awaits.
  • Once only. The stream clears its reference before disposing a reservation. ItemPool does not guard against a second return, and a reservation disposed twice is lent to the next two callers at once.
  • Nothing after disposal. Every write after disposal throws ObjectDisposedException, so a caller that kept the body cannot write into a stream the pool has lent elsewhere.

Position is what the transport has taken plus what is held, because the Lambda, Azure Functions and testing hosts answer ResponseStarted from the body's position. Bytes being handed over stop counting when the handover begins. A first version kept counting them, and ResponseCompressionFilterTests.AnNdjsonStreamIsOneMemberDeliveredItemByItem failed: the compressing body saw a started response at its first write and sent NDJSON uncompressed.

Measured

Linux containers, with the server pinned to 2 CPUs as on container-h1 and curl on 2 others, sending requests one after another on one keep-alive connection. Five interleaved rounds of 3,000 requests per row, after a warm-up round. The patched row is this branch's Hardened.Requests.Runtime.dll in the RequestBench target built on 0.40.0-rc1000. Between that tag and this branch, only this change touches the runtime's source.

SSE p50 Server CPU per SSE request TCP segments per SSE response NDJSON p50
0.40.0-rc1000 119 µs 223 µs 16.8 113 µs
This change 106 µs 190 µs 13.0 107 µs
ASP.NET Core minimal APIs 109 µs 173 µs 9.7 115 µs

Bodies are byte-identical across the three, and curl --raw shows one chunk per item from this change. The CPU still left against ASP.NET Core is not investigated here. The likely cause is the per-item JsonSerializer.SerializeAsync call, where SseFormatter serializes into a reused buffer.

BenchmarkDotNet on an M3 puts ItemBufferStream at 2.76 µs and 64 bytes allocated per 89-event response.

Tests

  • ItemBufferStreamTests: one write per item, one reservation per item, the reservation held until the transport's write finishes and returned when it fails, Position during a handover, double disposal, and every write and flush refused after disposal.
  • AsyncEnumerableIoFilterTests: one write per item for both framings, every reservation returned when the stream ends, when the handler fails after the first item and when the transport fails, nothing held while the handler is quiet, the transport back on the response after a failure before the first byte, and an unstarted response at the transport's first write.
  • IOFilterProviderTests: the provider's pool reaches the streamed filter.

dotnet build Hardened.slnx -c Release -p:ContinuousIntegrationBuild=true is clean and dotnet csharpier check . passes. The whole solution's tests pass locally with coverage, 9,888 of them with none skipped, and the coverage gate passes. ItemBufferStream is fully covered. CI measured Hardened.Requests.Runtime at 98.4% of lines and 96.7% of branches, up from 97.7% and 94.9%, and its floor is raised to that. Only that row moves.

Hardened.Web.Runtime branch coverage

The second CI run failed the coverage gate on Hardened.Web.Runtime, which this change does not touch: 98.9% of branches against a 99.5% floor. Both runs left the same nine branches uncovered. The collector counted 996 branches in one and 882 in the other, so the same misses read 99.0% and then 98.9%.

Seven of the nine now have tests:

  • a CORS suffix rule and the null origin, which has no scheme
  • HardenedOpenApiUi.GetHashCode with no path
  • Unauthorized<T>.FromResponse for a 401 without WWW-Authenticate
  • the three early returns in RouteRegistrationStartupService: no catalog, no operation, no document
  • a registered operation with no object in it, in RouteRegistry

The remaining two are the escaping branches in RouteRegistry.Escape. Escape is only given RegisteredOperationId.For output, which is letters and digits, so neither branch can be reached. The escape is left in place.

No docs change. The streaming guide says each item is written and flushed as the handler yields it, which is still what happens.

🤖 Generated with Claude Code

Ian Johnson and others added 3 commits September 25, 2026 20:59
Kestrel sends every write to a chunked response as a chunk of its own, and SseFraming writes an
event in three parts, so an 89-event response went out as 267 chunks where ASP.NET Core sends 89.
RequestBench put Hardened's sse.medium about 30 µs behind ASP.NET Core at p50.

For the length of a stream the body is now an ItemBufferStream. Each item is written into a
MemoryStream reserved from IMemoryStreamPool at its first write, and the flush that ends the item
hands it to the transport in one write and returns the reservation once that write has finished.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ItemBufferStream tests took the assembly to 98.4% of lines and 96.7% of branches in CI run
36206862349. Only this row moves; the other assemblies above their floors are not this change's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The coverage collector counts a different number of branches from run to run, so the same nine
misses read 99.0% in one run and 98.9% in the next, and the second failed the gate on #434. Seven
are now taken. The other two are in RouteRegistry.Escape, which is only given
RegisteredOperationId.For output, letters and digits, so neither can be reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ipjohnson
ipjohnson merged commit 1389e79 into main Sep 26, 2026
3 checks passed
@ipjohnson ipjohnson mentioned this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant