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.
This commit is contained in:
parent
06e3604dd1
commit
4d011995d1
1 changed files with 94 additions and 23 deletions
|
|
@ -0,0 +1,47 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue