Write each streamed item to the transport as one write - #434
Merged
Merged
Conversation
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Each item of a streamed response now reaches the transport as one write.
SseFramingwrote 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'sTypedResults.ServerSentEventssends 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.mediump50 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 rpsjson.mediumwas 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 aMemoryStreamreserved fromIMemoryStreamPoolat 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.AsyncEnumerableIoFilterinstalls it with oneusing.IOFilterProviderpasses the application'sIMemoryStreamPoolthrough a new optional constructor parameter. A filter or provider built without one keeps a pool of its own.How the pooled stream always comes back
using, so it is disposed on every exit, a throw and a cancellation included. Disposal puts the transport back and returns anything still held.ItemPooldoes not guard against a second return, and a reservation disposed twice is lent to the next two callers at once.ObjectDisposedException, so a caller that kept the body cannot write into a stream the pool has lent elsewhere.Positionis what the transport has taken plus what is held, because the Lambda, Azure Functions and testing hosts answerResponseStartedfrom the body's position. Bytes being handed over stop counting when the handover begins. A first version kept counting them, andResponseCompressionFilterTests.AnNdjsonStreamIsOneMemberDeliveredItemByItemfailed: 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.dllin the RequestBench target built on 0.40.0-rc1000. Between that tag and this branch, only this change touches the runtime's source.Bodies are byte-identical across the three, and
curl --rawshows 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-itemJsonSerializer.SerializeAsynccall, whereSseFormatterserializes into a reused buffer.BenchmarkDotNet on an M3 puts
ItemBufferStreamat 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,Positionduring 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=trueis clean anddotnet csharpier check .passes. The whole solution's tests pass locally with coverage, 9,888 of them with none skipped, and the coverage gate passes.ItemBufferStreamis fully covered. CI measuredHardened.Requests.Runtimeat 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.Runtimebranch coverageThe 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:
nullorigin, which has no schemeHardenedOpenApiUi.GetHashCodewith no pathUnauthorized<T>.FromResponsefor a 401 withoutWWW-AuthenticateRouteRegistrationStartupService: no catalog, no operation, no documentRouteRegistryThe remaining two are the escaping branches in
RouteRegistry.Escape.Escapeis only givenRegisteredOperationId.Foroutput, 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