Tag conditional responses with SHA-1 and hold the body in a pooled MemoryStream - #435
Merged
Merged
Conversation
…moryStream [ConditionalGet] held an untagged body in a new MemoryStream with no capacity. A 130 KB body regrew it by doubling to 256 KB on every request. That allocated about 0.5 MB and copied about 240 KB per request, and the two largest buffers were on the large object heap. The body is now held in a stream reserved from IMemoryStreamPool. The stream is returned once, after the body is sent, dropped for a 304, or left behind by a chain that threw. Computed tags are SHA-1. A colliding tag leaves a client with a stale copy, not another caller's bytes. ByPayload's cache key stays SHA-256. 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.
[ConditionalGet]now holds an untagged body in a pooled stream and tags it with SHA-1. Closes #433.RequestBench measured the cost on container-h1 (run
36187074173, 0.40.0-rc1000). At 1,000 rps,etag.largeminusjson.largewas +278 µs for Hardened, against −25 to +155 µs for the five ASP.NET Core frameworks. At 5,000 rps Hardened'setag.largep90 was 2.5 ms, against 1.2 to 1.6 ms.Change
ConditionalResponseStreamholds a body that has no tag at its first write in aMemoryStreamreserved fromIMemoryStreamPool. It used anew MemoryStream()with no capacity. A 130 KiB body regrew that by doubling to 256 KiB on every request, which allocated about 0.5 MB and copied about 240 KB, with the two largest buffers on the large object heap. A returned stream keeps its capacity, so a steady body size stops regrowing it.ConditionalGetFilteris one instance per handler, so it resolves the pool fromcontext.RequestServiceson each request. It usesGetService. A container composed by hand with no pool gets a plainMemoryStream, asHardenedHtmlTemplatedoes.EntityTagHeader.ForContenthashes with SHA-1, still base64 in quotes. The response cache tags stored responses with the same method, so the two tags keep matching.ByPayload's cache key and the static content mount's tags stay SHA-256.ConditionalResponseStreamis internal.How the pooled stream always comes back
CompleteAsyncin itsfinally, andCompleteAsyncreturns the stream in afinallyof its own. The stream goes back after the body is copied to the transport, after a 304, when the chain threw, and when the transport write fails.ItemPooldoes not guard against a second return.ObjectDisposedException, so a caller that kept the body cannot write into a stream the pool has lent to another request.Positionon return, which throws on a closed stream, as the remarks onJsonSerializerImpl.DeserializeAsyncdescribe. Nothing here disposes the pooled stream.Measured
BenchmarkDotNet, in process on net10.0, on an M3 Pro. One GET goes through the chain. The handler writes 130 KiB in 16 KB pieces and sets no tag. The transport is
Stream.Null, so the numbers are the filter's.1389e790)[ConditionalGet]With a 1,000-byte body the request took 651 ns on main and 631 ns with this change, and allocated 2,392 B and 1,256 B.
On the M3 Pro the gain is all from the buffer. SHA-1 and SHA-256 both hash at 3.1 GB/s there, and the hash is almost all of the 47 µs left. The RequestBench runner is an AMD EPYC 9V74, a Zen 4 part that also has SHA instructions, so the hash change should gain little there. It was not measured there. SHA-1 costs less per byte on a CPU without SHA instructions, and its tag is 28 characters rather than 44.
Upgrade
Every computed tag changes. A client holding a SHA-256 tag gets a 200 with the new tag on its first conditional request after the upgrade, and 304s after that. The shipped response cache store is in memory, so a restart empties it. A store an application wrote that outlives the deploy replays each entry's old tag until the entry expires, and that tag still matches. This belongs in the release notes.
Tests
ConditionalGetFilterTestsgained seven tests. Six run against a counting pool that fills a returned stream with a marker and throws on a closed one:The seventh uses the application's
MemoryStreamPool. The next request gets the same stream, with its capacity and without the last body's bytes.Six of the seven fail against main's filter. The one that checks nothing is reserved too early passes on main too.
EntityTagHeaderTestspins the SHA-1 of the empty input.dotnet build Hardened.slnx -c Release -p:ContinuousIntegrationBuild=trueis clean anddotnet csharpier check .passes. The whole solution's tests pass locally with coverage, 9,895 of them with none skipped, and the coverage gate passes.ConditionalResponseStreamhas 24 of 24 branches covered, andHardened.Web.Runtimestays at 100% of branches.Docs
docs/guide/conditional-requests.md,docs/guide/response-caching.mdanddocs/design/response-caching.mdsay SHA-1. Each example tag is recomputed over the exact body shown beside it. The design page's example tag was 24 characters, which is neither hash, and is now a SHA-1. Its cost paragraph says the buffer is pooled.ByPayload's remark said the rest of the framework hashes with SHA-256, which is no longer true.🤖 Generated with Claude Code