1use std::{collections::HashMap, fmt, path::Path};
13
14use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
15use tinymist_std::{ImmutPath, error::IgnoreLogging};
16use tinymist_world::vfs::notify::NotifyDeps;
17use tokio::sync::mpsc;
18use typst::diag::{FileError, FileResult};
19
20use tinymist_world::vfs::{
21 Bytes, FileChangeSet, FileSnapshot, PathAccessModel,
22 notify::{FilesystemEvent, NotifyMessage, UpstreamUpdateEvent},
23 system::SystemAccessModel,
24};
25
26type WatcherPair = (RecommendedWatcher, mpsc::UnboundedReceiver<NotifyEvent>);
27type NotifyEvent = notify::Result<notify::Event>;
28type FileEntry = (ImmutPath, FileSnapshot);
29
30trait NotifyActorAccess: fmt::Debug + Send + Sync {
31 fn content(&self, src: &Path) -> FileResult<Bytes>;
32
33 fn is_watchable_file(&self, src: &Path) -> bool;
34}
35
36#[derive(Debug)]
37struct SystemNotifyActorAccess(SystemAccessModel);
38
39impl NotifyActorAccess for SystemNotifyActorAccess {
40 fn content(&self, src: &Path) -> FileResult<Bytes> {
41 self.0.content(src)
42 }
43
44 fn is_watchable_file(&self, src: &Path) -> bool {
45 src.metadata().is_ok_and(|meta| !meta.is_dir())
46 }
47}
48
49#[derive(Debug)]
50enum NotifyWatcher {
51 System(WatcherPair),
52 #[cfg(test)]
53 Fake(FakeWatcher),
54}
55
56impl NotifyWatcher {
57 async fn recv(&mut self) -> Option<NotifyEvent> {
58 match self {
59 Self::System((_, watcher_receiver)) => watcher_receiver.recv().await,
60 #[cfg(test)]
61 Self::Fake(_) => None,
62 }
63 }
64
65 fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
66 match self {
67 Self::System((watcher, _)) => watcher.watch(path, recursive_mode),
68 #[cfg(test)]
69 Self::Fake(watcher) => watcher.watch(path),
70 }
71 }
72
73 fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
74 match self {
75 Self::System((watcher, _)) => watcher.unwatch(path),
76 #[cfg(test)]
77 Self::Fake(watcher) => watcher.unwatch(path),
78 }
79 }
80}
81
82#[cfg(test)]
83#[derive(Debug, Clone, PartialEq, Eq)]
84enum FakeWatchCommand {
85 Watch(std::path::PathBuf),
86 Unwatch(std::path::PathBuf),
87}
88
89#[cfg(test)]
90#[derive(Debug, Default, Clone)]
91struct FakeWatchCommands(std::sync::Arc<std::sync::Mutex<Vec<FakeWatchCommand>>>);
92
93#[cfg(test)]
94impl FakeWatchCommands {
95 fn push(&self, command: FakeWatchCommand) {
96 self.0
97 .lock()
98 .expect("fake watch commands poisoned")
99 .push(command);
100 }
101
102 fn take(&self) -> Vec<FakeWatchCommand> {
103 std::mem::take(&mut *self.0.lock().expect("fake watch commands poisoned"))
104 }
105}
106
107#[cfg(test)]
108#[derive(Debug)]
109struct FakeWatcher {
110 commands: FakeWatchCommands,
111}
112
113#[cfg(test)]
114impl FakeWatcher {
115 fn watch(&self, path: &Path) -> notify::Result<()> {
116 self.commands
117 .push(FakeWatchCommand::Watch(path.to_path_buf()));
118 Ok(())
119 }
120
121 fn unwatch(&self, path: &Path) -> notify::Result<()> {
122 self.commands
123 .push(FakeWatchCommand::Unwatch(path.to_path_buf()));
124 Ok(())
125 }
126}
127
128#[derive(Debug)]
132enum WatchState {
133 Stable,
136 EmptyOrRemoval {
139 recheck_at: usize,
140 payload: FileSnapshot,
141 },
142}
143
144impl Default for WatchState {
146 fn default() -> Self {
147 Self::Stable
148 }
149}
150
151#[derive(Debug)]
153struct WatchedEntry {
154 lifetime: usize,
159 watching: bool,
161 seen: bool,
163 state: WatchState,
165 prev: Option<FileSnapshot>,
167}
168
169#[derive(Debug)]
171struct UndeterminedNotifyEvent {
172 at_realtime: tinymist_std::time::Instant,
174 at_logical_tick: usize,
176 path: ImmutPath,
178}
179
180#[derive(Debug)]
184pub struct NotifyActor<F: FnMut(FilesystemEvent)> {
185 inner: Box<dyn NotifyActorAccess>,
187
188 lifetime: usize,
190 logical_tick: usize,
192
193 undetermined_send: mpsc::UnboundedSender<UndeterminedNotifyEvent>,
195 undetermined_recv: mpsc::UnboundedReceiver<UndeterminedNotifyEvent>,
196
197 watched_entries: HashMap<ImmutPath, WatchedEntry>,
199
200 interrupted_by_events: F,
201
202 watcher: Option<NotifyWatcher>,
204}
205
206impl<F: FnMut(FilesystemEvent) + Send + Sync> NotifyActor<F> {
207 pub fn new(interrupted_by_events: F) -> Self {
209 let (undetermined_send, undetermined_recv) = mpsc::unbounded_channel();
210 let (watcher_tx, watcher_rx) = mpsc::unbounded_channel();
211 let watcher = log_notify_error(
212 RecommendedWatcher::new(
213 move |event| {
214 watcher_tx.send(event).log_error("failed to send fs notify");
215 },
216 Config::default(),
217 ),
218 "failed to create watcher",
219 );
220
221 NotifyActor {
222 inner: Box::new(SystemNotifyActorAccess(SystemAccessModel)),
223 lifetime: 1,
225 logical_tick: 1,
226
227 interrupted_by_events,
228
229 undetermined_send,
230 undetermined_recv,
231
232 watched_entries: HashMap::new(),
233 watcher: watcher.map(|it| NotifyWatcher::System((it, watcher_rx))),
234 }
235 }
236
237 #[cfg(test)]
238 fn new_for_test(
239 inner: Box<dyn NotifyActorAccess>,
240 commands: FakeWatchCommands,
241 interrupted_by_events: F,
242 ) -> Self {
243 let (undetermined_send, undetermined_recv) = mpsc::unbounded_channel();
244
245 NotifyActor {
246 inner,
247 lifetime: 1,
249 logical_tick: 1,
250
251 interrupted_by_events,
252
253 undetermined_send,
254 undetermined_recv,
255
256 watched_entries: HashMap::new(),
257 watcher: Some(NotifyWatcher::Fake(FakeWatcher { commands })),
258 }
259 }
260
261 async fn get_notify_event(watcher: &mut Option<NotifyWatcher>) -> Option<NotifyEvent> {
263 match watcher {
264 Some(watcher) => watcher.recv().await,
265 None => None,
266 }
267 }
268
269 pub async fn run(mut self, mut inbox: mpsc::UnboundedReceiver<NotifyMessage>) {
271 use NotifyMessage::*;
272 #[derive(Debug)]
274 enum ActorEvent {
275 ReCheck(UndeterminedNotifyEvent),
277 PollMissing,
279 Message(Option<NotifyMessage>),
281 NotifyEvent(NotifyEvent),
283 }
284
285 let mut missing_poll =
286 tokio::time::interval(tinymist_std::time::Duration::from_millis(300));
287 missing_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
288
289 'event_loop: loop {
290 let event = tokio::select! {
292 it = inbox.recv() => ActorEvent::Message(it),
293 Some(it) = Self::get_notify_event(&mut self.watcher) => ActorEvent::NotifyEvent(it),
294 Some(it) = self.undetermined_recv.recv() => ActorEvent::ReCheck(it),
295 _ = missing_poll.tick() => ActorEvent::PollMissing,
296 };
297
298 self.logical_tick += 1;
300
301 match event {
304 ActorEvent::Message(None) => {
305 log::info!("NotifyActor: failed to get event, exiting...");
306 break 'event_loop;
307 }
308 ActorEvent::Message(Some(Settle)) => {
309 log::info!("NotifyActor: settle event received");
310 break 'event_loop;
311 }
312 ActorEvent::Message(Some(UpstreamUpdate(event))) => {
313 self.invalidate_upstream(event);
314 }
315 ActorEvent::Message(Some(SyncDependency(paths))) => {
316 if let Some(changeset) = self.update_watches(paths.as_ref()) {
317 (self.interrupted_by_events)(FilesystemEvent::Update(changeset, true));
318 }
319 }
320 ActorEvent::NotifyEvent(event) => {
321 if let Some(event) = log_notify_error(event, "failed to notify") {
323 self.notify_event(event);
324 }
325 }
326 ActorEvent::ReCheck(event) => {
327 self.recheck_notify_event(event).await;
328 }
329 ActorEvent::PollMissing => {
330 self.poll_missing_watches();
331 }
332 }
333 }
334
335 log::info!("NotifyActor: exited");
336 }
337
338 fn invalidate_upstream(&mut self, event: UpstreamUpdateEvent) {
340 let changeset = self.update_watches(&event.invalidates).unwrap_or_default();
342
343 (self.interrupted_by_events)(FilesystemEvent::UpstreamUpdate {
345 changeset,
346 upstream_event: Some(event),
347 });
348 }
349
350 fn update_watches(&mut self, paths: &dyn NotifyDeps) -> Option<FileChangeSet> {
352 self.lifetime += 1;
354
355 let mut changeset = FileChangeSet::default();
356
357 for path in self.watched_entries.values_mut() {
359 path.seen = false;
360 }
361
362 paths.dependencies(&mut |path| {
367 let mut contained = false;
368 let entry = self
370 .watched_entries
371 .entry(path.clone())
372 .and_modify(|watch_entry| {
373 contained = true;
374 watch_entry.lifetime = self.lifetime;
375 })
376 .or_insert_with(|| WatchedEntry {
377 lifetime: self.lifetime,
378 watching: false,
379 seen: false,
380 state: WatchState::Stable,
381 prev: None,
382 });
383
384 if entry.seen {
385 return;
386 }
387 entry.seen = true;
388
389 if self.watcher.is_some() {
390 self.watch_file_if_needed(path, contained);
391
392 changeset.may_insert(self.notify_entry_update(path.clone()));
393 } else {
394 let watched = self.inner.content(path);
395 changeset.inserts.push((path.clone(), watched.into()));
396 }
397 });
398
399 self.watched_entries.retain(|path, entry| {
403 if !entry.seen && entry.watching {
404 log::debug!("unwatch {path:?}");
405 if let Some(watcher) = &mut self.watcher {
406 log_notify_error(watcher.unwatch(path), "failed to unwatch");
407 entry.watching = false;
408 }
409 }
410
411 let fresh = self.lifetime - entry.lifetime < 30;
412 if !fresh {
413 changeset.removes.push(path.clone());
414 }
415 fresh
416 });
417
418 (!changeset.is_empty()).then_some(changeset)
419 }
420
421 fn watch_file_if_needed(&mut self, path: &ImmutPath, contained: bool) {
422 let Some(entry) = self.watched_entries.get_mut(path) else {
423 return;
424 };
425
426 if !self.inner.is_watchable_file(path.as_ref()) {
427 return;
428 }
429
430 if !contained || !entry.watching {
435 log::debug!("watching {path:?}");
436 if let Some(watcher) = &mut self.watcher {
437 entry.watching = log_notify_error(
438 watcher.watch(path.as_ref(), RecursiveMode::NonRecursive),
439 "failed to watch",
440 )
441 .is_some();
442 }
443 }
444 }
445
446 fn poll_missing_watches(&mut self) {
447 if self.watcher.is_none() {
448 return;
449 }
450
451 let paths = self
452 .watched_entries
453 .iter()
454 .filter(|(_, entry)| entry.seen && !entry.watching && entry_is_known_missing(entry))
455 .filter(|&(path, _)| self.inner.is_watchable_file(path.as_ref()))
456 .map(|(path, _)| path.clone())
457 .collect::<Vec<_>>();
458
459 if paths.is_empty() {
460 return;
461 }
462
463 let mut changeset = FileChangeSet::default();
464 for path in paths {
465 self.watch_file_if_needed(&path, true);
466 changeset.may_insert(self.notify_entry_update(path));
467 }
468
469 if !changeset.is_empty() {
470 (self.interrupted_by_events)(FilesystemEvent::Update(changeset, false));
471 }
472 }
473
474 fn notify_event(&mut self, event: notify::Event) {
476 if !is_relevant_event_kind(&event.kind) {
477 return;
478 }
479
480 let mut changeset = FileChangeSet::default();
482 for path in event.paths.iter() {
483 changeset.may_insert(self.notify_entry_update(path.as_path().into()));
485 }
486
487 if matches!(
493 event.kind,
494 notify::EventKind::Remove(notify::event::RemoveKind::File)
495 | notify::EventKind::Modify(notify::event::ModifyKind::Name(
496 notify::event::RenameMode::From
497 ))
498 ) {
499 for path in &event.paths {
500 let Some(entry) = self.watched_entries.get_mut(path.as_path()) else {
501 continue;
502 };
503 if !entry.watching {
504 continue;
505 }
506 if let Some(watcher) = &mut self.watcher {
509 log_notify_error(watcher.unwatch(path), "failed to unwatch");
510 }
511 entry.watching = false;
512 }
513 }
514
515 if !changeset.is_empty() {
517 (self.interrupted_by_events)(FilesystemEvent::Update(changeset, false));
518 }
519 }
520
521 fn notify_entry_update(&mut self, path: ImmutPath) -> Option<FileEntry> {
523 let entry = self.watched_entries.get_mut(&path)?;
534
535 let file = FileSnapshot::from(self.inner.content(&path));
537
538 match (entry.prev.as_deref(), file.as_ref()) {
541 (None, ..) | (Some(Err(..)), Ok(..)) => {}
546 (Some(it), Err(err)) => match &mut entry.state {
548 WatchState::Stable => {
552 if matches!(err.as_ref(), FileError::NotFound(..) | FileError::Other(..)) {
553 entry.state = WatchState::EmptyOrRemoval {
554 recheck_at: self.logical_tick,
555 payload: file.clone(),
556 };
557 entry.prev = Some(file);
558 let event = UndeterminedNotifyEvent {
559 at_realtime: tinymist_std::time::Instant::now(),
560 at_logical_tick: self.logical_tick,
561 path: path.clone(),
562 };
563 log_send_error("recheck", self.undetermined_send.send(event));
564 return None;
565 }
566 if it.as_ref().is_err_and(|it| it == err) {
570 return None;
571 }
572 }
573
574 WatchState::EmptyOrRemoval { payload, .. } => {
577 *payload = file;
579 return None;
580 }
581 },
582 (Some(Ok(prev_content)), Ok(next_content)) => {
584 if prev_content == next_content {
586 return None;
587 }
588
589 match entry.state {
590 WatchState::Stable => {
594 if next_content.is_empty() {
595 entry.state = WatchState::EmptyOrRemoval {
596 recheck_at: self.logical_tick,
597 payload: file.clone(),
598 };
599 entry.prev = Some(file);
600 let event = UndeterminedNotifyEvent {
601 at_realtime: tinymist_std::time::Instant::now(),
602 at_logical_tick: self.logical_tick,
603 path,
604 };
605 log_send_error("recheck", self.undetermined_send.send(event));
606 return None;
607 }
608 }
609
610 WatchState::EmptyOrRemoval { .. } if next_content.is_empty() => return None,
612 WatchState::EmptyOrRemoval { .. } => {}
614 }
615 }
616 };
617
618 entry.state = WatchState::Stable;
621 entry.prev = Some(file.clone());
622
623 Some((path, file))
625 }
626
627 async fn recheck_notify_event(&mut self, event: UndeterminedNotifyEvent) -> Option<()> {
629 let now = tinymist_std::time::Instant::now();
630 log::debug!("recheck event {event:?} at {now:?}");
631
632 let reserved = now - event.at_realtime;
634 if reserved < tinymist_std::time::Duration::from_millis(50) {
635 let send = self.undetermined_send.clone();
636 tokio::spawn(async move {
637 tokio::time::sleep(tinymist_std::time::Duration::from_millis(50) - reserved).await;
639 log_send_error("reschedule", send.send(event));
640 });
641 return None;
642 }
643
644 let entry = self.watched_entries.get_mut(&event.path)?;
646
647 match std::mem::take(&mut entry.state) {
649 WatchState::Stable => {}
651 WatchState::EmptyOrRemoval {
654 recheck_at,
655 payload,
656 } => {
657 if recheck_at == event.at_logical_tick {
658 log::debug!("notify event real happened {event:?}, state: {payload:?}");
659
660 let mut changeset = FileChangeSet::default();
662 changeset.inserts.push((event.path, payload));
663
664 (self.interrupted_by_events)(FilesystemEvent::Update(changeset, false));
665 }
666 }
667 };
668
669 Some(())
670 }
671}
672
673fn is_relevant_event_kind(kind: ¬ify::EventKind) -> bool {
675 match kind {
676 notify::EventKind::Any => true,
677 notify::EventKind::Access(_) => false,
678 notify::EventKind::Create(_) => true,
679 notify::EventKind::Modify(kind) => match kind {
680 notify::event::ModifyKind::Any => true,
681 notify::event::ModifyKind::Data(_) => true,
682 notify::event::ModifyKind::Metadata(_) => false,
683 notify::event::ModifyKind::Name(_) => true,
684 notify::event::ModifyKind::Other => false,
685 },
686 notify::EventKind::Remove(_) => true,
687 notify::EventKind::Other => false,
688 }
689}
690
691#[inline]
692fn log_notify_error<T>(res: notify::Result<T>, reason: &'static str) -> Option<T> {
693 res.map_err(|err| log::warn!("{reason}: notify error: {err}"))
694 .ok()
695}
696
697#[inline]
698fn log_send_error<T>(chan: &'static str, res: Result<(), mpsc::error::SendError<T>>) -> bool {
699 res.map_err(|err| log::warn!("NotifyActor: send to {chan} error: {err}"))
700 .is_ok()
701}
702
703pub async fn watch_deps(
705 inbox: mpsc::UnboundedReceiver<NotifyMessage>,
706 interrupted_by_events: impl FnMut(FilesystemEvent) + Send + Sync + 'static,
707) {
708 log::info!("NotifyActor: start watching files...");
709 spawn_watch_deps(inbox, interrupted_by_events);
711}
712
713fn spawn_watch_deps(
714 inbox: mpsc::UnboundedReceiver<NotifyMessage>,
715 interrupted_by_events: impl FnMut(FilesystemEvent) + Send + Sync + 'static,
716) -> tokio::task::JoinHandle<()> {
717 tokio::spawn(NotifyActor::new(interrupted_by_events).run(inbox))
718}
719
720fn entry_is_known_missing(entry: &WatchedEntry) -> bool {
721 entry.prev.as_ref().is_some_and(|snapshot| {
722 matches!(snapshot.as_ref(), Err(err) if matches!(err.as_ref(), FileError::NotFound(..)))
723 })
724}
725
726#[cfg(test)]
727mod tests {
728 use std::{
729 collections::HashMap,
730 path::{Path, PathBuf},
731 sync::{Arc, Mutex},
732 };
733
734 use notify::event::{CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode};
735
736 use super::*;
737
738 type EventSink = Box<dyn FnMut(FilesystemEvent) + Send + Sync>;
739
740 #[derive(Debug, Clone)]
748 struct TestFile {
749 snapshot: FileSnapshot,
750 watchable: bool,
751 }
752
753 #[derive(Debug, Default, Clone)]
754 struct TestAccess {
755 files: Arc<Mutex<HashMap<PathBuf, TestFile>>>,
756 reads: Arc<Mutex<HashMap<PathBuf, usize>>>,
757 }
758
759 impl TestAccess {
760 fn set_content(&self, path: &ImmutPath, content: &str) {
761 self.set_snapshot(path, content_snapshot(content), true);
762 }
763
764 fn set_empty(&self, path: &ImmutPath) {
765 self.set_content(path, "");
766 }
767
768 fn set_error(&self, path: &ImmutPath) {
769 self.set_snapshot(
770 path,
771 Err::<Bytes, FileError>(FileError::Other(None)).into(),
772 true,
773 );
774 }
775
776 fn set_missing(&self, path: &ImmutPath) {
777 self.files
778 .lock()
779 .expect("test access poisoned")
780 .remove(path.as_ref());
781 }
782
783 fn set_snapshot(&self, path: &ImmutPath, snapshot: FileSnapshot, watchable: bool) {
784 self.files.lock().expect("test access poisoned").insert(
785 path.as_ref().to_path_buf(),
786 TestFile {
787 snapshot,
788 watchable,
789 },
790 );
791 }
792
793 fn read_count(&self, path: &ImmutPath) -> usize {
794 self.reads
795 .lock()
796 .expect("test read counts poisoned")
797 .get(path.as_ref())
798 .copied()
799 .unwrap_or_default()
800 }
801 }
802
803 impl NotifyActorAccess for TestAccess {
804 fn content(&self, src: &Path) -> FileResult<Bytes> {
805 *self
806 .reads
807 .lock()
808 .expect("test read counts poisoned")
809 .entry(src.to_path_buf())
810 .or_default() += 1;
811
812 self.files
813 .lock()
814 .expect("test access poisoned")
815 .get(src)
816 .map_or_else(
817 || Err(FileError::NotFound(src.into())),
818 |file| file.snapshot.content().cloned(),
819 )
820 }
821
822 fn is_watchable_file(&self, src: &Path) -> bool {
823 self.files
824 .lock()
825 .expect("test access poisoned")
826 .get(src)
827 .is_some_and(|file| file.watchable)
828 }
829 }
830
831 #[derive(Debug)]
832 enum MatrixInput {
833 SyncDependency(Vec<ImmutPath>),
834 UpstreamInvalidation {
835 invalidates: Vec<ImmutPath>,
836 opaque: usize,
837 },
838 WatcherEvent {
839 kind: notify::EventKind,
840 paths: Vec<ImmutPath>,
841 },
842 DelayedRecheck(ImmutPath),
843 DelayedRecheckAt {
844 path: ImmutPath,
845 recheck_at: usize,
846 },
847 PollMissing,
848 }
849
850 struct NotifyActorHarness {
851 access: TestAccess,
852 commands: FakeWatchCommands,
853 events: Arc<Mutex<Vec<FilesystemEvent>>>,
854 actor: NotifyActor<EventSink>,
855 }
856
857 impl NotifyActorHarness {
858 fn new() -> Self {
859 let access = TestAccess::default();
860 let commands = FakeWatchCommands::default();
861 let events = Arc::new(Mutex::new(Vec::new()));
862 let sink_events = events.clone();
863 let sink: EventSink = Box::new(move |event| {
864 sink_events
865 .lock()
866 .expect("test event sink poisoned")
867 .push(event);
868 });
869 let actor = NotifyActor::new_for_test(Box::new(access.clone()), commands.clone(), sink);
870
871 Self {
872 access,
873 commands,
874 events,
875 actor,
876 }
877 }
878
879 async fn apply(&mut self, input: MatrixInput) {
880 self.actor.logical_tick += 1;
881
882 match input {
883 MatrixInput::SyncDependency(paths) => {
884 if let Some(changeset) = self.actor.update_watches(&paths) {
885 (self.actor.interrupted_by_events)(FilesystemEvent::Update(
886 changeset, true,
887 ));
888 }
889 }
890 MatrixInput::UpstreamInvalidation {
891 invalidates,
892 opaque,
893 } => {
894 self.actor.invalidate_upstream(UpstreamUpdateEvent {
895 invalidates,
896 opaque: Box::new(opaque),
897 });
898 }
899 MatrixInput::WatcherEvent { kind, paths } => {
900 self.actor.notify_event(notify_event(kind, paths));
901 }
902 MatrixInput::DelayedRecheck(path) => {
903 let recheck_at = self.pending_recheck_at(&path);
904 self.force_recheck_at(path, recheck_at).await;
905 }
906 MatrixInput::DelayedRecheckAt { path, recheck_at } => {
907 self.force_recheck_at(path, recheck_at).await;
908 }
909 MatrixInput::PollMissing => {
910 self.actor.poll_missing_watches();
911 }
912 }
913 }
914
915 fn pending_recheck_at(&self, path: &ImmutPath) -> usize {
916 match self
917 .actor
918 .watched_entries
919 .get(path)
920 .expect("watched entry must exist for delayed recheck")
921 .state
922 {
923 WatchState::EmptyOrRemoval { recheck_at, .. } => recheck_at,
924 WatchState::Stable => panic!("watched entry must be pending recheck"),
925 }
926 }
927
928 async fn force_recheck_at(&mut self, path: ImmutPath, recheck_at: usize) {
929 self.actor
930 .recheck_notify_event(UndeterminedNotifyEvent {
931 at_realtime: tinymist_std::time::Instant::now()
932 - tinymist_std::time::Duration::from_millis(60),
933 at_logical_tick: recheck_at,
934 path,
935 })
936 .await;
937 }
938
939 fn take_events(&self) -> Vec<FilesystemEvent> {
940 std::mem::take(&mut *self.events.lock().expect("test event sink poisoned"))
941 }
942
943 fn assert_no_events(&self) {
944 assert!(
945 self.events
946 .lock()
947 .expect("test event sink poisoned")
948 .is_empty(),
949 "expected no filesystem events"
950 );
951 }
952
953 fn take_commands(&self) -> Vec<FakeWatchCommand> {
954 self.commands.take()
955 }
956
957 fn assert_watching(&self, path: &ImmutPath, expected: bool) {
958 assert_eq!(
959 self.actor
960 .watched_entries
961 .get(path)
962 .map(|entry| entry.watching),
963 Some(expected)
964 );
965 }
966 }
967
968 #[tokio::test(flavor = "current_thread")]
969 async fn sync_dependency_updates_watch_set_and_changed_contents() {
970 let mut harness = NotifyActorHarness::new();
971 let first = test_path("sync-first.typ");
972 let second = test_path("sync-second.typ");
973
974 harness.access.set_content(&first, "first-v1");
975 harness.access.set_content(&second, "second-v1");
976 harness
977 .apply(MatrixInput::SyncDependency(vec![
978 first.clone(),
979 second.clone(),
980 ]))
981 .await;
982
983 assert_eq!(harness.take_commands(), vec![watch(&first), watch(&second)]);
984 let events = harness.take_events();
985 assert_eq!(events.len(), 1);
986 assert_update(
987 &events[0],
988 true,
989 &[
990 (&first, ExpectedSnapshot::Content("first-v1")),
991 (&second, ExpectedSnapshot::Content("second-v1")),
992 ],
993 );
994
995 harness.access.set_content(&first, "first-v2");
996 harness
997 .apply(MatrixInput::SyncDependency(vec![
998 first.clone(),
999 second.clone(),
1000 ]))
1001 .await;
1002
1003 assert_eq!(harness.take_commands(), Vec::new());
1004 let events = harness.take_events();
1005 assert_eq!(events.len(), 1);
1006 assert_update(
1007 &events[0],
1008 true,
1009 &[(&first, ExpectedSnapshot::Content("first-v2"))],
1010 );
1011
1012 harness
1013 .apply(MatrixInput::SyncDependency(vec![first.clone()]))
1014 .await;
1015
1016 assert_eq!(harness.take_commands(), vec![unwatch(&second)]);
1017 harness.assert_no_events();
1018
1019 harness.access.set_content(&second, "second-v2");
1020 harness
1021 .apply(MatrixInput::SyncDependency(vec![
1022 first.clone(),
1023 second.clone(),
1024 ]))
1025 .await;
1026
1027 assert_eq!(harness.take_commands(), vec![watch(&second)]);
1028 let events = harness.take_events();
1029 assert_eq!(events.len(), 1);
1030 assert_update(
1031 &events[0],
1032 true,
1033 &[(&second, ExpectedSnapshot::Content("second-v2"))],
1034 );
1035 }
1036
1037 #[tokio::test(flavor = "current_thread")]
1038 async fn create_and_modify_events_update_watched_dependencies() {
1039 let mut harness = NotifyActorHarness::new();
1040 let dep = test_path("create-modify.typ");
1041
1042 harness.access.set_content(&dep, "initial");
1043 harness
1044 .apply(MatrixInput::SyncDependency(vec![dep.clone()]))
1045 .await;
1046 harness.take_events();
1047 harness.take_commands();
1048
1049 harness.access.set_content(&dep, "created");
1050 harness
1051 .apply(MatrixInput::WatcherEvent {
1052 kind: notify::EventKind::Create(CreateKind::File),
1053 paths: vec![dep.clone()],
1054 })
1055 .await;
1056
1057 let events = harness.take_events();
1058 assert_eq!(events.len(), 1);
1059 assert_update(
1060 &events[0],
1061 false,
1062 &[(&dep, ExpectedSnapshot::Content("created"))],
1063 );
1064
1065 harness.access.set_content(&dep, "modified");
1066 harness
1067 .apply(MatrixInput::WatcherEvent {
1068 kind: modify_data_event(),
1069 paths: vec![dep.clone()],
1070 })
1071 .await;
1072
1073 let events = harness.take_events();
1074 assert_eq!(events.len(), 1);
1075 assert_update(
1076 &events[0],
1077 false,
1078 &[(&dep, ExpectedSnapshot::Content("modified"))],
1079 );
1080
1081 harness
1082 .apply(MatrixInput::WatcherEvent {
1083 kind: modify_data_event(),
1084 paths: vec![dep.clone()],
1085 })
1086 .await;
1087
1088 harness.assert_no_events();
1089 assert_eq!(harness.take_commands(), Vec::new());
1090 }
1091
1092 #[tokio::test(flavor = "current_thread")]
1093 async fn raw_events_for_unwatched_paths_are_ignored() {
1094 let mut harness = NotifyActorHarness::new();
1095 let watched = test_path("watched.typ");
1096 let unwatched = test_path("unwatched.typ");
1097
1098 harness.access.set_content(&watched, "watched");
1099 harness.access.set_content(&unwatched, "unwatched");
1100 harness
1101 .apply(MatrixInput::SyncDependency(vec![watched.clone()]))
1102 .await;
1103 harness.take_events();
1104 harness.take_commands();
1105
1106 harness.access.set_content(&unwatched, "unwatched-change");
1107 harness
1108 .apply(MatrixInput::WatcherEvent {
1109 kind: modify_data_event(),
1110 paths: vec![unwatched],
1111 })
1112 .await;
1113
1114 harness.assert_no_events();
1115 assert_eq!(harness.take_commands(), Vec::new());
1116 }
1117
1118 #[tokio::test(flavor = "current_thread")]
1119 async fn irrelevant_events_are_ignored_without_rereading_watched_file() {
1120 let mut harness = NotifyActorHarness::new();
1121 let dep = test_path("irrelevant-events.typ");
1122
1123 harness.access.set_content(&dep, "stable");
1124 harness
1125 .apply(MatrixInput::SyncDependency(vec![dep.clone()]))
1126 .await;
1127 harness.take_events();
1128 harness.take_commands();
1129
1130 let irrelevant_kinds = [
1131 notify::EventKind::Access(notify::event::AccessKind::Open(
1132 notify::event::AccessMode::Any,
1133 )),
1134 notify::EventKind::Access(notify::event::AccessKind::Close(
1135 notify::event::AccessMode::Read,
1136 )),
1137 notify::EventKind::Modify(notify::event::ModifyKind::Metadata(
1138 notify::event::MetadataKind::Any,
1139 )),
1140 notify::EventKind::Modify(notify::event::ModifyKind::Other),
1141 notify::EventKind::Other,
1142 ];
1143
1144 for kind in irrelevant_kinds {
1145 let reads_before_event = harness.access.read_count(&dep);
1146 harness
1147 .apply(MatrixInput::WatcherEvent {
1148 kind,
1149 paths: vec![dep.clone()],
1150 })
1151 .await;
1152
1153 harness.assert_no_events();
1154 assert_eq!(harness.take_commands(), Vec::new());
1155 assert_eq!(harness.access.read_count(&dep), reads_before_event);
1156 }
1157 }
1158
1159 #[tokio::test(flavor = "current_thread")]
1160 async fn remove_and_rename_from_reset_watch_state_and_confirm_changes() {
1161 let mut harness = NotifyActorHarness::new();
1162 let dep = test_path("remove-rename-from.typ");
1163
1164 harness.access.set_content(&dep, "alive");
1165 harness
1166 .apply(MatrixInput::SyncDependency(vec![dep.clone()]))
1167 .await;
1168 harness.take_events();
1169 harness.take_commands();
1170
1171 harness.access.set_missing(&dep);
1172 harness
1173 .apply(MatrixInput::WatcherEvent {
1174 kind: notify::EventKind::Remove(RemoveKind::File),
1175 paths: vec![dep.clone()],
1176 })
1177 .await;
1178
1179 assert_eq!(harness.take_commands(), vec![unwatch(&dep)]);
1180 harness.assert_watching(&dep, false);
1181 harness.assert_no_events();
1182
1183 harness
1184 .apply(MatrixInput::DelayedRecheck(dep.clone()))
1185 .await;
1186
1187 let events = harness.take_events();
1188 assert_eq!(events.len(), 1);
1189 assert_update(&events[0], false, &[(&dep, ExpectedSnapshot::NotFound)]);
1190
1191 harness.access.set_content(&dep, "restored");
1192 harness
1193 .apply(MatrixInput::SyncDependency(vec![dep.clone()]))
1194 .await;
1195
1196 assert_eq!(harness.take_commands(), vec![watch(&dep)]);
1197 harness.assert_watching(&dep, true);
1198 let events = harness.take_events();
1199 assert_eq!(events.len(), 1);
1200 assert_update(
1201 &events[0],
1202 true,
1203 &[(&dep, ExpectedSnapshot::Content("restored"))],
1204 );
1205
1206 harness.access.set_missing(&dep);
1207 harness
1208 .apply(MatrixInput::WatcherEvent {
1209 kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)),
1210 paths: vec![dep.clone()],
1211 })
1212 .await;
1213
1214 assert_eq!(harness.take_commands(), vec![unwatch(&dep)]);
1215 harness.assert_watching(&dep, false);
1216 harness.assert_no_events();
1217
1218 harness
1219 .apply(MatrixInput::DelayedRecheck(dep.clone()))
1220 .await;
1221
1222 let events = harness.take_events();
1223 assert_eq!(events.len(), 1);
1224 assert_update(&events[0], false, &[(&dep, ExpectedSnapshot::NotFound)]);
1225 }
1226
1227 #[tokio::test(flavor = "current_thread")]
1228 async fn missing_poll_rewatches_recreated_dependency_without_notify_event() {
1229 let mut harness = NotifyActorHarness::new();
1230 let dep = test_path("missing-poll-recreate.typ");
1231
1232 harness.access.set_content(&dep, "alive");
1233 harness
1234 .apply(MatrixInput::SyncDependency(vec![dep.clone()]))
1235 .await;
1236 harness.take_events();
1237 harness.take_commands();
1238
1239 harness.access.set_missing(&dep);
1240 harness
1241 .apply(MatrixInput::WatcherEvent {
1242 kind: notify::EventKind::Remove(RemoveKind::File),
1243 paths: vec![dep.clone()],
1244 })
1245 .await;
1246 assert_eq!(harness.take_commands(), vec![unwatch(&dep)]);
1247
1248 harness
1249 .apply(MatrixInput::DelayedRecheck(dep.clone()))
1250 .await;
1251 let events = harness.take_events();
1252 assert_eq!(events.len(), 1);
1253 assert_update(&events[0], false, &[(&dep, ExpectedSnapshot::NotFound)]);
1254
1255 harness.access.set_content(&dep, "recreated");
1256 harness.apply(MatrixInput::PollMissing).await;
1257
1258 assert_eq!(harness.take_commands(), vec![watch(&dep)]);
1259 harness.assert_watching(&dep, true);
1260 let events = harness.take_events();
1261 assert_eq!(events.len(), 1);
1262 assert_update(
1263 &events[0],
1264 false,
1265 &[(&dep, ExpectedSnapshot::Content("recreated"))],
1266 );
1267 }
1268
1269 #[tokio::test(flavor = "current_thread")]
1270 async fn rename_to_paired_rename_and_multi_path_events_are_mapped() {
1271 let mut harness = NotifyActorHarness::new();
1272 let rename_to = test_path("rename-to.typ");
1273 let rename_from = test_path("paired-from.typ");
1274 let paired_to = test_path("paired-to.typ");
1275 let multi_first = test_path("multi-first.typ");
1276 let multi_second = test_path("multi-second.typ");
1277 let ignored = test_path("multi-ignored.typ");
1278
1279 for path in [
1280 &rename_to,
1281 &rename_from,
1282 &paired_to,
1283 &multi_first,
1284 &multi_second,
1285 ] {
1286 harness.access.set_content(path, "initial");
1287 }
1288 harness.access.set_content(&ignored, "ignored");
1289 harness
1290 .apply(MatrixInput::SyncDependency(vec![
1291 rename_to.clone(),
1292 rename_from.clone(),
1293 paired_to.clone(),
1294 multi_first.clone(),
1295 multi_second.clone(),
1296 ]))
1297 .await;
1298 harness.take_events();
1299 harness.take_commands();
1300
1301 harness.access.set_content(&rename_to, "rename-to-content");
1302 harness
1303 .apply(MatrixInput::WatcherEvent {
1304 kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::To)),
1305 paths: vec![rename_to.clone()],
1306 })
1307 .await;
1308
1309 let events = harness.take_events();
1310 assert_eq!(events.len(), 1);
1311 assert_update(
1312 &events[0],
1313 false,
1314 &[(&rename_to, ExpectedSnapshot::Content("rename-to-content"))],
1315 );
1316 assert_eq!(harness.take_commands(), Vec::new());
1317
1318 harness.access.set_missing(&rename_from);
1319 harness.access.set_content(&paired_to, "paired-to-content");
1320 harness
1321 .apply(MatrixInput::WatcherEvent {
1322 kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
1323 paths: vec![rename_from.clone(), paired_to.clone()],
1324 })
1325 .await;
1326
1327 let events = harness.take_events();
1328 assert_eq!(events.len(), 1);
1329 assert_update(
1330 &events[0],
1331 false,
1332 &[(&paired_to, ExpectedSnapshot::Content("paired-to-content"))],
1333 );
1334 assert_eq!(harness.take_commands(), Vec::new());
1335
1336 harness
1337 .apply(MatrixInput::DelayedRecheck(rename_from.clone()))
1338 .await;
1339
1340 let events = harness.take_events();
1341 assert_eq!(events.len(), 1);
1342 assert_update(
1343 &events[0],
1344 false,
1345 &[(&rename_from, ExpectedSnapshot::NotFound)],
1346 );
1347
1348 harness
1349 .access
1350 .set_content(&multi_first, "multi-first-content");
1351 harness
1352 .access
1353 .set_content(&multi_second, "multi-second-content");
1354 harness.access.set_content(&ignored, "ignored-content");
1355 harness
1356 .apply(MatrixInput::WatcherEvent {
1357 kind: modify_data_event(),
1358 paths: vec![multi_first.clone(), multi_second.clone(), ignored],
1359 })
1360 .await;
1361
1362 let events = harness.take_events();
1363 assert_eq!(events.len(), 1);
1364 assert_update(
1365 &events[0],
1366 false,
1367 &[
1368 (
1369 &multi_first,
1370 ExpectedSnapshot::Content("multi-first-content"),
1371 ),
1372 (
1373 &multi_second,
1374 ExpectedSnapshot::Content("multi-second-content"),
1375 ),
1376 ],
1377 );
1378 assert_eq!(harness.take_commands(), Vec::new());
1379 }
1380
1381 #[tokio::test(flavor = "current_thread")]
1382 async fn unstable_reads_delay_confirmation_and_recover_before_recheck() {
1383 let mut harness = NotifyActorHarness::new();
1384 let empty = test_path("unstable-empty.typ");
1385 let missing = test_path("unstable-missing.typ");
1386 let errored = test_path("unstable-error.typ");
1387 let recovery = test_path("unstable-recovery.typ");
1388
1389 for path in [&empty, &missing, &errored, &recovery] {
1390 harness.access.set_content(path, "stable");
1391 }
1392 harness
1393 .apply(MatrixInput::SyncDependency(vec![
1394 empty.clone(),
1395 missing.clone(),
1396 errored.clone(),
1397 recovery.clone(),
1398 ]))
1399 .await;
1400 harness.take_events();
1401 harness.take_commands();
1402
1403 harness.access.set_empty(&empty);
1404 harness
1405 .apply(MatrixInput::WatcherEvent {
1406 kind: modify_data_event(),
1407 paths: vec![empty.clone()],
1408 })
1409 .await;
1410 harness.assert_no_events();
1411 harness
1412 .apply(MatrixInput::DelayedRecheck(empty.clone()))
1413 .await;
1414
1415 let events = harness.take_events();
1416 assert_eq!(events.len(), 1);
1417 assert_update(
1418 &events[0],
1419 false,
1420 &[(&empty, ExpectedSnapshot::Content(""))],
1421 );
1422
1423 harness.access.set_missing(&missing);
1424 harness
1425 .apply(MatrixInput::WatcherEvent {
1426 kind: modify_data_event(),
1427 paths: vec![missing.clone()],
1428 })
1429 .await;
1430 harness.assert_no_events();
1431 harness
1432 .apply(MatrixInput::DelayedRecheck(missing.clone()))
1433 .await;
1434
1435 let events = harness.take_events();
1436 assert_eq!(events.len(), 1);
1437 assert_update(&events[0], false, &[(&missing, ExpectedSnapshot::NotFound)]);
1438
1439 harness.access.set_error(&errored);
1440 harness
1441 .apply(MatrixInput::WatcherEvent {
1442 kind: modify_data_event(),
1443 paths: vec![errored.clone()],
1444 })
1445 .await;
1446 harness.assert_no_events();
1447 harness
1448 .apply(MatrixInput::DelayedRecheck(errored.clone()))
1449 .await;
1450
1451 let events = harness.take_events();
1452 assert_eq!(events.len(), 1);
1453 assert_update(&events[0], false, &[(&errored, ExpectedSnapshot::Other)]);
1454
1455 harness.access.set_empty(&recovery);
1456 harness
1457 .apply(MatrixInput::WatcherEvent {
1458 kind: modify_data_event(),
1459 paths: vec![recovery.clone()],
1460 })
1461 .await;
1462 harness.assert_no_events();
1463 let recovery_recheck_at = harness.pending_recheck_at(&recovery);
1464
1465 harness.access.set_content(&recovery, "recovered");
1466 harness
1467 .apply(MatrixInput::WatcherEvent {
1468 kind: modify_data_event(),
1469 paths: vec![recovery.clone()],
1470 })
1471 .await;
1472
1473 let events = harness.take_events();
1474 assert_eq!(events.len(), 1);
1475 assert_update(
1476 &events[0],
1477 false,
1478 &[(&recovery, ExpectedSnapshot::Content("recovered"))],
1479 );
1480
1481 harness
1482 .apply(MatrixInput::DelayedRecheckAt {
1483 path: recovery.clone(),
1484 recheck_at: recovery_recheck_at,
1485 })
1486 .await;
1487 harness.assert_no_events();
1488 }
1489
1490 #[tokio::test(flavor = "current_thread")]
1491 async fn upstream_invalidation_refreshes_watches_and_carries_payload() {
1492 let mut harness = NotifyActorHarness::new();
1493 let existing = test_path("upstream-existing.typ");
1494 let added = test_path("upstream-added.typ");
1495
1496 harness.access.set_content(&existing, "existing-v1");
1497 harness
1498 .apply(MatrixInput::SyncDependency(vec![existing.clone()]))
1499 .await;
1500 harness.take_events();
1501 harness.take_commands();
1502
1503 harness.access.set_content(&existing, "existing-v2");
1504 harness.access.set_content(&added, "added-v1");
1505 harness
1506 .apply(MatrixInput::UpstreamInvalidation {
1507 invalidates: vec![existing.clone(), added.clone()],
1508 opaque: 42,
1509 })
1510 .await;
1511
1512 assert_eq!(harness.take_commands(), vec![watch(&added)]);
1513 let events = harness.take_events();
1514 assert_eq!(events.len(), 1);
1515 assert_upstream_update(
1516 &events[0],
1517 &[
1518 (&existing, ExpectedSnapshot::Content("existing-v2")),
1519 (&added, ExpectedSnapshot::Content("added-v1")),
1520 ],
1521 &[existing.clone(), added.clone()],
1522 42,
1523 );
1524 }
1525
1526 #[tokio::test(flavor = "current_thread")]
1527 #[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
1528 async fn real_fs_sync_dependency_updates_and_readds_dependencies() {
1529 let mut harness = RealFsHarness::new();
1530 let first = harness.write("sync-first.typ", "first-v1");
1531 let second = harness.write("sync-second.typ", "second-v1");
1532 let third = harness.write("sync-third.typ", "third-v1");
1533
1534 harness.sync(&[first.clone(), second.clone()]);
1535 harness
1536 .expect_update_all(
1537 true,
1538 &[
1539 (&first, ExpectedSnapshot::Content("first-v1")),
1540 (&second, ExpectedSnapshot::Content("second-v1")),
1541 ],
1542 )
1543 .await;
1544
1545 harness.write_path(&first, "first-v2");
1546 harness
1547 .expect_update(&first, false, ExpectedSnapshot::Content("first-v2"))
1548 .await;
1549
1550 harness.sync(std::slice::from_ref(&first));
1551
1552 harness.sync(&[first.clone(), third.clone()]);
1553 harness
1554 .expect_update(&third, true, ExpectedSnapshot::Content("third-v1"))
1555 .await;
1556 harness.settle().await;
1557 }
1558
1559 #[tokio::test(flavor = "current_thread")]
1560 #[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
1561 async fn real_fs_modify_unwatched_and_multi_file_updates() {
1562 let mut harness = RealFsHarness::new();
1563 let watched = harness.write("watched.typ", "watched-v1");
1564 let other = harness.write("other.typ", "other-v1");
1565 let unwatched = harness.write("unwatched.typ", "unwatched-v1");
1566
1567 harness.sync(&[watched.clone(), other.clone()]);
1568 harness
1569 .expect_update_all(
1570 true,
1571 &[
1572 (&watched, ExpectedSnapshot::Content("watched-v1")),
1573 (&other, ExpectedSnapshot::Content("other-v1")),
1574 ],
1575 )
1576 .await;
1577
1578 harness.write_path(&watched, "watched-v2");
1579 harness
1580 .expect_update(&watched, false, ExpectedSnapshot::Content("watched-v2"))
1581 .await;
1582
1583 harness.drain_events();
1584 harness.write_path(&unwatched, "unwatched-v2");
1585 harness.expect_no_update(&unwatched).await;
1586
1587 harness.write_path(&watched, "watched-v3");
1588 harness.write_path(&other, "other-v2");
1589 harness
1590 .expect_update(&watched, false, ExpectedSnapshot::Content("watched-v3"))
1591 .await;
1592 harness
1593 .expect_update(&other, false, ExpectedSnapshot::Content("other-v2"))
1594 .await;
1595 harness.settle().await;
1596 }
1597
1598 #[tokio::test(flavor = "current_thread")]
1599 #[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
1600 async fn real_fs_remove_rename_away_and_readd_dependencies() {
1601 let mut harness = RealFsHarness::new();
1602 let remove = harness.write("remove.typ", "remove-v1");
1603 let rename = harness.write("rename-away.typ", "rename-v1");
1604 let renamed = harness.path("renamed-away.typ");
1605
1606 harness.sync(&[remove.clone(), rename.clone()]);
1607 harness
1608 .expect_update_all(
1609 true,
1610 &[
1611 (&remove, ExpectedSnapshot::Content("remove-v1")),
1612 (&rename, ExpectedSnapshot::Content("rename-v1")),
1613 ],
1614 )
1615 .await;
1616
1617 harness.remove(&remove);
1618 harness
1619 .expect_update(&remove, false, ExpectedSnapshot::NotFound)
1620 .await;
1621
1622 harness.write_path(&remove, "remove-v2");
1623 harness.sync(&[remove.clone(), rename.clone()]);
1624 harness
1625 .expect_update(&remove, true, ExpectedSnapshot::Content("remove-v2"))
1626 .await;
1627
1628 harness.rename(&rename, &renamed);
1629 harness
1630 .expect_update(&rename, false, ExpectedSnapshot::NotFound)
1631 .await;
1632 harness.settle().await;
1633 }
1634
1635 #[tokio::test(flavor = "current_thread")]
1636 #[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
1637 async fn real_fs_atomic_replace_empty_missing_and_recovery() {
1638 let mut harness = RealFsHarness::new();
1639 let atomic = harness.write("atomic.typ", "atomic-v1");
1640 let empty = harness.write("empty.typ", "stable");
1641 let missing = harness.write("missing.typ", "stable");
1642 let recovery = harness.write("recovery.typ", "stable");
1643
1644 harness.sync(&[
1645 atomic.clone(),
1646 empty.clone(),
1647 missing.clone(),
1648 recovery.clone(),
1649 ]);
1650 harness
1651 .expect_update_all(
1652 true,
1653 &[
1654 (&atomic, ExpectedSnapshot::Content("atomic-v1")),
1655 (&empty, ExpectedSnapshot::Content("stable")),
1656 (&missing, ExpectedSnapshot::Content("stable")),
1657 (&recovery, ExpectedSnapshot::Content("stable")),
1658 ],
1659 )
1660 .await;
1661
1662 let atomic_tmp = harness.write("atomic.tmp", "atomic-v2");
1663 harness.rename(&atomic_tmp, &atomic);
1664 harness
1665 .expect_update(&atomic, false, ExpectedSnapshot::Content("atomic-v2"))
1666 .await;
1667
1668 harness.write_path(&empty, "");
1669 harness
1670 .expect_update(&empty, false, ExpectedSnapshot::Content(""))
1671 .await;
1672
1673 harness.remove(&missing);
1674 harness
1675 .expect_update(&missing, false, ExpectedSnapshot::NotFound)
1676 .await;
1677
1678 harness.write_path(&recovery, "");
1679 harness.write_path(&recovery, "recovered");
1680 harness
1681 .expect_update(&recovery, false, ExpectedSnapshot::Content("recovered"))
1682 .await;
1683 harness.settle().await;
1684 }
1685
1686 #[tokio::test(flavor = "current_thread")]
1687 #[ignore = "uses the host filesystem watcher; CI runs real_fs_* explicitly"]
1688 async fn real_fs_upstream_invalidation_refreshes_watches() {
1689 let mut harness = RealFsHarness::new();
1690 let existing = harness.write("upstream-existing.typ", "existing-v1");
1691 let added = harness.write("upstream-added.typ", "added-v1");
1692
1693 harness.sync(std::slice::from_ref(&existing));
1694 harness
1695 .expect_update(&existing, true, ExpectedSnapshot::Content("existing-v1"))
1696 .await;
1697
1698 harness.write_path(&existing, "existing-v2");
1699 harness.upstream(&[existing.clone(), added.clone()], 7);
1700 harness
1701 .expect_upstream_after_optional_updates(
1702 &[(&added, ExpectedSnapshot::Content("added-v1"))],
1703 &[(&existing, ExpectedSnapshot::Content("existing-v2"))],
1704 &[existing.clone(), added.clone()],
1705 7,
1706 )
1707 .await;
1708 harness.write_path(&added, "added-v2");
1709 harness
1710 .expect_update(&added, false, ExpectedSnapshot::Content("added-v2"))
1711 .await;
1712 harness.settle().await;
1713 }
1714
1715 #[derive(Debug, Clone, Copy)]
1716 enum ExpectedSnapshot<'a> {
1717 Content(&'a str),
1718 NotFound,
1719 Other,
1720 }
1721
1722 fn test_path(name: &str) -> ImmutPath {
1723 Arc::from(
1724 PathBuf::from("/tinymist-notify-actor-test")
1725 .join(name)
1726 .into_boxed_path(),
1727 )
1728 }
1729
1730 fn notify_event(kind: notify::EventKind, paths: Vec<ImmutPath>) -> notify::Event {
1731 paths
1732 .into_iter()
1733 .fold(notify::Event::new(kind), |event, path| {
1734 event.add_path(path.as_ref().to_path_buf())
1735 })
1736 }
1737
1738 fn modify_data_event() -> notify::EventKind {
1739 notify::EventKind::Modify(ModifyKind::Data(DataChange::Content))
1740 }
1741
1742 fn content_snapshot(content: &str) -> FileSnapshot {
1743 Ok::<Bytes, FileError>(Bytes::from_string(content.to_owned())).into()
1744 }
1745
1746 fn watch(path: &ImmutPath) -> FakeWatchCommand {
1747 FakeWatchCommand::Watch(path.as_ref().to_path_buf())
1748 }
1749
1750 fn unwatch(path: &ImmutPath) -> FakeWatchCommand {
1751 FakeWatchCommand::Unwatch(path.as_ref().to_path_buf())
1752 }
1753
1754 fn assert_update(
1755 event: &FilesystemEvent,
1756 expected_is_sync: bool,
1757 expected: &[(&ImmutPath, ExpectedSnapshot<'_>)],
1758 ) {
1759 let FilesystemEvent::Update(changeset, is_sync) = event else {
1760 panic!("expected update event, got {event:?}");
1761 };
1762
1763 assert_eq!(*is_sync, expected_is_sync);
1764 assert_changeset(changeset, expected);
1765 }
1766
1767 struct RealFsHarness {
1768 _dir: tempfile::TempDir,
1769 sender: mpsc::UnboundedSender<NotifyMessage>,
1770 events_recv: mpsc::UnboundedReceiver<FilesystemEvent>,
1771 handle: tokio::task::JoinHandle<()>,
1772 }
1773
1774 impl RealFsHarness {
1775 fn new() -> Self {
1776 let dir = tempfile::tempdir().expect("tempdir should be created");
1777 let (sender, inbox) = mpsc::unbounded_channel();
1778 let (events_send, events_recv) = mpsc::unbounded_channel();
1779 let handle = spawn_watch_deps(inbox, move |event| {
1780 events_send
1781 .send(event)
1782 .expect("real watcher event receiver should stay open");
1783 });
1784
1785 Self {
1786 _dir: dir,
1787 sender,
1788 events_recv,
1789 handle,
1790 }
1791 }
1792
1793 fn path(&self, name: &str) -> ImmutPath {
1794 Arc::from(self._dir.path().join(name).into_boxed_path())
1795 }
1796
1797 fn write(&self, name: &str, content: &str) -> ImmutPath {
1798 let path = self.path(name);
1799 self.write_path(&path, content);
1800 path
1801 }
1802
1803 fn write_path(&self, path: &ImmutPath, content: &str) {
1804 std::fs::write(path.as_ref(), content).expect("temp file should be written");
1805 }
1806
1807 fn remove(&self, path: &ImmutPath) {
1808 std::fs::remove_file(path.as_ref()).expect("temp file should be removed");
1809 }
1810
1811 fn rename(&self, from: &ImmutPath, to: &ImmutPath) {
1812 std::fs::rename(from.as_ref(), to.as_ref()).expect("temp file should be renamed");
1813 }
1814
1815 fn sync(&self, paths: &[ImmutPath]) {
1816 self.sender
1817 .send(NotifyMessage::SyncDependency(Box::new(paths.to_vec())))
1818 .expect("sync dependency send should succeed");
1819 }
1820
1821 fn upstream(&self, invalidates: &[ImmutPath], opaque: usize) {
1822 self.sender
1823 .send(NotifyMessage::UpstreamUpdate(UpstreamUpdateEvent {
1824 invalidates: invalidates.to_vec(),
1825 opaque: Box::new(opaque),
1826 }))
1827 .expect("upstream update send should succeed");
1828 }
1829
1830 async fn expect_update(
1831 &mut self,
1832 expected_path: &ImmutPath,
1833 expected_is_sync: bool,
1834 expected: ExpectedSnapshot<'_>,
1835 ) {
1836 self.expect_event(
1837 || {
1838 format!(
1839 "update path={expected_path:?}, is_sync={expected_is_sync}, snapshot={expected:?}"
1840 )
1841 },
1842 |event| update_contains(event, expected_path, expected_is_sync, expected),
1843 )
1844 .await;
1845 }
1846
1847 async fn expect_update_all(
1848 &mut self,
1849 expected_is_sync: bool,
1850 expected: &[(&ImmutPath, ExpectedSnapshot<'_>)],
1851 ) {
1852 self.expect_event(
1853 || format!("update is_sync={expected_is_sync}, snapshots={expected:?}"),
1854 |event| update_contains_all(event, expected_is_sync, expected),
1855 )
1856 .await;
1857 }
1858
1859 async fn expect_upstream_after_optional_updates(
1860 &mut self,
1861 required_upstream: &[(&ImmutPath, ExpectedSnapshot<'_>)],
1862 optional_prior_updates: &[(&ImmutPath, ExpectedSnapshot<'_>)],
1863 expected_invalidates: &[ImmutPath],
1864 expected_opaque: usize,
1865 ) {
1866 let mut seen_prior = vec![false; optional_prior_updates.len()];
1867
1868 self.expect_event(
1869 || {
1870 format!(
1871 "upstream required={required_upstream:?}, optional_prior={optional_prior_updates:?}"
1872 )
1873 },
1874 |event| {
1875 if let FilesystemEvent::Update(changeset, false) = event {
1876 for (seen, (path, snapshot)) in
1877 seen_prior.iter_mut().zip(optional_prior_updates)
1878 {
1879 if changeset_contains(changeset, path, *snapshot) {
1880 *seen = true;
1881 }
1882 }
1883
1884 return false;
1885 }
1886
1887 let FilesystemEvent::UpstreamUpdate {
1888 changeset,
1889 upstream_event: Some(upstream_event),
1890 } = event
1891 else {
1892 return false;
1893 };
1894
1895 upstream_event
1896 .opaque
1897 .downcast_ref::<usize>()
1898 .is_some_and(|opaque| *opaque == expected_opaque)
1899 && upstream_event.invalidates.as_slice() == expected_invalidates
1900 && required_upstream
1901 .iter()
1902 .all(|(path, snapshot)| changeset_contains(changeset, path, *snapshot))
1903 && optional_prior_updates.iter().enumerate().all(
1904 |(idx, (path, snapshot))| {
1905 seen_prior[idx]
1906 || changeset_contains(changeset, path, *snapshot)
1907 },
1908 )
1909 },
1910 )
1911 .await;
1912 }
1913
1914 async fn expect_no_update(&mut self, expected_path: &ImmutPath) {
1915 let res = tokio::time::timeout(std::time::Duration::from_millis(250), async {
1916 loop {
1917 let event = self
1918 .events_recv
1919 .recv()
1920 .await
1921 .expect("real watcher event sender should stay open");
1922 if update_mentions_path(&event, expected_path) {
1923 panic!("unexpected real watcher update for {expected_path:?}: {event:?}");
1924 }
1925 }
1926 })
1927 .await;
1928 assert!(
1929 res.is_err(),
1930 "no-update wait should end by timeout, not by matching an event"
1931 );
1932 }
1933
1934 async fn expect_event(
1935 &mut self,
1936 description: impl Fn() -> String,
1937 mut matches: impl FnMut(&FilesystemEvent) -> bool,
1938 ) {
1939 let mut last_event = None;
1940 let res = tokio::time::timeout(std::time::Duration::from_secs(3), async {
1941 loop {
1942 let event = self
1943 .events_recv
1944 .recv()
1945 .await
1946 .expect("real watcher event sender should stay open");
1947 if matches(&event) {
1948 return;
1949 }
1950 last_event = Some(format!("{event:?}"));
1951 }
1952 })
1953 .await;
1954
1955 if res.is_err() {
1956 panic!(
1957 "timed out waiting for real watcher {}; last event: {}",
1958 description(),
1959 last_event.unwrap_or_else(|| "<none>".to_owned())
1960 );
1961 }
1962 }
1963
1964 fn drain_events(&mut self) {
1965 while self.events_recv.try_recv().is_ok() {}
1966 }
1967
1968 async fn settle(self) {
1969 self.sender
1970 .send(NotifyMessage::Settle)
1971 .expect("settle send should succeed");
1972 tokio::time::timeout(std::time::Duration::from_millis(500), self.handle)
1973 .await
1974 .expect("production notify actor did not settle")
1975 .expect("production notify actor task failed");
1976 }
1977 }
1978
1979 fn update_contains(
1980 event: &FilesystemEvent,
1981 expected_path: &ImmutPath,
1982 expected_is_sync: bool,
1983 expected: ExpectedSnapshot<'_>,
1984 ) -> bool {
1985 let FilesystemEvent::Update(changeset, is_sync) = event else {
1986 return false;
1987 };
1988 *is_sync == expected_is_sync && changeset_contains(changeset, expected_path, expected)
1989 }
1990
1991 fn update_contains_all(
1992 event: &FilesystemEvent,
1993 expected_is_sync: bool,
1994 expected: &[(&ImmutPath, ExpectedSnapshot<'_>)],
1995 ) -> bool {
1996 let FilesystemEvent::Update(changeset, is_sync) = event else {
1997 return false;
1998 };
1999 *is_sync == expected_is_sync
2000 && expected
2001 .iter()
2002 .all(|(path, snapshot)| changeset_contains(changeset, path, *snapshot))
2003 }
2004
2005 fn update_mentions_path(event: &FilesystemEvent, expected_path: &ImmutPath) -> bool {
2006 let FilesystemEvent::Update(changeset, ..) = event else {
2007 return false;
2008 };
2009
2010 changeset
2011 .inserts
2012 .iter()
2013 .any(|(path, _)| path == expected_path)
2014 || changeset.removes.iter().any(|path| path == expected_path)
2015 }
2016
2017 fn changeset_contains(
2018 changeset: &FileChangeSet,
2019 expected_path: &ImmutPath,
2020 expected: ExpectedSnapshot<'_>,
2021 ) -> bool {
2022 changeset.inserts.iter().any(|(path, snapshot)| {
2023 path == expected_path && snapshot_matches(snapshot, expected_path, expected)
2024 })
2025 }
2026
2027 fn snapshot_matches(
2028 snapshot: &FileSnapshot,
2029 expected_path: &ImmutPath,
2030 expected: ExpectedSnapshot<'_>,
2031 ) -> bool {
2032 match expected {
2033 ExpectedSnapshot::Content(content) => snapshot
2034 .content()
2035 .is_ok_and(|bytes| bytes.as_slice() == content.as_bytes()),
2036 ExpectedSnapshot::NotFound => {
2037 let Err(err) = snapshot.as_ref() else {
2038 return false;
2039 };
2040 let FileError::NotFound(actual_path) = err.as_ref() else {
2041 return false;
2042 };
2043 actual_path.as_path() == expected_path.as_ref()
2044 }
2045 ExpectedSnapshot::Other => snapshot
2046 .as_ref()
2047 .is_err_and(|err| matches!(err.as_ref(), FileError::Other(_))),
2048 }
2049 }
2050
2051 fn assert_upstream_update(
2052 event: &FilesystemEvent,
2053 expected: &[(&ImmutPath, ExpectedSnapshot<'_>)],
2054 expected_invalidates: &[ImmutPath],
2055 expected_opaque: usize,
2056 ) {
2057 let FilesystemEvent::UpstreamUpdate {
2058 changeset,
2059 upstream_event: Some(upstream_event),
2060 } = event
2061 else {
2062 panic!("expected upstream update event, got {event:?}");
2063 };
2064
2065 assert_changeset(changeset, expected);
2066 assert_eq!(upstream_event.invalidates, expected_invalidates);
2067 assert_eq!(
2068 upstream_event
2069 .opaque
2070 .downcast_ref::<usize>()
2071 .copied()
2072 .expect("opaque payload should be usize"),
2073 expected_opaque
2074 );
2075 }
2076
2077 fn assert_changeset(
2078 changeset: &FileChangeSet,
2079 expected: &[(&ImmutPath, ExpectedSnapshot<'_>)],
2080 ) {
2081 assert_eq!(changeset.removes, Vec::<ImmutPath>::new());
2082 assert_eq!(changeset.inserts.len(), expected.len());
2083
2084 for ((actual_path, actual_snapshot), (expected_path, expected_snapshot)) in
2085 changeset.inserts.iter().zip(expected)
2086 {
2087 assert_eq!(actual_path, *expected_path);
2088 assert_snapshot(expected_path, actual_snapshot, *expected_snapshot);
2089 }
2090 }
2091
2092 fn assert_snapshot(path: &ImmutPath, snapshot: &FileSnapshot, expected: ExpectedSnapshot<'_>) {
2093 match expected {
2094 ExpectedSnapshot::Content(content) => {
2095 let bytes = snapshot.content().expect("expected file content");
2096 assert_eq!(bytes.as_slice(), content.as_bytes());
2097 }
2098 ExpectedSnapshot::NotFound => {
2099 let Err(err) = snapshot.as_ref() else {
2100 panic!("expected not found snapshot for {path:?}");
2101 };
2102 let FileError::NotFound(actual_path) = err.as_ref() else {
2103 panic!("expected not found snapshot for {path:?}, got {err:?}");
2104 };
2105 assert_eq!(actual_path.as_path(), path.as_ref());
2106 }
2107 ExpectedSnapshot::Other => {
2108 let Err(err) = snapshot.as_ref() else {
2109 panic!("expected other-error snapshot for {path:?}");
2110 };
2111 assert!(
2112 matches!(err.as_ref(), FileError::Other(_)),
2113 "expected other-error snapshot for {path:?}, got {err:?}"
2114 );
2115 }
2116 }
2117 }
2118}