@@ -44,7 +44,7 @@ public sealed partial class StreamableHttpServerTransport : ITransport
4444 private TaskCompletionSource < bool > ? _httpResponseTcs ;
4545 private string ? _negotiatedProtocolVersion ;
4646 private bool _getHttpRequestStarted ;
47- private bool _getHttpResponseCompleted ;
47+ private bool _disposed ;
4848
4949 /// <summary>
5050 /// Initializes a new instance of the <see cref="StreamableHttpServerTransport"/> class.
@@ -137,33 +137,53 @@ public async Task HandleGetRequestAsync(Stream sseResponseStream, CancellationTo
137137 throw new InvalidOperationException ( "GET requests are not supported in stateless mode." ) ;
138138 }
139139
140- using ( await _unsolicitedMessageLock . LockAsync ( cancellationToken ) . ConfigureAwait ( false ) )
140+ try
141141 {
142- if ( _getHttpRequestStarted )
142+ using ( await _unsolicitedMessageLock . LockAsync ( cancellationToken ) . ConfigureAwait ( false ) )
143143 {
144- throw new InvalidOperationException ( "Session resumption is not yet supported. Please start a new session." ) ;
145- }
144+ if ( _getHttpRequestStarted )
145+ {
146+ throw new InvalidOperationException ( "Session resumption is not yet supported. Please start a new session." ) ;
147+ }
146148
147- _getHttpRequestStarted = true ;
148- _httpSseWriter = new SseEventWriter ( sseResponseStream ) ;
149- _httpResponseTcs = new TaskCompletionSource < bool > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
150- _storeSseWriter = await TryCreateEventStreamAsync ( streamId : UnsolicitedMessageStreamId , cancellationToken ) . ConfigureAwait ( false ) ;
151- if ( _storeSseWriter is not null )
152- {
153- var primingItem = await _storeSseWriter . WriteEventAsync ( SseItem . Prime < JsonRpcMessage > ( ) , cancellationToken ) . ConfigureAwait ( false ) ;
154- await _httpSseWriter . WriteAsync ( primingItem , cancellationToken ) . ConfigureAwait ( false ) ;
149+ _getHttpRequestStarted = true ;
150+ _httpSseWriter = new SseEventWriter ( sseResponseStream ) ;
151+ _httpResponseTcs = new TaskCompletionSource < bool > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
152+ _storeSseWriter = await TryCreateEventStreamAsync ( streamId : UnsolicitedMessageStreamId , cancellationToken ) . ConfigureAwait ( false ) ;
153+ if ( _storeSseWriter is not null )
154+ {
155+ var primingItem = await _storeSseWriter . WriteEventAsync ( SseItem . Prime < JsonRpcMessage > ( ) , cancellationToken ) . ConfigureAwait ( false ) ;
156+ await _httpSseWriter . WriteAsync ( primingItem , cancellationToken ) . ConfigureAwait ( false ) ;
157+ }
158+ else
159+ {
160+ // If there's no priming write, flush the stream to ensure HTTP response headers are
161+ // sent to the client now that the transport is ready to accept messages via SendMessageAsync.
162+ await sseResponseStream . FlushAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
163+ }
155164 }
156- else
165+
166+ // Wait for the response to be written before returning from the handler.
167+ // This keeps the HTTP response open until the final response message is sent.
168+ await _httpResponseTcs . Task . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
169+ }
170+ finally
171+ {
172+ // Release the SseEventWriter's reference to the response stream promptly when the GET
173+ // request ends, regardless of how it exits. Otherwise the response stream (and the
174+ // underlying Kestrel connection and associated memory pool buffers) remains pinned
175+ // in memory until the session itself is disposed (via explicit DELETE or idle timeout).
176+ // Clients that disconnect without sending DELETE — common with long-lived SSE — would
177+ // otherwise accumulate significant unmanaged memory per session during that interval.
178+ using ( await _unsolicitedMessageLock . LockAsync ( CancellationToken . None ) . ConfigureAwait ( false ) )
157179 {
158- // If there's no priming write, flush the stream to ensure HTTP response headers are
159- // sent to the client now that the transport is ready to accept messages via SendMessageAsync.
160- await sseResponseStream . FlushAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
180+ if ( _httpSseWriter is { } writer )
181+ {
182+ _httpSseWriter = null ;
183+ writer . Dispose ( ) ;
184+ }
161185 }
162186 }
163-
164- // Wait for the response to be written before returning from the handler.
165- // This keeps the HTTP response open until the final response message is sent.
166- await _httpResponseTcs . Task . WaitAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
167187 }
168188
169189 /// <summary>
@@ -219,23 +239,22 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
219239 return ;
220240 }
221241
222- Debug . Assert ( _httpSseWriter is not null ) ;
223242 Debug . Assert ( _httpResponseTcs is not null ) ;
224243
225244 var item = SseItem . Message ( message ) ;
226245
227246 if ( _storeSseWriter is not null )
228247 {
248+ // Always record the message in the event store (if configured) — even when the GET
249+ // response stream is gone — so a reconnecting client can replay it via Last-Event-ID.
229250 item = await _storeSseWriter . WriteEventAsync ( item , cancellationToken ) . ConfigureAwait ( false ) ;
230251 }
231252
232- if ( ! _getHttpResponseCompleted )
253+ if ( _httpSseWriter is { } writer )
233254 {
234- // Only write the message to the response if the response has not completed.
235-
236255 try
237256 {
238- await _httpSseWriter ! . WriteAsync ( item , cancellationToken ) . ConfigureAwait ( false ) ;
257+ await writer . WriteAsync ( item , cancellationToken ) . ConfigureAwait ( false ) ;
239258 }
240259 catch ( Exception ex ) when ( ! cancellationToken . IsCancellationRequested )
241260 {
@@ -249,12 +268,12 @@ public async ValueTask DisposeAsync()
249268 {
250269 using var _ = await _unsolicitedMessageLock . LockAsync ( ) . ConfigureAwait ( false ) ;
251270
252- if ( _getHttpResponseCompleted )
271+ if ( _disposed )
253272 {
254273 return ;
255274 }
256275
257- _getHttpResponseCompleted = true ;
276+ _disposed = true ;
258277
259278 try
260279 {
@@ -266,7 +285,11 @@ public async ValueTask DisposeAsync()
266285 try
267286 {
268287 _httpResponseTcs ? . TrySetResult ( true ) ;
269- _httpSseWriter ? . Dispose ( ) ;
288+ if ( _httpSseWriter is { } writer )
289+ {
290+ _httpSseWriter = null ;
291+ writer . Dispose ( ) ;
292+ }
270293
271294 if ( _storeSseWriter is not null )
272295 {
0 commit comments