Skip to content

Commit e6e6fe8

Browse files
committed
fix(watch): created files notify the resource-list category
- `WatchAction::ReindexCode` carries `created`, set at the handler's known-path discrimination; first-time files broadcast `FileCreated`, re-indexes broadcast `FileReindexed` - both notification lanes map `FileCreated` to `list_changed` and `FileReindexed` to a URI-filtered `resources/updated`; a modify never notifies the list category - raw-wire locks pin the per-delete count: one delete emits exactly one `list_changed`, including for files created within the watch session - wire-lock harness creates session files through a retry write: the parent-dir watch may still be registering at session start, and a processed create makes the retry a known-file modify, which the list category filters
1 parent ad24446 commit e6e6fe8

4 files changed

Lines changed: 221 additions & 6 deletions

File tree

‎src/watcher/handler.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ use super::WatchError;
99
/// Actions returned by handlers for the UnifiedWatcher to execute.
1010
#[derive(Debug, Clone)]
1111
pub enum WatchAction {
12-
/// Re-index a code file.
13-
ReindexCode { path: PathBuf },
12+
/// Re-index a code file. `created` marks a first-time file (unknown
13+
/// to the index at event time): the broadcast becomes `FileCreated`,
14+
/// which the notification lanes map to `list_changed`.
15+
ReindexCode { path: PathBuf, created: bool },
1416

1517
/// Re-index a document file.
1618
ReindexDocument { path: PathBuf },

‎src/watcher/handlers/code.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ impl WatchHandler for CodeFileHandler {
178178
}
179179
Ok(WatchAction::ReindexCode {
180180
path: self.to_relative(path),
181+
created: !known,
181182
})
182183
}
183184

‎src/watcher/unified.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ impl UnifiedWatcher {
458458
handler_name: &str,
459459
) -> Result<(), WatchError> {
460460
match action {
461-
WatchAction::ReindexCode { path } => {
461+
WatchAction::ReindexCode { path, created } => {
462462
let mut indexer = self.facade.write().await;
463463
match indexer.index_file(&path) {
464464
Ok(result) => {
@@ -477,9 +477,15 @@ impl UnifiedWatcher {
477477
}
478478
}
479479

480-
// Notify
481-
self.broadcaster
482-
.send(FileChangeEvent::FileReindexed { path: path.clone() });
480+
// A first-time file grew the resource list;
481+
// the lanes map FileCreated to list_changed
482+
// and FileReindexed to a URI-filtered update.
483+
let event = if created {
484+
FileChangeEvent::FileCreated { path: path.clone() }
485+
} else {
486+
FileChangeEvent::FileReindexed { path: path.clone() }
487+
};
488+
self.broadcaster.send(event);
483489
}
484490
IndexingResult::Cached(_) => {
485491
crate::debug_event!(handler_name, "unchanged (hash match)");

‎tests/cli/test_serve_stdio_dual_generation.rs‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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("\npub 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

Comments
 (0)