Kestrel WebTransport never releases finished streams: session stalls after 100 streams and leaks ~3.7 KB per stream

Open
#70,395 1 comment 0 reactions 0 assignees View on GitHub

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

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

area-networking feature-kestrel
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:

  1. The session stalls after 100 streams. The per-stream QuicStreamContext is never disposed, so msquic never gives the peer more bidirectional stream credit. Once MaxBidirectionalStreamCount (100 by default) streams have been used, the client's next OpenBidirectionalStream waits forever, even though every earlier stream finished long ago.
  2. The session leaks about 3.7 KB per stream until it ends. Each WebTransportStream stays in WebTransportSession._openStreams, along with its QuicStreamContext, pipes and abort ExceptionDispatchInfo.

The cause, in src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs:

  • The constructor registers on context.StreamContext.ConnectionClosed to call WebTransportSession.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 the ConnectionContext it got from AcceptStreamAsync when it is done, so that removal callback never runs.
  • Nothing disposes the underlying stream context (Http3StreamContext.StreamContext, the QuicStreamContext). In Http3Connection.CreateAndAddWebTransportStream, ownership passes to the WebTransportStream, 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
  1. 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();
            }
        });
    }
});
  1. From any WebTransport client (we used a .NET QuicConnection client 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 -stat after 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:

  1. Call the internal WebTransportSession.TryRemoveStream(streamId) through [UnsafeAccessor] with [UnsafeAccessorType(...WebTransportSession...)]. It also disposes the WebTransportStream.
  2. Then dispose stream.Features, which is the QuicStreamContext.

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

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/aspnetcore

All issues in dotnet/aspnetcore

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.