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.
47 lines
1.8 KiB
C#
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();
|
|
}
|
|
}
|