@@ -6113,7 +6113,12 @@ def string
61136113 # the registry insert and the acknowledgement write, which happens outside the lock.
61146114 io = StringIO . new
61156115 @transport . instance_variable_get ( :@listen_subscriptions ) [ "listen-1" ] = {
6116- request_id : "listen-1" , stream : io , filter : { toolsListChanged : true } , active : false , write_mutex : Mutex . new
6116+ request_id : "listen-1" ,
6117+ stream : io ,
6118+ filter : { toolsListChanged : true } ,
6119+ active : false ,
6120+ write_mutex : Mutex . new ,
6121+ keepalive_wakeup : ConditionVariable . new ,
61176122 }
61186123
61196124 @server . notify_tools_list_changed
@@ -6321,7 +6326,7 @@ def string
63216326 end
63226327 stream . define_singleton_method ( :flush ) { }
63236328 @transport . instance_variable_get ( :@listen_subscriptions ) [ "listen-1" ] = {
6324- request_id : "listen-1" , stream : stream , filter : { } , write_mutex : Mutex . new ,
6329+ request_id : "listen-1" , stream : stream , filter : { } , write_mutex : Mutex . new , keepalive_wakeup : ConditionVariable . new ,
63256330 }
63266331
63276332 @transport . send ( :send_listen_keepalive_ping , "listen-1" )
@@ -6347,6 +6352,113 @@ def string
63476352 transport . close
63486353 end
63496354
6355+ test "listen keepalive ends with its slot when a delivery write fails" do
6356+ # A long interval: were the thread still sleeping it out after the slot is freed, the join below would time out.
6357+ transport = StreamableHTTPTransport . new ( @server , listen_keepalive_interval : 30 )
6358+ before = Thread . list
6359+ io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } , transport : transport )
6360+ keepalive_threads = Thread . list - before
6361+ assert_equal 1 , keepalive_threads . size
6362+ wait_until_asleep ( keepalive_threads )
6363+ io . define_singleton_method ( :write ) do |_data |
6364+ raise Errno ::EPIPE
6365+ end
6366+
6367+ transport . send_notification ( "notifications/tools/list_changed" , nil , **{ } )
6368+
6369+ assert_empty transport . instance_variable_get ( :@listen_subscriptions )
6370+ assert ( keepalive_threads . all? { |thread | thread . join ( 5 ) } , "the keepalive thread outlived its freed slot" )
6371+ assert_predicate io , :closed?
6372+ ensure
6373+ # Whatever the assertions did, the streams and their threads must not outlive the test.
6374+ transport . close
6375+
6376+ keepalive_threads . each do |thread |
6377+ thread . join ( 5 )
6378+ end
6379+ end
6380+
6381+ test "listen keepalive ends with its slot on transport close" do
6382+ transport = StreamableHTTPTransport . new ( @server , listen_keepalive_interval : 30 )
6383+ before = Thread . list
6384+ open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } , transport : transport )
6385+ open_listen_stream ( id : "listen-2" , notifications : { toolsListChanged : true } , transport : transport )
6386+ keepalive_threads = Thread . list - before
6387+ assert_equal 2 , keepalive_threads . size
6388+ wait_until_asleep ( keepalive_threads )
6389+
6390+ transport . close
6391+
6392+ assert ( keepalive_threads . all? { |thread | thread . join ( 5 ) } , "a keepalive thread outlived the transport" )
6393+ ensure
6394+ transport . close
6395+
6396+ keepalive_threads . each do |thread |
6397+ thread . join ( 5 )
6398+ end
6399+ end
6400+
6401+ test "listen keepalive writes under the stream's write mutex" do
6402+ # Holding the mutex a delivery or the closing result would hold makes the ping wait its turn,
6403+ # so a comment frame cannot land between the bytes of another message.
6404+ transport = StreamableHTTPTransport . new ( @server , listen_keepalive_interval : 30 )
6405+ before = Thread . list
6406+ io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } , transport : transport )
6407+ keepalive_threads = Thread . list - before
6408+ subscription_key , subscription = transport . instance_variable_get ( :@listen_subscriptions ) . first
6409+ written_before = io . string . dup
6410+
6411+ subscription [ :write_mutex ] . lock
6412+ ping_thread = Thread . new { transport . send ( :send_listen_keepalive_ping , subscription_key ) }
6413+ wait_until_asleep ( [ ping_thread ] )
6414+ assert_equal written_before , io . string , "the ping must wait for the stream's write mutex"
6415+ subscription [ :write_mutex ] . unlock
6416+
6417+ assert ( ping_thread . join ( 5 ) , "the ping did not finish once the mutex was released" )
6418+ assert_match ( /\A : ping / , io . string . delete_prefix ( written_before ) )
6419+ ensure
6420+ subscription [ :write_mutex ] . unlock if subscription && subscription [ :write_mutex ] . owned?
6421+
6422+ transport . close
6423+
6424+ keepalive_threads . each do |thread |
6425+ thread . join ( 5 )
6426+ end
6427+ end
6428+
6429+ test "listen keepalive does not write once the transport marked the stream closed" do
6430+ # Teardown marks the entry closed and writes the result under the write mutex; a ping that resolved
6431+ # the entry just before must find the flag and skip, or a comment frame follows the final message.
6432+ transport = StreamableHTTPTransport . new ( @server , listen_keepalive_interval : 30 )
6433+ before = Thread . list
6434+ io = open_listen_stream ( id : "listen-1" , notifications : { toolsListChanged : true } , transport : transport )
6435+ keepalive_threads = Thread . list - before
6436+ subscription_key , subscription = transport . instance_variable_get ( :@listen_subscriptions ) . first
6437+ written_before = io . string . dup
6438+ subscription [ :closed ] = true
6439+
6440+ transport . send ( :send_listen_keepalive_ping , subscription_key )
6441+
6442+ assert_equal written_before , io . string
6443+ ensure
6444+ transport . close
6445+
6446+ keepalive_threads . each do |thread |
6447+ thread . join ( 5 )
6448+ end
6449+ end
6450+
6451+ # A freshly started keepalive thread may not have reached its wait yet; the removal has to land while
6452+ # the thread is asleep for the test to say anything about waking it. The bound is generous for
6453+ # a starved CI runner while staying far below the 30 second interval these tests use.
6454+ def wait_until_asleep ( threads )
6455+ deadline = Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) + 5
6456+ until threads . all? { |thread | thread . status == "sleep" }
6457+ flunk ( "keepalive threads did not reach their wait" ) if Process . clock_gettime ( Process ::CLOCK_MONOTONIC ) > deadline
6458+ sleep ( 0.005 )
6459+ end
6460+ end
6461+
63506462 test "listen keepalive is not started when the interval is nil" do
63516463 # The set of threads, not their count: `Thread.list` is process-wide, so a thread another test left running
63526464 # that finishes between the two samples moves the count in the direction this assertion does not care about.
0 commit comments