Skip to content

Fix IOutputCache empty-body bug (#1702): bump feature revision in InvokeFeatures.Set#2364

Open
GarrettBeatty wants to merge 2 commits into
devfrom
fix-issue-1702-outputcache
Open

Fix IOutputCache empty-body bug (#1702): bump feature revision in InvokeFeatures.Set#2364
GarrettBeatty wants to merge 2 commits into
devfrom
fix-issue-1702-outputcache

Conversation

@GarrettBeatty
Copy link
Copy Markdown
Collaborator

@GarrettBeatty GarrettBeatty commented May 12, 2026

Summary

  • Fixes #1702: IOutputCache (and any other middleware that wraps the response body) caches/sees zero bytes when an ASP.NET Core app is hosted in Lambda.
  • One-line behavioral change in Internal/InvokeFeatures.cs: Set<TFeature> now delegates to the indexer so _containerRevision is bumped on every mutation.

The bug

When middleware does HttpContext.Response.Body = wrapperStream, ASP.NET Core's DefaultHttpResponse.Body.set replaces the active IHttpResponseBodyFeature via Features.Set<IHttpResponseBodyFeature>(new StreamResponseBodyFeature(value, oldFeature)) (source).

ASP.NET Core caches feature lookups via FeatureReferences<T>.Fetch, which only re-reads the dictionary when Collection.Revision changes (source).

Our InvokeFeatures.Set<TFeature> was bypassing the indexer:

public void Set<TFeature>(TFeature instance)
{
    if (instance == null)
        return;

    _features[typeof(TFeature)] = instance;   // <-- never bumps _containerRevision
}

So after Response.Body = OutputCacheStream:

  1. The dictionary entry for IHttpResponseBodyFeature is updated to the new StreamResponseBodyFeature wrapping OutputCacheStream.
  2. _containerRevision is not bumped.
  3. ASP.NET Core's cached IHttpResponseBodyFeature reference (and the PipeWriter it produced) still point at the original feature, which writes directly to the underlying MemoryStream.
  4. WriteAsJsonAsync (and friends) → response.BodyWriter → cached writer → bytes flow into the MemoryStream and never visit OutputCacheStream.
  5. OutputCacheMiddleware.FinalizeCacheBodyAsync reads context.OutputCacheStream.GetCachedResponseBody() → empty → caches an empty entry.
  6. Every subsequent request is served from the cache as an empty body.

The fix

public void Set<TFeature>(TFeature instance)
{
    this[typeof(TFeature)] = instance;   // indexer already does _containerRevision++
}

The indexer setter (just above in the same file) was already correct — it increments _containerRevision on every change and supports null-removal per the IFeatureCollection contract. Delegating to it makes both code paths behave identically. No other change is needed; the bug is invisible until middleware swaps a feature mid-request.

This change also helps any other middleware that wraps the body via Response.Body = / Response.BodyWriter = (e.g. ResponseCompression).

How we tested

End-to-end on real AWS Lambda + API Gateway HTTP API v2:

  1. Reproduced the bug on dev (unfixed): deployed a minimal app — services.AddOutputCache() + app.UseOutputCache() + MapGet(\"/api/values\", () => new[]{\"value1\",\"value2\"}). First request returned Content-Length: 19, body [\"value1\",\"value2\"]. Every subsequent request returned Content-Length: 0, empty body, with age header confirming it was served from the cache.

  2. Verified PR #2150 does not fix it. That PR adds responseFeatures.Body.Position = 0L after marshalling. Deployed it; bug still repros. A diag endpoint confirmed the underlying MemoryStream had len=19, pos=0 after the request — the position reset worked, but it was on the wrong stream. The OutputCache buffer was always empty because writes never reached OutputCacheStream.

  3. Verified the real fix works. Built a NuGet from this branch, redeployed the same sample. Result:

Request 1 (cold):  200, Content-Length: 19, body: [\"value1\",\"value2\"]
Request 2:         200, Content-Length: 19, body: [\"value1\",\"value2\"], age: 0
Request 3:         200, Content-Length: 19, body: [\"value1\",\"value2\"], age: 0
Request 4:         200, Content-Length: 19, body: [\"value1\",\"value2\"], age: 1

All four requests return the full body. Requests 2-4 carry the age header, proving they're served from the OutputCache (and that the cache contains real bytes, not zero-length).

Test plan

  • Reproduce the bug end-to-end against current dev
  • Verify PR Reset Stream Position back to 0 #2150's proposed fix does not solve the bug
  • Verify this PR's fix solves the bug end-to-end against the same repro
  • CI: existing Amazon.Lambda.AspNetCoreServer.Test suite passes

Notes

  • PR Reset Stream Position back to 0 #2150 should be closed in favor of this; its Position = 0L line addresses a symptom on the wrong stream and has no effect on the OutputCache scenario.
  • Single-line behavioral change; same fix applies to all three hosting modes (APIGatewayProxyFunction, APIGatewayHttpApiV2ProxyFunction, ApplicationLoadBalancerFunction) because they all share InvokeFeatures.

🤖 Generated with Claude Code

InvokeFeatures.Set<TFeature> was updating the feature dictionary without
incrementing _containerRevision. ASP.NET Core's FeatureReferences cache
uses Collection.Revision to detect feature swaps; without the bump, cached
feature references stay stale after middleware (e.g. OutputCache,
ResponseCompression) replaces a feature via HttpContext.Response.Body =
wrapper. The result was that response writes bypassed the wrapper entirely,
so OutputCache cached zero-byte bodies and subsequent cached responses
returned empty.

Resolves #1702.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@GarrettBeatty
Copy link
Copy Markdown
Collaborator Author

@normj tbh i dont fully understand this fix or this area of the code, but from testing at least it seems to fix the issue. I am not really sure on the full implications of this change

@GarrettBeatty
Copy link
Copy Markdown
Collaborator Author

I can add a unit test if the change looks good

Regression tests for issue #1702: verify that Set<TFeature> bumps the
feature collection revision so ASP.NET Core's FeatureReferences cache
sees feature swaps, and that Set<TFeature>(null) removes the entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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