@@ -6876,6 +6876,76 @@ def wait_until_blocked_or_done(thread)
68766876 assert_equal 2 , sse_events ( second ) . count { |event | event [ "method" ] == "notifications/tools/list_changed" }
68776877 end
68786878
6879+ test "a delivery that took an entry before its removal does not write to the removed stream" do
6880+ io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } )
6881+ registry = @transport . instance_variable_get ( :@listen_subscriptions )
6882+ subscription_key , entry = registry . first
6883+ written = io . string . dup
6884+
6885+ # Holding the stream's write mutex lets the delivery take its snapshot under the registry lock and then
6886+ # block ahead of its write, which is where a removal can land between the two.
6887+ entry [ :write_mutex ] . lock
6888+ delivery = Thread . new { @server . notify_tools_list_changed }
6889+
6890+ begin
6891+ wait_until_blocked_or_done ( delivery )
6892+ @transport . send ( :remove_listen_subscription , subscription_key )
6893+ ensure
6894+ entry [ :write_mutex ] . unlock
6895+ end
6896+
6897+ refute_nil delivery . join ( 5 )
6898+
6899+ assert entry [ :closed ]
6900+ assert_equal written , io . string
6901+ assert_empty registry
6902+ ensure
6903+ delivery &.join ( 5 )
6904+ end
6905+
6906+ test "closing a removed stream waits for a delivery already past its closed check" do
6907+ io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } )
6908+ registry = @transport . instance_variable_get ( :@listen_subscriptions )
6909+ subscription_key , entry = registry . first
6910+ gate = Queue . new
6911+ entered_write = false
6912+ open_at_write = nil
6913+ # The write blocks once the delivery is inside it: past the `closed` check and holding the write mutex,
6914+ # which is the window a removal and the close that follows it used to race into.
6915+ io . define_singleton_method ( :write ) do |data |
6916+ entered_write = true
6917+ gate . pop
6918+ open_at_write = !closed?
6919+ super ( data )
6920+ end
6921+ delivery = Thread . new { @server . notify_tools_list_changed }
6922+ deadline = Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) + 5
6923+ until entered_write
6924+ flunk ( "the delivery did not reach the write" ) if Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) > deadline
6925+ sleep ( 0.005 )
6926+ end
6927+
6928+ closer = Thread . new do
6929+ @transport . send ( :close_removed_listen_stream , @transport . send ( :remove_listen_subscription , subscription_key ) )
6930+ end
6931+ wait_until_blocked_or_done ( closer )
6932+
6933+ assert entry [ :closed ]
6934+ assert_empty registry
6935+ refute_predicate io , :closed? , "the stream was closed under a write still in progress"
6936+
6937+ gate << true
6938+ refute_nil delivery . join ( 5 )
6939+ refute_nil closer . join ( 5 )
6940+ assert open_at_write , "the write ran against a closed stream"
6941+ assert_predicate io , :closed?
6942+ assert_includes io . string , "notifications/tools/list_changed"
6943+ ensure
6944+ gate &.close
6945+ delivery &.join ( 5 )
6946+ closer &.join ( 5 )
6947+ end
6948+
68796949 test "listen keepalive writes a comment frame outside the mutex" do
68806950 # Register a subscription backed by a mutex-probing stream, then invoke the ping directly.
68816951 mutex = @transport . instance_variable_get ( :@mutex )
@@ -6902,15 +6972,21 @@ def wait_until_blocked_or_done(thread)
69026972 test "listen keepalive frees the slot and closes the stream when the peer is gone" do
69036973 transport = StreamableHTTPTransport . new ( @server , listen_keepalive_interval : 0.01 )
69046974 io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } , transport : transport )
6975+ entry = transport . instance_variable_get ( :@listen_subscriptions ) . values . first
69056976 io . define_singleton_method ( :write ) { |_data | raise Errno ::ECONNRESET }
69066977
6978+ # The keepalive thread frees the slot first and closes the stream second, so wait for both.
69076979 deadline = Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) + 2
6908- until transport . instance_variable_get ( :@listen_subscriptions ) . empty?
6909- flunk ( "keepalive did not free the slot for a dead peer" ) if Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) > deadline
6980+ until transport . instance_variable_get ( :@listen_subscriptions ) . empty? && io . closed?
6981+ flunk ( "keepalive did not free the slot and close the stream for a dead peer" ) if Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) > deadline
69106982 sleep ( 0.01 )
69116983 end
69126984
69136985 assert_predicate io , :closed?
6986+
6987+ # The removal a failed ping performs goes through the same helper as every other, so a delivery that took the entry before
6988+ # the ping failed skips it instead of writing to the closed stream.
6989+ assert entry [ :closed ] , "the entry a failed ping removed was not marked closed"
69146990 ensure
69156991 transport . close
69166992 end
0 commit comments