Kestrel WebTransport never releases finished streams: session stalls after 100 streams and leaks ~3.7 KB per stream
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- csharp
- Domain
- backend, networking
Research direction
Start with src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs, especially DisposeAsync() and the ConnectionClosed registration, then trace ownership through Http3Connection.CreateAndAddWebTransportStream. Check the session removal path and how request stream lifetime handlers dispose their contexts. Done means disposing an accepted WebTransport stream removes it from the session and releases its QUIC stream context so sequential streams can continue; verify with a regression test for stream credit and retained memory.
Written by the indexing model from the issue text.
Description
Is there an existing issue for this?
- I have searched the existing issues
Describe the bug
On a WebTransport session, Kestrel never releases a finished stream, so streams pile up for the whole session. This causes two problems:
- The session stalls after 100 streams. The per-stream
QuicStreamContextis never disposed, so msquic never gives the peer more bidirectional stream credit. OnceMaxBidirectionalStreamCount(100 by default) streams have been used, the client's nextOpenBidirectionalStreamwaits forever, even though every earlier stream finished long ago. - The session leaks about 3.7 KB per stream until it ends. Each
WebTransportStreamstays inWebTransportSession._openStreams, along with itsQuicStreamContext, pipes and abortExceptionDispatchInfo.
The cause, in src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs:
- The constructor registers on
context.StreamContext.ConnectionClosedto callWebTransportSession.TryRemoveStream. That registration is the only path that removes the stream from_openStreams. WebTransportStream.DisposeAsync()first disposes_connectionClosedRegistration, then completes the duplex pipe. The app disposes theConnectionContextit got fromAcceptStreamAsyncwhen it is done, so that removal callback never runs.- Nothing disposes the underlying stream context (
Http3StreamContext.StreamContext, theQuicStreamContext). InHttp3Connection.CreateAndAddWebTransportStream, ownership passes to theWebTransportStream, and that type never disposes it. Request streams are different: their stream lifetime handler disposes the context.
Expected Behavior
Disposing a stream from IWebTransportSession.AcceptStreamAsync (or finishing it) removes it from the session, disposes its stream context, and returns the QUIC stream credit. A session should then serve any number of sequential streams with flat memory.
Steps To Reproduce
- Start a Kestrel HTTP/3 server with WebTransport enabled. For each accepted stream, read one message, write a reply, then
await stream.DisposeAsync():
app.Run(async ctx =>
{
var session = await ctx.Features.GetRequiredFeature<IHttpWebTransportFeature>().AcceptAsync(ctx.RequestAborted);
while (await session.AcceptStreamAsync(ctx.RequestAborted) is { } stream)
{
_ = Task.Run(async () =>
{
await using (stream)
{
var read = await stream.Transport.Input.ReadAsync();
await stream.Transport.Output.WriteAsync(read.Buffer.ToArray());
stream.Transport.Input.AdvanceTo(read.Buffer.End);
await stream.Transport.Output.CompleteAsync();
}
});
}
});
- From any WebTransport client (we used a .NET
QuicConnectionclient and a Rust quinn client), open bidirectional streams one at a time on a single session. Each stream should send a message, read the reply to FIN, and close.
Results:
- Stream 101 never opens. Here is a server debug log for one stream: the stream is accepted, writes shut down gracefully, and nothing else happens. msquic sends no new MAX_STREAMS.
- Heap growth, measured in-process with
GC.GetTotalMemory(true)after each round:
| After calls | Managed heap |
|---|---|
| 10,000 | 46 MB |
| 20,000 | 84 MB |
| 30,000 | 121 MB |
| 40,000 | 158 MB |
To get past the stall for this measurement, our test disposed the stream context as in the workaround below.
dumpheap -statafter 20k calls: 22,000 ×ConcurrentDictionary<long, WebTransportStream>+Node, 22,000 ×WebTransportStream, 22,003 ×QuicStreamContext, 21,998 ×ExceptionDispatchInfo.
Workaround. After the app is done with a stream:
- Call the internal
WebTransportSession.TryRemoveStream(streamId)through[UnsafeAccessor]with[UnsafeAccessorType(...WebTransportSession...)]. It also disposes theWebTransportStream. - Then dispose
stream.Features, which is theQuicStreamContext.
With both steps, 40,000 calls on one session stay flat at about 1.7 MB, and the session runs without a limit.
Exceptions (if any)
None. The client just waits for stream credit forever.
.NET Version
11.0.100 (Linux x64, libmsquic 2.5)
Anything else?
Related WebTransport issues we filed: #70384 (draft H3_DATAGRAM codepoint) and #70386 (client uni-stream reader completed at FIN).
- Dominant language
- C#
- Stars
- 38.5k
- Forks
- 11.6k
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 289
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from dotnet/aspnetcore
-
area-networking
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
dotnet/aspnetcore#70545 · 1 comment ·
Maintainers usually reply within 1 day
-
area-infrastructure
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
dotnet/aspnetcore#70524 · 1 comment ·
Maintainers usually reply within 1 day
-
[OpenTelemetry] Hosting replaces an Activity.DisplayName that the app set, when a route matchesOpenarea-hosting Regression
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
dotnet/aspnetcore#70516 · 1 comment · 1 reaction ·
Maintainers usually reply within 1 day
-
[SSE] ServerSentEventsResult.WrapEvents is missing [EnumeratorCancellation], so RequestAborted never reaches the app's generator on the IAsyncEnumerable<T> overloadPossibly taken @bharatprajapati-coder claimed this 1 day ago. Openarea-minimal
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
dotnet/aspnetcore#70419 · 2 comments ·
Maintainers usually reply within 1 day
-
[Blazor] DefaultInMemoryCircuitPersistenceProvider never disposes the retention CancellationTokenSource when a persist is rejected at capacityPossibly taken @Vinoth2562000 claimed this 1 day ago. Openarea-blazor
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
dotnet/aspnetcore#70418 · 1 comment · 1 assignee ·
Maintainers usually reply within 1 day
All issues in dotnet/aspnetcore
Similar issues
-
bug emitter:client:csharp
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
microsoft/typespec#12572 · 1 comment · 1 reaction ·
Maintainers usually reply within 1 day
-
needs-area-label
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 2 days
-
AWS Lambda: KeyNotFoundException drops HTTP-triggered transactions when allowAllHeaders is disabledOpenbug
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
newrelic/newrelic-dotnet-agent#3949 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 87/100
Maintainers usually reply within 1 day
-
priority:P2
Difficulty 2/5 1-3 hours Newbie friendliness 80/100