csharp-sdk/tests/ModelContextProtocol.Tests/Transport/StreamableHttpServerTransportTests.cs
Joel Forsyth 4d011995d1 Release SSE response stream reference when GET request ends
StreamableHttpServerTransport held a reference to the Kestrel SSE
response stream via _httpSseWriter for the entire session lifetime.
Since the transport is only disposed when the session itself is disposed
(via explicit DELETE or idle timeout), clients that disconnect without
sending DELETE — which is typical for long-lived SSE connections — left
the response stream pinned for the remainder of the idle timeout window.

Because the Kestrel HTTP connection (and its associated memory pool
buffers, Pipe readers/writers, socket send/receive buffers) is only
collectible once all references to the response stream are released,
this pinned ~20MiB of unmanaged memory per disconnected session until
cleanup. Under steady connect/disconnect churn (e.g., IDE availability
probes) this accumulated into sustained memory growth that eventually
OOMKilled the container.

Fix: release _httpSseWriter from within HandleGetRequestAsync's finally
block so the reference is dropped as soon as the GET request exits, not
just when the session is disposed. SendMessageAsync handles the
now-nullable field via the existing _getHttpResponseCompleted gate, and
any server-to-client messages sent after disconnect are still captured
by the event store writer for replay via Last-Event-ID.

Add a unit test that verifies _httpSseWriter is null once
HandleGetRequestAsync returns.

Relates to #766.
2026-06-05 16:52:49 -07:00

47 lines
1.8 KiB
C#

using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Reflection;
namespace ModelContextProtocol.Tests.Transport;
public class StreamableHttpServerTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
{
[Fact]
public async Task HandleGetRequestAsync_ReleasesStreamReference_AfterRequestEnds()
{
await using var transport = new StreamableHttpServerTransport()
{
SessionId = "test-session",
};
var pipe = new Pipe();
var responseStream = pipe.Writer.AsStream();
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(100));
try
{
await transport.HandleGetRequestAsync(responseStream, cts.Token);
}
catch (OperationCanceledException)
{
}
// After the GET request handler returns, the transport must not retain a reference to the
// response stream via _httpSseWriter. Otherwise the Kestrel connection and its associated
// memory pool buffers (which can be ~20MiB per SSE session) stay pinned in unmanaged memory
// until the session is eventually disposed (via explicit DELETE or idle timeout), causing
// steady memory growth for servers whose clients disconnect without sending DELETE.
var httpSseWriterField = typeof(StreamableHttpServerTransport).GetField(
"_httpSseWriter",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(httpSseWriterField);
var httpSseWriterValue = httpSseWriterField.GetValue(transport);
Assert.Null(httpSseWriterValue);
await pipe.Reader.CompleteAsync();
await pipe.Writer.CompleteAsync();
}
}