@@ -559,6 +559,212 @@ fn serve_stdio_listen_delivers_tagged_change_notifications() {
559559 assert ! ( status. success( ) , "clean exit, got {status:?}" ) ;
560560}
561561
562+ /// A list-only subscriber is told when the resource list GROWS: a
563+ /// watched file created mid-session produces `list_changed`, while a
564+ /// modify of an existing file stays silent on that category.
565+ #[ test]
566+ fn serve_stdio_listen_list_only_subscriber_hears_creates ( ) {
567+ let workspace = seed_workspace ( ) ;
568+ let mut session = spawn_serve_watch ( workspace. path ( ) ) ;
569+
570+ writeln ! (
571+ session. stdin,
572+ "{}" ,
573+ json!( {
574+ "jsonrpc" : "2.0" ,
575+ "id" : 1 ,
576+ "method" : "tools/list" ,
577+ "params" : { "_meta" : stateless_meta( ) }
578+ } )
579+ )
580+ . expect ( "write session opener" ) ;
581+ session. stdin . flush ( ) . expect ( "flush opener" ) ;
582+ let opener = recv_json ( & session. rx ) ;
583+ assert_eq ! ( opener[ "id" ] , 1 , "opener response id\n {opener}" ) ;
584+
585+ writeln ! (
586+ session. stdin,
587+ "{}" ,
588+ json!( {
589+ "jsonrpc" : "2.0" ,
590+ "id" : 2 ,
591+ "method" : "subscriptions/listen" ,
592+ "params" : {
593+ "_meta" : stateless_meta( ) ,
594+ "notifications" : { "resourcesListChanged" : true }
595+ }
596+ } )
597+ )
598+ . expect ( "write listen" ) ;
599+ session. stdin . flush ( ) . expect ( "flush listen" ) ;
600+ let ack = recv_json ( & session. rx ) ;
601+ assert_eq ! (
602+ ack[ "method" ] , "notifications/subscriptions/acknowledged" ,
603+ "listen is acknowledged\n {ack}"
604+ ) ;
605+
606+ create_and_await_list_changed (
607+ & session. rx ,
608+ & workspace. path ( ) . join ( "src/beta.rs" ) ,
609+ "pub fn beta() -> i32 {\n 2\n }\n " ,
610+ ) ;
611+
612+ // A modify must not notify the list category (no over-notification).
613+ let fixture = workspace. path ( ) . join ( "src/alpha.rs" ) ;
614+ let mut content = std:: fs:: read_to_string ( & fixture) . expect ( "read fixture" ) ;
615+ content. push_str ( "\n pub fn epsilon() -> i32 {\n 5\n }\n " ) ;
616+ std:: fs:: write ( & fixture, content) . expect ( "modify watched file" ) ;
617+
618+ let quiet = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
619+ while let Some ( remaining) = quiet. checked_duration_since ( Instant :: now ( ) ) {
620+ match session. rx . recv_timeout ( remaining) {
621+ Ok ( line) => {
622+ let msg: Value = match serde_json:: from_str ( & line) {
623+ Ok ( v) => v,
624+ Err ( _) => continue ,
625+ } ;
626+ if msg[ "method" ] == "notifications/resources/list_changed" {
627+ panic ! ( "a modify must not notify the list category\n {msg}" ) ;
628+ }
629+ }
630+ Err ( _) => break ,
631+ }
632+ }
633+
634+ drop ( session. stdin ) ;
635+ let status = wait_with_timeout ( & mut session. child , Duration :: from_secs ( 10 ) ) ;
636+ assert ! ( status. success( ) , "clean exit, got {status:?}" ) ;
637+ }
638+
639+ /// Create a watched file and wait for its `list_changed`. The watch on
640+ /// the parent dir may still be registering at session start under
641+ /// parallel-suite load; a write before it lands emits no event.
642+ /// Re-touching is safe in every interleaving: a processed create makes
643+ /// the retry a known-file modify, which the list category filters.
644+ fn create_and_await_list_changed ( rx : & Receiver < String > , path : & Path , content : & str ) {
645+ for _ in 0 ..3 {
646+ std:: fs:: write ( path, content) . expect ( "write watched file" ) ;
647+ let deadline = Instant :: now ( ) + Duration :: from_secs ( 8 ) ;
648+ while let Some ( remaining) = deadline. checked_duration_since ( Instant :: now ( ) ) {
649+ match rx. recv_timeout ( remaining) {
650+ Ok ( line) => {
651+ let msg: Value = match serde_json:: from_str ( & line) {
652+ Ok ( v) => v,
653+ Err ( _) => continue ,
654+ } ;
655+ if msg[ "method" ] == "notifications/resources/list_changed" {
656+ return ;
657+ }
658+ }
659+ Err ( _) => break ,
660+ }
661+ }
662+ }
663+ panic ! ( "no list_changed for created file {}" , path. display( ) ) ;
664+ }
665+
666+ fn recv_list_changed ( rx : & Receiver < String > , what : & str ) {
667+ let deadline = Instant :: now ( ) + Duration :: from_secs ( 20 ) ;
668+ loop {
669+ let remaining = deadline
670+ . checked_duration_since ( Instant :: now ( ) )
671+ . unwrap_or_else ( || panic ! ( "list_changed before timeout: {what}" ) ) ;
672+ let line = rx
673+ . recv_timeout ( remaining)
674+ . unwrap_or_else ( |_| panic ! ( "server line before timeout: {what}" ) ) ;
675+ let msg: Value = match serde_json:: from_str ( & line) {
676+ Ok ( v) => v,
677+ Err ( _) => continue ,
678+ } ;
679+ if msg[ "method" ] == "notifications/resources/list_changed" {
680+ return ;
681+ }
682+ }
683+ }
684+
685+ fn assert_no_more_list_changed ( rx : & Receiver < String > , what : & str ) {
686+ let quiet = Instant :: now ( ) + Duration :: from_secs ( 5 ) ;
687+ while let Some ( remaining) = quiet. checked_duration_since ( Instant :: now ( ) ) {
688+ match rx. recv_timeout ( remaining) {
689+ Ok ( line) => {
690+ let msg: Value = match serde_json:: from_str ( & line) {
691+ Ok ( v) => v,
692+ Err ( _) => continue ,
693+ } ;
694+ if msg[ "method" ] == "notifications/resources/list_changed" {
695+ panic ! ( "duplicate list_changed: {what}\n {msg}" ) ;
696+ }
697+ }
698+ Err ( _) => break ,
699+ }
700+ }
701+ }
702+
703+ /// One delete, one `list_changed` — including for a file created within
704+ /// the same watch session (the historical double-broadcast shape). The
705+ /// settled-burst wave collapses duplicate and double-routed remove
706+ /// observations into one batch sync and one broadcast.
707+ #[ test]
708+ fn serve_stdio_listen_delete_notifies_list_exactly_once ( ) {
709+ let workspace = seed_workspace ( ) ;
710+ let mut session = spawn_serve_watch ( workspace. path ( ) ) ;
711+
712+ writeln ! (
713+ session. stdin,
714+ "{}" ,
715+ json!( {
716+ "jsonrpc" : "2.0" ,
717+ "id" : 1 ,
718+ "method" : "tools/list" ,
719+ "params" : { "_meta" : stateless_meta( ) }
720+ } )
721+ )
722+ . expect ( "write session opener" ) ;
723+ session. stdin . flush ( ) . expect ( "flush opener" ) ;
724+ let opener = recv_json ( & session. rx ) ;
725+ assert_eq ! ( opener[ "id" ] , 1 , "opener response id\n {opener}" ) ;
726+
727+ writeln ! (
728+ session. stdin,
729+ "{}" ,
730+ json!( {
731+ "jsonrpc" : "2.0" ,
732+ "id" : 2 ,
733+ "method" : "subscriptions/listen" ,
734+ "params" : {
735+ "_meta" : stateless_meta( ) ,
736+ "notifications" : { "resourcesListChanged" : true }
737+ }
738+ } )
739+ )
740+ . expect ( "write listen" ) ;
741+ session. stdin . flush ( ) . expect ( "flush listen" ) ;
742+ let ack = recv_json ( & session. rx ) ;
743+ assert_eq ! (
744+ ack[ "method" ] , "notifications/subscriptions/acknowledged" ,
745+ "listen is acknowledged\n {ack}"
746+ ) ;
747+
748+ // Create within the session; consume the create's own list_changed.
749+ let beta = workspace. path ( ) . join ( "src/beta.rs" ) ;
750+ create_and_await_list_changed ( & session. rx , & beta, "pub fn beta() -> i32 {\n 2\n }\n " ) ;
751+
752+ // The historical double-broadcast shape: delete the session-created
753+ // file. Exactly one list_changed.
754+ std:: fs:: remove_file ( & beta) . expect ( "delete session-created file" ) ;
755+ recv_list_changed ( & session. rx , "delete of session-created beta.rs" ) ;
756+ assert_no_more_list_changed ( & session. rx , "delete of session-created beta.rs" ) ;
757+
758+ // Control: delete a pre-session file. Exactly one.
759+ std:: fs:: remove_file ( workspace. path ( ) . join ( "src/alpha.rs" ) ) . expect ( "delete pre-session file" ) ;
760+ recv_list_changed ( & session. rx , "delete of pre-session alpha.rs" ) ;
761+ assert_no_more_list_changed ( & session. rx , "delete of pre-session alpha.rs" ) ;
762+
763+ drop ( session. stdin ) ;
764+ let status = wait_with_timeout ( & mut session. child , Duration :: from_secs ( 10 ) ) ;
765+ assert ! ( status. success( ) , "clean exit, got {status:?}" ) ;
766+ }
767+
562768/// A stateless session that never opts in via `subscriptions/listen`
563769/// receives zero unsolicited notifications when watched files change.
564770#[ test]
0 commit comments