Skip to content

Mark a removed subscriptions/listen stream closed - #587

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:mark_removed_listen_streams_closed
Oct 5, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:mark_removed_listen_streams_closed

Conversation

@koic

@koic koic commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Motivation and Context

Removing a listen stream after a failed write or a finished keepalive did not mark its entry closed, as transport close does, so a delivery or keepalive that had taken the entry before the removal still wrote to the stream being closed.
That write failed against the closing stream and was reported as a failed delivery.

Removal now marks the entry closed, and those writes skip it under the stream's write mutex. The flag is set without taking the write mutex, which must never be taken inside the registry lock; a write already past its check proceeds and may fail against the closing stream, and every later one sees the flag.

How Has This Been Tested?

A new test in test/mcp/server/transports/streamable_http_transport_test.rb delivers to an entry taken before its removal and checks that nothing is written. Against the previous library the notification is written to the removed stream.
The keepalive failure test also checks that the entry a failed ping removes is marked closed.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic
koic force-pushed the mark_removed_listen_streams_closed branch from ba0ba0c to 1e91d38 Compare October 3, 2026 11:13

@soyeladice-svg soyeladice-svg left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted review: this closes the snapshot-before-removal case when the writer is still waiting on write_mutex, but I think a smaller race remains once a writer has already acquired that mutex and passed next if subscription[:closed]. remove_listen_subscription() marks closed under the registry mutex, then callers such as the keepalive ensure and delivery-error path call close_stream_safely() without taking write_mutex. A writer that passed the flag check just before removal can therefore still race the close and fail against a stream being closed—the exact failure mode described in the PR, just in the post-check window. Could removal stay registry-lock-only, but the subsequent close be serialized under subscription[:write_mutex] (after the registry lock is released)? That preserves the lock-order rule while letting an in-flight writer finish before close. A regression that blocks inside send_to_stream after the closed check, removes the entry, and asserts close waits for the writer would pin this down.

## Motivation and Context

Removing a listen stream after a failed write or a finished keepalive did not mark its entry closed,
as transport close does, so a delivery or keepalive that had taken the entry before the removal still
wrote to the stream being closed.
That write failed against the closing stream and was reported as a failed delivery.

Removal now marks the entry closed, and those writes skip it under the stream's write mutex.
The flag is set without taking the write mutex, which must never be taken inside the registry lock;
the close that follows a removal takes the write mutex outside the registry lock, so a write already past its check
lands before the stream closes, and every later one sees the flag.

## How Has This Been Tested?

A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` delivers to an entry taken
before its removal and checks that nothing is written. Against the previous library the notification
is written to the removed stream.
The keepalive failure test also checks that the entry a failed ping removes is marked closed.
Another blocks a delivery inside its write, removes the entry from a second thread, and checks that the stream
is closed only after the write has completed.

## Breaking Changes

None.
@koic
koic force-pushed the mark_removed_listen_streams_closed branch from 1e91d38 to 0cef024 Compare October 3, 2026 15:04
@koic

koic commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Thanks! That window was real. The close after a removal now takes the stream's write mutex (close_removed_listen_stream), so a write already past its closed check completes or fails before the stream closes. A regression test pins that down.

@koic
koic merged commit 745bc61 into modelcontextprotocol:main Oct 5, 2026
11 checks passed
@koic
koic deleted the mark_removed_listen_streams_closed branch October 5, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants