Skip to main content

acyclic_fs/
watch.rs

1//! Bounded native change notifications for materialized checkouts.
2//!
3//! Watch events are acceleration hints, never filesystem truth. Consumers read
4//! the affected paths from the host and produce ordinary authenticated checkout
5//! mutations. Queue overflow, backend uncertainty, ambiguous rename, root
6//! replacement, restart, or an unrepresentable path invalidates the hint stream
7//! and requires a new bounded baseline scan.
8
9use crate::NativeRootIdentity;
10use crate::cancellation::{CancellationError, CancellationToken};
11use crate::kernel::{LogicalName, NamespacePath, NamespacePathError};
12use crate::model::{FilesystemProfile, VolumeLimits};
13use crate::performance::{
14    MeasuredResult, OperationFailure, OperationReceipt, WorkBudget, WorkCounters, WorkError,
15};
16use notify::event::{ModifyKind, RenameMode};
17use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
18use std::collections::BTreeSet;
19use std::mem::size_of;
20use std::path::{Component, Path, PathBuf};
21use std::sync::atomic::{AtomicU32, Ordering};
22use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel};
23use std::sync::{Arc, Condvar, Mutex};
24use std::time::{Duration, Instant};
25use thiserror::Error;
26
27/// Native notification mechanism compiled for this target.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum NativeWatchBackend {
30    /// Linux inotify through the reviewed `notify` adapter.
31    LinuxInotify,
32    /// macOS `FSEvents` through the reviewed `notify` adapter.
33    MacosFsevents,
34    /// Windows `ReadDirectoryChangesW` through the reviewed `notify` adapter.
35    WindowsReadDirectoryChanges,
36    /// No target-specific native notification mechanism is available.
37    Unsupported,
38}
39
40impl NativeWatchBackend {
41    /// Stable SDK spelling used by generated-language capability surfaces.
42    #[must_use]
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::LinuxInotify => "linux-inotify",
46            Self::MacosFsevents => "macos-fsevents",
47            Self::WindowsReadDirectoryChanges => "windows-read-directory-changes",
48            Self::Unsupported => "unsupported",
49        }
50    }
51}
52
53/// Exact guarantees supplied by the compiled native watcher.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub struct NativeWatchCapabilities {
56    /// Host notification mechanism selected at compile time.
57    pub backend: NativeWatchBackend,
58    /// Whether descendant changes can be requested recursively.
59    pub recursive: bool,
60    /// Whether a persisted cursor can prove continuity across process restart.
61    ///
62    /// When false, reopening always requires the authenticated baseline
63    /// handshake. A process-local [`WatchSequence`] is never a restart token.
64    pub persistent_restart: bool,
65    /// Whether every poll fences replacement of the admitted root identity.
66    pub root_identity_fencing: bool,
67}
68
69/// Returns compile-time native watcher guarantees without touching a user path.
70#[must_use]
71pub const fn native_watch_capabilities() -> NativeWatchCapabilities {
72    #[cfg(target_os = "linux")]
73    let backend = NativeWatchBackend::LinuxInotify;
74    #[cfg(target_os = "macos")]
75    let backend = NativeWatchBackend::MacosFsevents;
76    #[cfg(target_os = "windows")]
77    let backend = NativeWatchBackend::WindowsReadDirectoryChanges;
78    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
79    let backend = NativeWatchBackend::Unsupported;
80
81    NativeWatchCapabilities {
82        backend,
83        recursive: !matches!(backend, NativeWatchBackend::Unsupported),
84        persistent_restart: false,
85        root_identity_fencing: cfg!(any(unix, windows)),
86    }
87}
88
89/// Process-local watcher epoch. It changes after every completed baseline scan.
90#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
91pub struct WatchEpoch(u64);
92
93impl WatchEpoch {
94    /// Constructs an epoch from a persisted process-local adapter value.
95    #[must_use]
96    pub const fn from_u64(value: u64) -> Self {
97        Self(value)
98    }
99
100    /// Returns the monotonic process-local value.
101    #[must_use]
102    pub const fn get(self) -> u64 {
103        self.0
104    }
105}
106
107/// Contiguous sequence within one [`WatchEpoch`].
108#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
109pub struct WatchSequence(u64);
110
111impl WatchSequence {
112    /// Constructs a contiguous sequence from an adapter cursor.
113    #[must_use]
114    pub const fn from_u64(value: u64) -> Self {
115        Self(value)
116    }
117
118    /// Returns the contiguous process-local value.
119    #[must_use]
120    pub const fn get(self) -> u64 {
121        self.0
122    }
123}
124
125/// One bounded host-side change hint.
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum WatchChange {
128    /// A namespace entry appeared.
129    Created(NamespacePath),
130    /// File content or an unspecified property may have changed.
131    Modified(NamespacePath),
132    /// Metadata or named attributes may have changed.
133    MetadataChanged(NamespacePath),
134    /// A namespace entry disappeared.
135    Removed(NamespacePath),
136    /// One paired rename with exact old and new relative paths.
137    Renamed {
138        /// Old relative path.
139        from: NamespacePath,
140        /// New relative path.
141        to: NamespacePath,
142    },
143}
144
145/// Why watcher hints can no longer prove a contiguous change interval.
146#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
147pub enum WatchInvalidationReason {
148    /// Every newly opened watcher starts without an authenticated baseline.
149    #[error("an initial authenticated baseline is required")]
150    InitialSnapshotRequired,
151    /// The bounded notification queue overflowed.
152    #[error("the native notification queue overflowed")]
153    QueueOverflow,
154    /// The native backend reported lost or unreliable events.
155    #[error("the native backend requires a rescan")]
156    NativeRescanRequired,
157    /// The native backend returned an asynchronous error.
158    #[error("the native backend reported an asynchronous error")]
159    BackendError,
160    /// A native path escaped or could not be represented by volume semantics.
161    #[error("a native path cannot be represented by volume semantics")]
162    UnrepresentablePath,
163    /// A rename could not be paired without guessing.
164    #[error("a native rename could not be paired exactly")]
165    AmbiguousRename,
166    /// The watched root itself was removed, renamed, or replaced.
167    #[error("the watched root changed identity")]
168    RootChanged,
169}
170
171/// One bounded watcher poll result.
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub enum WatchBatch {
174    /// A contiguous process-local interval of change hints.
175    Changes {
176        /// Baseline epoch against which paths are interpreted.
177        epoch: WatchEpoch,
178        /// Sequence assigned to the first returned change.
179        first_sequence: WatchSequence,
180        /// Sequence to request after applying every returned change.
181        next_sequence: WatchSequence,
182        /// Bounded hints moved from the native queue without path copies.
183        changes: Vec<WatchChange>,
184    },
185    /// All hints from this epoch must be discarded and a baseline rescanned.
186    RescanRequired {
187        /// Invalidated watcher epoch.
188        epoch: WatchEpoch,
189        /// Exact invalidation class.
190        reason: WatchInvalidationReason,
191    },
192}
193
194/// Bounded native watcher configuration.
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196pub struct NativeWatchOptions {
197    /// Volume path/name bounds used while converting host paths.
198    pub limits: VolumeLimits,
199    /// Maximum queued native changes before typed invalidation.
200    pub maximum_queued_changes: u32,
201    /// Whether descendants are observed recursively.
202    pub recursive: bool,
203}
204
205#[cfg(unix)]
206fn open_native_root(path: &Path) -> std::io::Result<std::fs::File> {
207    use std::os::unix::fs::OpenOptionsExt;
208    let mut options = std::fs::OpenOptions::new();
209    options
210        .read(true)
211        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW);
212    options.open(path)
213}
214
215#[cfg(windows)]
216fn open_native_root(path: &Path) -> std::io::Result<std::fs::File> {
217    use std::os::windows::fs::MetadataExt;
218    use std::os::windows::fs::OpenOptionsExt;
219    use windows::Win32::Storage::FileSystem::{
220        FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
221        FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
222    };
223    let mut options = std::fs::OpenOptions::new();
224    options
225        .read(true)
226        .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0)
227        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0);
228    let file = options.open(path)?;
229    if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 {
230        return Err(std::io::Error::new(
231            std::io::ErrorKind::InvalidInput,
232            "watch root is a reparse point",
233        ));
234    }
235    Ok(file)
236}
237
238#[cfg(not(any(unix, windows)))]
239fn open_native_root(path: &Path) -> std::io::Result<std::fs::File> {
240    std::fs::File::open(path)
241}
242
243const fn native_filesystem_profile() -> FilesystemProfile {
244    #[cfg(unix)]
245    {
246        FilesystemProfile::Posix
247    }
248    #[cfg(windows)]
249    {
250        FilesystemProfile::Windows
251    }
252    #[cfg(not(any(unix, windows)))]
253    {
254        FilesystemProfile::Portable
255    }
256}
257
258impl NativeWatchOptions {
259    /// Conservative recursive defaults.
260    #[must_use]
261    pub const fn new(limits: VolumeLimits) -> Self {
262        Self {
263            limits,
264            maximum_queued_changes: 16_384,
265            recursive: true,
266        }
267    }
268
269    fn validate(self) -> Result<Self, NativeWatchError> {
270        if self.maximum_queued_changes == 0 {
271            return Err(NativeWatchError::InvalidOptions);
272        }
273        Ok(self)
274    }
275}
276
277#[derive(Debug)]
278struct SharedState {
279    invalidation: Option<WatchInvalidationReason>,
280    #[cfg(target_os = "linux")]
281    pending_rename: Option<PendingRename>,
282    /// Host path of the outstanding fence cookie, cleared on delivery.
283    fence: Option<PathBuf>,
284    /// Host subtrees whose events are never hints, in fence preference order.
285    excluded: Vec<PathBuf>,
286}
287
288#[cfg(target_os = "linux")]
289#[derive(Debug)]
290struct PendingRename {
291    tracker: usize,
292    from: PathBuf,
293    to: Option<PathBuf>,
294}
295
296impl SharedState {
297    const fn new(invalidation: Option<WatchInvalidationReason>) -> Self {
298        Self {
299            invalidation,
300            #[cfg(target_os = "linux")]
301            pending_rename: None,
302            fence: None,
303            excluded: Vec::new(),
304        }
305    }
306
307    fn seal_invalidation(&mut self) -> Option<WatchInvalidationReason> {
308        #[cfg(target_os = "linux")]
309        if self.invalidation.is_none() && self.pending_rename.is_some() {
310            self.invalidation = Some(WatchInvalidationReason::AmbiguousRename);
311        }
312        self.invalidation
313    }
314}
315
316struct NativeEventContext {
317    root: PathBuf,
318    root_identity: NativeRootIdentity,
319    profile: FilesystemProfile,
320    limits: VolumeLimits,
321    #[cfg(target_os = "linux")]
322    maximum_queued_changes: u32,
323}
324
325/// One live native watcher over a materialized checkout root.
326///
327/// Opening starts invalidated. Call [`Self::begin_rescan`], build an exact
328/// baseline while notifications continue to queue, and call
329/// [`Self::finish_rescan`] before polling. The same handshake repairs overflow
330/// without losing changes concurrent with the baseline scan.
331pub struct NativeWatch {
332    watcher: RecommendedWatcher,
333    root: PathBuf,
334    recursive: bool,
335    watched_directories: BTreeSet<PathBuf>,
336    receiver: Receiver<WatchChange>,
337    queued: Arc<AtomicU32>,
338    shared: Arc<Mutex<SharedState>>,
339    fenced: Arc<Condvar>,
340    fences: u64,
341    excluded: Vec<(NamespacePath, PathBuf)>,
342    epoch: WatchEpoch,
343    next_sequence: WatchSequence,
344    rescan_in_progress: bool,
345    root_identity: NativeRootIdentity,
346}
347
348impl NativeWatch {
349    /// Returns exact guarantees for this watcher's compiled backend.
350    #[must_use]
351    pub const fn capabilities(&self) -> NativeWatchCapabilities {
352        native_watch_capabilities()
353    }
354
355    /// Opens the platform-recommended event source.
356    ///
357    /// Linux uses inotify, macOS uses `FSEvents`, and Windows uses
358    /// `ReadDirectoryChangesW` through the reviewed `notify` adapter. Native
359    /// journals can later implement the same invalidation protocol.
360    ///
361    /// # Errors
362    ///
363    /// Rejects invalid bounds, an unavailable/non-directory root, allocation
364    /// conversion, poisoned synchronization, or native watcher startup failure.
365    pub fn open(
366        root: impl AsRef<Path>,
367        options: NativeWatchOptions,
368    ) -> Result<Self, NativeWatchError> {
369        Self::open_with_profile(root, native_filesystem_profile(), options)
370    }
371
372    /// Opens a watcher whose emitted names use the supplied volume profile.
373    ///
374    /// Use this when watcher paths feed a volume whose profile can differ from
375    /// the host-native profile selected by [`Self::open`].
376    ///
377    /// # Errors
378    ///
379    /// Rejects invalid bounds, an unavailable/non-directory root, allocation
380    /// conversion, poisoned synchronization, or native watcher startup failure.
381    pub fn open_with_profile(
382        root: impl AsRef<Path>,
383        profile: FilesystemProfile,
384        options: NativeWatchOptions,
385    ) -> Result<Self, NativeWatchError> {
386        let options = options.validate()?;
387        let requested_root = root.as_ref();
388        let admission = requested_root
389            .symlink_metadata()
390            .map_err(|error| NativeWatchError::Io(error.to_string()))?;
391        if admission.file_type().is_symlink() {
392            return Err(NativeWatchError::RootIsNotDirectory);
393        }
394        let root = requested_root
395            .canonicalize()
396            .map_err(|error| NativeWatchError::Io(error.to_string()))?;
397        let root_file =
398            open_native_root(&root).map_err(|error| NativeWatchError::Io(error.to_string()))?;
399        let metadata = root_file
400            .metadata()
401            .map_err(|error| NativeWatchError::Io(error.to_string()))?;
402        if !metadata.is_dir() {
403            return Err(NativeWatchError::RootIsNotDirectory);
404        }
405        let root_identity = NativeRootIdentity::from_file(&root_file)
406            .map_err(|error| NativeWatchError::Io(error.to_string()))?;
407        let capacity = usize::try_from(options.maximum_queued_changes)
408            .map_err(|_| NativeWatchError::InvalidOptions)?;
409        let (sender, receiver) = sync_channel(capacity);
410        let queued = Arc::new(AtomicU32::new(0));
411        let shared = Arc::new(Mutex::new(SharedState::new(Some(
412            WatchInvalidationReason::InitialSnapshotRequired,
413        ))));
414        let fenced = Arc::new(Condvar::new());
415        let callback_shared = Arc::clone(&shared);
416        let callback_fenced = Arc::clone(&fenced);
417        let callback_queued = Arc::clone(&queued);
418        let callback_context = NativeEventContext {
419            root: root.clone(),
420            root_identity,
421            profile,
422            limits: options.limits,
423            #[cfg(target_os = "linux")]
424            maximum_queued_changes: options.maximum_queued_changes,
425        };
426        let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
427            accept_native_event(
428                &event,
429                &callback_context,
430                &sender,
431                &callback_shared,
432                &callback_queued,
433            );
434            // Signal only after the cookie's own hint is handled: delivery is
435            // ordered, so every earlier host change is already queued.
436            if let (Ok(event), Ok(mut state)) = (&event, callback_shared.lock())
437                && state
438                    .fence
439                    .as_ref()
440                    .is_some_and(|fence| event.paths.contains(fence))
441            {
442                state.fence = None;
443                callback_fenced.notify_all();
444            }
445        })
446        .map_err(|error| NativeWatchError::Backend(error.to_string()))?;
447        watcher
448            .watch(
449                &root,
450                if options.recursive {
451                    RecursiveMode::Recursive
452                } else {
453                    RecursiveMode::NonRecursive
454                },
455            )
456            .map_err(|error| NativeWatchError::Backend(error.to_string()))?;
457        Ok(Self {
458            watcher,
459            watched_directories: BTreeSet::from([root.clone()]),
460            root,
461            recursive: options.recursive,
462            receiver,
463            queued,
464            shared,
465            fenced,
466            fences: 0,
467            excluded: Vec::new(),
468            epoch: WatchEpoch(0),
469            next_sequence: WatchSequence(0),
470            rescan_in_progress: false,
471            root_identity,
472        })
473    }
474
475    /// Adds one exact directory to a non-recursive native watcher.
476    ///
477    /// Recursive backends already cover the path and return immediately. A
478    /// non-recursive watcher validates every path prefix without following a
479    /// symbolic link, then installs exactly one additional subscription. No
480    /// descendant enumeration occurs.
481    ///
482    /// # Errors
483    ///
484    /// Rejects an absent/non-directory path, a symbolic-link prefix, a path
485    /// that cannot be represented on this host, or a backend registration
486    /// failure.
487    pub fn watch_directory(&mut self, directory: &NamespacePath) -> Result<(), NativeWatchError> {
488        if self.recursive || directory.is_root() {
489            return Ok(());
490        }
491        let relative = crate::native_capture::namespace_to_host_path(directory)
492            .map_err(|_| NativeWatchError::UnrepresentablePath)?;
493        let mut candidate = self.root.clone();
494        for component in relative.components() {
495            let Component::Normal(component) = component else {
496                return Err(NativeWatchError::UnrepresentablePath);
497            };
498            candidate.push(component);
499            let metadata = match candidate.symlink_metadata() {
500                Ok(metadata) => metadata,
501                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
502                    candidate.pop();
503                    break;
504                }
505                Err(error) => return Err(NativeWatchError::Io(error.to_string())),
506            };
507            if metadata.file_type().is_symlink() {
508                return Err(NativeWatchError::SymbolicLinkDirectory);
509            }
510            if !metadata.is_dir() {
511                return Err(NativeWatchError::ObservedPathNotDirectory);
512            }
513        }
514        if self.watched_directories.contains(&candidate) {
515            return Ok(());
516        }
517        self.watcher
518            .watch(&candidate, RecursiveMode::NonRecursive)
519            .map_err(|error| NativeWatchError::Backend(error.to_string()))?;
520        self.watched_directories.insert(candidate);
521        Ok(())
522    }
523
524    fn forget_removed_directories(&mut self, changes: &[WatchChange]) {
525        if self.recursive {
526            return;
527        }
528        let mut removed = Vec::new();
529        for change in changes {
530            match change {
531                WatchChange::Removed(path) => removed.push(path),
532                WatchChange::Renamed { from, to } => {
533                    removed.push(from);
534                    removed.push(to);
535                }
536                WatchChange::Created(_)
537                | WatchChange::Modified(_)
538                | WatchChange::MetadataChanged(_) => {}
539            }
540        }
541        for path in removed {
542            let Ok(relative) = crate::native_capture::namespace_to_host_path(path) else {
543                continue;
544            };
545            let removed = self.root.join(relative);
546            let stale = self
547                .watched_directories
548                .iter()
549                .filter(|watched| **watched != self.root && watched.starts_with(&removed))
550                .cloned()
551                .collect::<Vec<_>>();
552            for watched in stale {
553                let _ = self.watcher.unwatch(&watched);
554                self.watched_directories.remove(&watched);
555            }
556        }
557    }
558
559    fn reset_demand_watches(&mut self) {
560        if self.recursive {
561            return;
562        }
563        let stale = self
564            .watched_directories
565            .iter()
566            .filter(|path| **path != self.root)
567            .cloned()
568            .collect::<Vec<_>>();
569        for path in stale {
570            let _ = self.watcher.unwatch(&path);
571            self.watched_directories.remove(&path);
572        }
573    }
574
575    /// Admits a demand-backed lazy baseline without enumerating the root.
576    ///
577    /// The paired demand source must hold the same root capability and resolve
578    /// first observations from the live root. Hints arriving after this
579    /// boundary remain queued and must be captured before the corresponding
580    /// operation barrier closes.
581    ///
582    /// # Errors
583    ///
584    /// Returns the same admission and root-identity failures as the ordinary
585    /// rescan handshake.
586    pub fn accept_lazy_baseline(&mut self) -> Result<WatchBatch, NativeWatchError> {
587        self.begin_rescan()?;
588        self.finish_rescan()
589    }
590
591    /// Returns the exact root identity admitted when the watcher opened.
592    #[must_use]
593    pub const fn root_identity(&self) -> NativeRootIdentity {
594        self.root_identity
595    }
596
597    /// Verifies that a separately held capture root is the directory admitted
598    /// by this watcher. A mismatch permanently invalidates the current epoch.
599    ///
600    /// # Errors
601    ///
602    /// Returns a typed root-change or synchronization failure.
603    pub fn verify_root_identity(
604        &self,
605        observed: NativeRootIdentity,
606    ) -> Result<(), NativeWatchError> {
607        if observed == self.root_identity {
608            return Ok(());
609        }
610        self.shared
611            .lock()
612            .map_err(|_| NativeWatchError::StatePoisoned)?
613            .invalidation = Some(WatchInvalidationReason::RootChanged);
614        Err(NativeWatchError::RootChanged)
615    }
616
617    /// Starts a baseline scan and atomically discards only pre-scan hints.
618    ///
619    /// Native events arriving after this method returns remain queued. The
620    /// caller must read the exact host tree before calling [`Self::finish_rescan`].
621    ///
622    /// # Errors
623    ///
624    /// Rejects a duplicate scan, epoch exhaustion, or poisoned watcher state.
625    pub fn begin_rescan(&mut self) -> Result<WatchEpoch, NativeWatchError> {
626        if self.rescan_in_progress {
627            return Err(NativeWatchError::RescanAlreadyInProgress);
628        }
629        let mut shared = self
630            .shared
631            .lock()
632            .map_err(|_| NativeWatchError::StatePoisoned)?;
633        while self.receiver.try_recv().is_ok() {
634            self.queued.fetch_sub(1, Ordering::AcqRel);
635        }
636        shared.invalidation = None;
637        #[cfg(target_os = "linux")]
638        {
639            shared.pending_rename = None;
640        }
641        self.epoch = WatchEpoch(
642            self.epoch
643                .0
644                .checked_add(1)
645                .ok_or(NativeWatchError::SequenceExhausted)?,
646        );
647        self.next_sequence = WatchSequence(0);
648        drop(shared);
649        self.reset_demand_watches();
650        self.rescan_in_progress = true;
651        Ok(self.epoch)
652    }
653
654    #[cfg(target_os = "windows")]
655    fn accept_verified_baseline<T, E>(
656        &mut self,
657        verify: impl FnOnce() -> Result<Option<T>, E>,
658    ) -> Result<Result<Option<T>, E>, NativeWatchError> {
659        if self.rescan_in_progress {
660            return Err(NativeWatchError::RescanAlreadyInProgress);
661        }
662        let mut shared = self
663            .shared
664            .lock()
665            .map_err(|_| NativeWatchError::StatePoisoned)?;
666        if shared.invalidation != Some(WatchInvalidationReason::InitialSnapshotRequired)
667            || self.epoch.0 != 0
668        {
669            return Err(NativeWatchError::RescanAlreadyInProgress);
670        }
671        let verified = match verify() {
672            Ok(Some(verified)) => verified,
673            Ok(None) => return Ok(Ok(None)),
674            Err(error) => return Ok(Err(error)),
675        };
676        // A callback that ran before admission could only observe the initial
677        // invalidation and would have dropped its hint. The durable verifier
678        // must cover that interval. Holding the lock now fences later callbacks.
679        self.epoch = WatchEpoch(
680            self.epoch
681                .0
682                .checked_add(1)
683                .ok_or(NativeWatchError::SequenceExhausted)?,
684        );
685        self.next_sequence = WatchSequence(0);
686        shared.invalidation = None;
687        Ok(Ok(Some(verified)))
688    }
689
690    /// Admits an existing Windows baseline only when the volume USN journal
691    /// proves that neither the watched root nor any entry on its volume
692    /// changed since the checkpoint.
693    ///
694    /// Validation runs while callback delivery is fenced by the watcher state
695    /// lock. Any discontinuity leaves the watcher invalidated and returns the
696    /// exact baseline requirement.
697    #[cfg(target_os = "windows")]
698    pub fn accept_windows_usn_baseline(
699        &mut self,
700        checkpoint: crate::WindowsUsnCheckpoint,
701    ) -> Result<Result<crate::WindowsUsnContinuity, crate::WindowsUsnError>, NativeWatchError> {
702        let root = self.root.clone();
703        self.accept_verified_baseline(|| {
704            crate::validate_windows_usn_checkpoint(&root, checkpoint).map(|continuity| {
705                (continuity == crate::WindowsUsnContinuity::Unchanged).then_some(continuity)
706            })
707        })
708        .map(|result| {
709            result.map(|admitted| {
710                admitted.unwrap_or(crate::WindowsUsnContinuity::BaselineRequired(
711                    crate::WindowsUsnDiscontinuity::VolumeAdvanced,
712                ))
713            })
714        })
715    }
716
717    /// Seals the caller's baseline scan.
718    ///
719    /// If events overflowed during scanning, the returned batch remains
720    /// invalidated and the scan must be repeated. Otherwise all concurrently
721    /// queued hints form the first contiguous interval after the baseline.
722    ///
723    /// # Errors
724    ///
725    /// Rejects completion without a matching scan or poisoned state.
726    pub fn finish_rescan(&mut self) -> Result<WatchBatch, NativeWatchError> {
727        if !self.rescan_in_progress {
728            return Err(NativeWatchError::NoRescanInProgress);
729        }
730        let observed = open_native_root(&self.root)
731            .and_then(|root| NativeRootIdentity::from_file(&root))
732            .ok();
733        if observed != Some(self.root_identity) {
734            self.shared
735                .lock()
736                .map_err(|_| NativeWatchError::StatePoisoned)?
737                .invalidation = Some(WatchInvalidationReason::RootChanged);
738        }
739        self.rescan_in_progress = false;
740        let mut shared = self
741            .shared
742            .lock()
743            .map_err(|_| NativeWatchError::StatePoisoned)?;
744        Ok(shared.seal_invalidation().map_or(
745            WatchBatch::Changes {
746                epoch: self.epoch,
747                first_sequence: self.next_sequence,
748                next_sequence: self.next_sequence,
749                changes: Vec::new(),
750            },
751            |reason| WatchBatch::RescanRequired {
752                epoch: self.epoch,
753                reason,
754            },
755        ))
756    }
757
758    /// Aborts an in-progress baseline and returns the watcher to an explicitly
759    /// invalidated, retryable state.
760    ///
761    /// Events that arrived during the failed scan remain bounded in the queue;
762    /// the next [`Self::begin_rescan`] discards them before establishing a new
763    /// baseline interval.
764    ///
765    /// # Errors
766    ///
767    /// Rejects an abort without a matching scan or poisoned watcher state.
768    pub fn abort_rescan(
769        &mut self,
770        reason: WatchInvalidationReason,
771    ) -> Result<(), NativeWatchError> {
772        if !self.rescan_in_progress {
773            return Err(NativeWatchError::NoRescanInProgress);
774        }
775        self.rescan_in_progress = false;
776        self.shared
777            .lock()
778            .map_err(|_| NativeWatchError::StatePoisoned)?
779            .invalidation = Some(reason);
780        Ok(())
781    }
782
783    /// Stops reporting hints for events wholly inside `subtrees`.
784    ///
785    /// Excluded subtrees are invisible to the consumer (for example a VCS
786    /// directory or reserved SDK state), so their churn never invalidates it.
787    /// Their order is the [`Self::fence`] placement preference.
788    ///
789    /// # Errors
790    ///
791    /// Rejects the root, unrepresentable paths, or poisoned synchronization.
792    pub fn exclude_subtrees(&mut self, subtrees: &[NamespacePath]) -> Result<(), NativeWatchError> {
793        let excluded = subtrees
794            .iter()
795            .map(|subtree| {
796                if subtree.is_root() {
797                    return Err(NativeWatchError::UnrepresentablePath);
798                }
799                crate::native_capture::namespace_to_host_path(subtree)
800                    .map(|relative| self.root.join(relative))
801                    .map_err(|_| NativeWatchError::UnrepresentablePath)
802            })
803            .collect::<Result<Vec<_>, _>>()?;
804        self.shared
805            .lock()
806            .map_err(|_| NativeWatchError::StatePoisoned)?
807            .excluded
808            .clone_from(&excluded);
809        self.excluded = subtrees.iter().cloned().zip(excluded).collect();
810        Ok(())
811    }
812
813    /// Waits until every host change completed before this call is queued.
814    ///
815    /// Native delivery is asynchronous (`FSEvents` most visibly), so a poll
816    /// alone can miss a write that finished before it. The fence creates and
817    /// removes a uniquely named cookie inside the first excluded subtree that
818    /// is a directory (creating the first one when none is), then waits for
819    /// the cookie's ordered notification. Excluded cookies are never hints.
820    ///
821    /// A fence either proves delivery or invalidates the watcher: when the
822    /// cookie cannot be placed, written, or observed within `timeout`, the
823    /// next poll demands an authenticated rescan instead.
824    ///
825    /// # Errors
826    ///
827    /// Returns only poisoned synchronization.
828    pub fn fence(&mut self, timeout: Duration) -> Result<(), NativeWatchError> {
829        let delivered = self.fence_cookie().is_ok_and(|cookie| {
830            self.await_cookie(&cookie, timeout)
831                .is_ok_and(|delivered| delivered)
832        });
833        let mut state = self
834            .shared
835            .lock()
836            .map_err(|_| NativeWatchError::StatePoisoned)?;
837        state.fence = None;
838        if !delivered && state.invalidation.is_none() {
839            state.invalidation = Some(WatchInvalidationReason::NativeRescanRequired);
840        }
841        Ok(())
842    }
843
844    fn fence_cookie(&mut self) -> Result<PathBuf, NativeWatchError> {
845        let (directory, host_directory) = self.fence_directory()?;
846        self.watch_directory(&directory)?;
847        self.fences = self.fences.wrapping_add(1);
848        Ok(host_directory.join(format!(
849            ".acyclic-fence-{}-{}",
850            std::process::id(),
851            self.fences
852        )))
853    }
854
855    /// Writes `cookie` and reports whether its notification arrived in time.
856    fn await_cookie(&self, cookie: &Path, timeout: Duration) -> Result<bool, NativeWatchError> {
857        let mut state = self
858            .shared
859            .lock()
860            .map_err(|_| NativeWatchError::StatePoisoned)?;
861        if state.invalidation.is_some() {
862            return Ok(false);
863        }
864        state.fence = Some(cookie.to_path_buf());
865        drop(state);
866        std::fs::File::create_new(cookie)
867            .and_then(|_| std::fs::remove_file(cookie))
868            .map_err(|error| NativeWatchError::Io(error.to_string()))?;
869        let deadline = Instant::now() + timeout;
870        let mut state = self
871            .shared
872            .lock()
873            .map_err(|_| NativeWatchError::StatePoisoned)?;
874        while state.fence.is_some() && state.invalidation.is_none() {
875            let remaining = deadline.saturating_duration_since(Instant::now());
876            if remaining.is_zero() {
877                return Ok(false);
878            }
879            state = self
880                .fenced
881                .wait_timeout(state, remaining)
882                .map_err(|_| NativeWatchError::StatePoisoned)?
883                .0;
884        }
885        Ok(state.invalidation.is_none())
886    }
887
888    fn fence_directory(&self) -> Result<(NamespacePath, PathBuf), NativeWatchError> {
889        for (subtree, host) in &self.excluded {
890            match host.symlink_metadata() {
891                Ok(metadata) if metadata.is_dir() => return Ok((subtree.clone(), host.clone())),
892                Ok(_) => {}
893                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
894                Err(error) => return Err(NativeWatchError::Io(error.to_string())),
895            }
896        }
897        let (subtree, host) = self
898            .excluded
899            .first()
900            .cloned()
901            .ok_or(NativeWatchError::InvalidOptions)?;
902        std::fs::create_dir(&host).map_err(|error| NativeWatchError::Io(error.to_string()))?;
903        Ok((subtree, host))
904    }
905
906    /// Moves at most `maximum_changes` contiguous hints from the native queue.
907    ///
908    /// # Errors
909    ///
910    /// Fails before output allocation for zero bounds, cancellation, an active
911    /// rescan, poisoned state, sequence overflow, allocation failure, or an
912    /// exceeded exact work budget.
913    pub fn poll(
914        &mut self,
915        maximum_changes: u32,
916        budget: WorkBudget,
917        cancellation: &CancellationToken,
918    ) -> MeasuredResult<OperationReceipt<WatchBatch>, NativeWatchError> {
919        cancellation
920            .check()
921            .map_err(|error| OperationFailure::before_work(error.into()))?;
922        if maximum_changes == 0 {
923            return Err(OperationFailure::before_work(
924                NativeWatchError::ZeroPollLimit,
925            ));
926        }
927        if self.rescan_in_progress {
928            return Err(OperationFailure::before_work(
929                NativeWatchError::RescanInProgress,
930            ));
931        }
932        if let Some(reason) = self.invalidation()? {
933            return Ok(OperationReceipt {
934                value: WatchBatch::RescanRequired {
935                    epoch: self.epoch,
936                    reason,
937                },
938                work: WorkCounters::default(),
939            });
940        }
941        let root_work = WorkCounters {
942            backend_read_operations: 1,
943            ..WorkCounters::default()
944        };
945        root_work
946            .verify(budget)
947            .map_err(|error| OperationFailure::before_work(error.into()))?;
948        self.verify_current_root()?;
949        if let Some(reason) = self.invalidation()? {
950            return Ok(OperationReceipt {
951                value: WatchBatch::RescanRequired {
952                    epoch: self.epoch,
953                    reason,
954                },
955                work: root_work,
956            });
957        }
958        self.poll_contiguous(maximum_changes, budget, root_work)
959    }
960
961    fn poll_contiguous(
962        &mut self,
963        maximum_changes: u32,
964        budget: WorkBudget,
965        initial_work: WorkCounters,
966    ) -> MeasuredResult<OperationReceipt<WatchBatch>, NativeWatchError> {
967        let queued = self.queued.load(Ordering::Acquire).min(maximum_changes);
968        if queued == 0 {
969            return Ok(OperationReceipt {
970                value: WatchBatch::Changes {
971                    epoch: self.epoch,
972                    first_sequence: self.next_sequence,
973                    next_sequence: self.next_sequence,
974                    changes: Vec::new(),
975                },
976                work: initial_work,
977            });
978        }
979        let capacity = usize::try_from(queued)
980            .map_err(|_| OperationFailure::before_work(NativeWatchError::InvalidOptions))?;
981        let allocation_bytes = capacity
982            .checked_mul(size_of::<WatchChange>())
983            .map(crate::foundation::usize_to_u64)
984            .ok_or_else(|| {
985                OperationFailure::before_work(NativeWatchError::Work(WorkError::Overflow))
986            })?;
987        let mut work = initial_work
988            .checked_add(WorkCounters {
989                allocation_operations: 1,
990                peak_allocation_bytes: allocation_bytes,
991                ..WorkCounters::default()
992            })
993            .map_err(|error| OperationFailure::before_work(error.into()))?;
994        work.verify(budget)
995            .map_err(|error| OperationFailure::before_work(error.into()))?;
996        let mut changes = Vec::new();
997        changes
998            .try_reserve_exact(capacity)
999            .map_err(|_| OperationFailure::new(NativeWatchError::AllocationFailed, work))?;
1000        while changes.len() < capacity {
1001            match self.receiver.try_recv() {
1002                Ok(change) => {
1003                    self.queued.fetch_sub(1, Ordering::AcqRel);
1004                    changes.push(change);
1005                }
1006                Err(TryRecvError::Empty) => break,
1007                Err(TryRecvError::Disconnected) => {
1008                    self.invalidate(WatchInvalidationReason::BackendError)?;
1009                    break;
1010                }
1011            }
1012        }
1013        if let Some(reason) = self.invalidation()? {
1014            return Ok(OperationReceipt {
1015                value: WatchBatch::RescanRequired {
1016                    epoch: self.epoch,
1017                    reason,
1018                },
1019                work,
1020            });
1021        }
1022        let count = crate::foundation::usize_to_u64(changes.len());
1023        let next = self
1024            .next_sequence
1025            .0
1026            .checked_add(count)
1027            .ok_or_else(|| OperationFailure::new(NativeWatchError::SequenceExhausted, work))?;
1028        work = work
1029            .checked_add(WorkCounters {
1030                items_examined: count,
1031                items_returned: count,
1032                ..WorkCounters::default()
1033            })
1034            .map_err(|error| OperationFailure::new(error.into(), work))?;
1035        work.verify(budget)
1036            .map_err(|error| OperationFailure::new(error.into(), work))?;
1037        let first_sequence = self.next_sequence;
1038        self.next_sequence = WatchSequence(next);
1039        self.forget_removed_directories(&changes);
1040        Ok(OperationReceipt {
1041            value: WatchBatch::Changes {
1042                epoch: self.epoch,
1043                first_sequence,
1044                next_sequence: self.next_sequence,
1045                changes,
1046            },
1047            work,
1048        })
1049    }
1050
1051    fn verify_current_root(&self) -> Result<(), OperationFailure<NativeWatchError>> {
1052        let observed = open_native_root(&self.root)
1053            .and_then(|root| NativeRootIdentity::from_file(&root))
1054            .ok();
1055        if observed == Some(self.root_identity) {
1056            return Ok(());
1057        }
1058        self.shared
1059            .lock()
1060            .map_err(|_| OperationFailure::before_work(NativeWatchError::StatePoisoned))?
1061            .invalidation = Some(WatchInvalidationReason::RootChanged);
1062        Ok(())
1063    }
1064
1065    fn invalidation(
1066        &self,
1067    ) -> Result<Option<WatchInvalidationReason>, OperationFailure<NativeWatchError>> {
1068        self.shared
1069            .lock()
1070            .map(|mut state| state.seal_invalidation())
1071            .map_err(|_| OperationFailure::before_work(NativeWatchError::StatePoisoned))
1072    }
1073
1074    fn invalidate(
1075        &self,
1076        reason: WatchInvalidationReason,
1077    ) -> Result<(), OperationFailure<NativeWatchError>> {
1078        let mut shared = self
1079            .shared
1080            .lock()
1081            .map_err(|_| OperationFailure::before_work(NativeWatchError::StatePoisoned))?;
1082        shared.invalidation.get_or_insert(reason);
1083        Ok(())
1084    }
1085}
1086
1087impl crate::demand::DemandDirectoryObserver for Mutex<NativeWatch> {
1088    fn observe_directory(
1089        &self,
1090        directory: &NamespacePath,
1091    ) -> Result<(), crate::demand::DemandError> {
1092        self.lock()
1093            .map_err(|_| NativeWatchError::StatePoisoned)?
1094            .watch_directory(directory)?;
1095        Ok(())
1096    }
1097}
1098
1099fn accept_native_event(
1100    event: &notify::Result<Event>,
1101    context: &NativeEventContext,
1102    sender: &SyncSender<WatchChange>,
1103    shared: &Arc<Mutex<SharedState>>,
1104    queued: &Arc<AtomicU32>,
1105) {
1106    let Ok(mut state) = shared.lock() else {
1107        return;
1108    };
1109    if state.invalidation.is_some() {
1110        return;
1111    }
1112    if let Ok(event) = event
1113        && !event.paths.is_empty()
1114        && event.paths.iter().all(|path| {
1115            state
1116                .excluded
1117                .iter()
1118                .any(|excluded| path.starts_with(excluded))
1119        })
1120    {
1121        return;
1122    }
1123    let mapped = event
1124        .as_ref()
1125        .map_err(|_| WatchInvalidationReason::BackendError)
1126        .and_then(|event| map_native_event(event, context));
1127    let changes = match mapped {
1128        Ok(MappedNativeEvent::Changes(changes)) => changes,
1129        #[cfg(target_os = "linux")]
1130        Ok(MappedNativeEvent::RenameHalf {
1131            tracker,
1132            mode,
1133            path,
1134        }) => {
1135            match mode {
1136                RenameMode::From if state.pending_rename.is_none() => {
1137                    if queued.load(Ordering::Acquire) >= context.maximum_queued_changes {
1138                        state.invalidation = Some(WatchInvalidationReason::QueueOverflow);
1139                        return;
1140                    }
1141                    state.pending_rename = Some(PendingRename {
1142                        tracker,
1143                        from: path,
1144                        to: None,
1145                    });
1146                }
1147                RenameMode::To => {
1148                    let Some(pending) = state.pending_rename.as_mut() else {
1149                        state.invalidation = Some(WatchInvalidationReason::AmbiguousRename);
1150                        return;
1151                    };
1152                    if pending.tracker != tracker || pending.to.is_some() {
1153                        state.invalidation = Some(WatchInvalidationReason::AmbiguousRename);
1154                    } else {
1155                        pending.to = Some(path);
1156                    }
1157                }
1158                _ => state.invalidation = Some(WatchInvalidationReason::AmbiguousRename),
1159            }
1160            return;
1161        }
1162        #[cfg(target_os = "linux")]
1163        Ok(MappedNativeEvent::RenameBoth {
1164            tracker,
1165            from,
1166            to,
1167            changes,
1168        }) => {
1169            let matched = state.pending_rename.as_ref().is_some_and(|pending| {
1170                pending.tracker == tracker
1171                    && pending.from == from
1172                    && pending.to.as_ref() == Some(&to)
1173            });
1174            if !matched {
1175                state.invalidation = Some(WatchInvalidationReason::AmbiguousRename);
1176                return;
1177            }
1178            state.pending_rename = None;
1179            changes
1180        }
1181        Err(reason) => {
1182            state.invalidation = Some(reason);
1183            return;
1184        }
1185    };
1186    for change in changes {
1187        #[cfg(target_os = "linux")]
1188        if queued
1189            .load(Ordering::Acquire)
1190            .saturating_add(u32::from(state.pending_rename.is_some()))
1191            >= context.maximum_queued_changes
1192        {
1193            state.invalidation = Some(WatchInvalidationReason::QueueOverflow);
1194            break;
1195        }
1196        match sender.try_send(change) {
1197            Ok(()) => {
1198                queued.fetch_add(1, Ordering::Release);
1199            }
1200            Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
1201                state.invalidation = Some(WatchInvalidationReason::QueueOverflow);
1202                break;
1203            }
1204        }
1205    }
1206}
1207
1208enum MappedNativeEvent {
1209    Changes(Vec<WatchChange>),
1210    #[cfg(target_os = "linux")]
1211    RenameHalf {
1212        tracker: usize,
1213        mode: RenameMode,
1214        path: PathBuf,
1215    },
1216    #[cfg(target_os = "linux")]
1217    RenameBoth {
1218        tracker: usize,
1219        from: PathBuf,
1220        to: PathBuf,
1221        changes: Vec<WatchChange>,
1222    },
1223}
1224
1225fn map_native_event(
1226    event: &Event,
1227    context: &NativeEventContext,
1228) -> Result<MappedNativeEvent, WatchInvalidationReason> {
1229    validate_replayed_root_creation(event, &context.root, context.root_identity)?;
1230    #[cfg(target_os = "linux")]
1231    if let EventKind::Modify(ModifyKind::Name(mode)) = event.kind {
1232        if event.need_rescan() {
1233            return Err(WatchInvalidationReason::NativeRescanRequired);
1234        }
1235        let tracker = event
1236            .attrs
1237            .tracker()
1238            .ok_or(WatchInvalidationReason::AmbiguousRename)?;
1239        if matches!(mode, RenameMode::From | RenameMode::To) {
1240            let [path] = event.paths.as_slice() else {
1241                return Err(WatchInvalidationReason::AmbiguousRename);
1242            };
1243            let relative =
1244                relative_namespace_path(&context.root, path, context.profile, context.limits)?;
1245            if relative.is_root() {
1246                return Err(WatchInvalidationReason::RootChanged);
1247            }
1248            return Ok(MappedNativeEvent::RenameHalf {
1249                tracker,
1250                mode,
1251                path: path.clone(),
1252            });
1253        }
1254        if mode == RenameMode::Both {
1255            let changes = map_event(event, &context.root, context.profile, context.limits)?;
1256            let [from, to] = event.paths.as_slice() else {
1257                return Err(WatchInvalidationReason::AmbiguousRename);
1258            };
1259            return Ok(MappedNativeEvent::RenameBoth {
1260                tracker,
1261                from: from.clone(),
1262                to: to.clone(),
1263                changes,
1264            });
1265        }
1266    }
1267    map_event(event, &context.root, context.profile, context.limits).map(MappedNativeEvent::Changes)
1268}
1269
1270fn validate_replayed_root_creation(
1271    event: &Event,
1272    root: &Path,
1273    admitted_root_identity: NativeRootIdentity,
1274) -> Result<(), WatchInvalidationReason> {
1275    #[cfg(target_os = "macos")]
1276    if event.paths.iter().any(|path| path == root) && matches!(event.kind, EventKind::Create(_)) {
1277        let observed = open_native_root(root)
1278            .and_then(|file| NativeRootIdentity::from_file(&file))
1279            .ok();
1280        if observed != Some(admitted_root_identity) {
1281            return Err(WatchInvalidationReason::RootChanged);
1282        }
1283    }
1284    #[cfg(not(target_os = "macos"))]
1285    let _ = (event, root, admitted_root_identity);
1286    Ok(())
1287}
1288
1289fn map_event(
1290    event: &Event,
1291    root: &Path,
1292    profile: FilesystemProfile,
1293    limits: VolumeLimits,
1294) -> Result<Vec<WatchChange>, WatchInvalidationReason> {
1295    if event.need_rescan() {
1296        return Err(WatchInvalidationReason::NativeRescanRequired);
1297    }
1298    if matches!(event.kind, EventKind::Access(_)) {
1299        return Ok(Vec::new());
1300    }
1301    if event.paths.is_empty() || event.paths.len() > 2 {
1302        return Err(WatchInvalidationReason::UnrepresentablePath);
1303    }
1304    let mut paths = Vec::new();
1305    paths
1306        .try_reserve_exact(event.paths.len())
1307        .map_err(|_| WatchInvalidationReason::UnrepresentablePath)?;
1308    for path in &event.paths {
1309        // The watched root necessarily predates watcher admission. FSEvents
1310        // may replay a creation hint for it after the baseline handshake;
1311        // that hint cannot describe a new namespace entry and has no capture
1312        // target. Root removal and rename remain fail-closed below.
1313        #[cfg(target_os = "macos")]
1314        if path == root && matches!(event.kind, EventKind::Create(_)) {
1315            continue;
1316        }
1317        paths.push(relative_namespace_path(root, path, profile, limits)?);
1318    }
1319    if paths.iter().any(NamespacePath::is_root)
1320        && matches!(
1321            event.kind,
1322            EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(_))
1323        )
1324    {
1325        return Err(WatchInvalidationReason::RootChanged);
1326    }
1327    // FSEvents may report a coalesced change against the watched directory
1328    // itself without identifying the affected descendant. The volume root is
1329    // not a mutable namespace entry, so require a bounded baseline instead of
1330    // inventing an invalid root mutation or silently losing the change.
1331    #[cfg(target_os = "macos")]
1332    if paths.iter().any(NamespacePath::is_root) {
1333        return Err(WatchInvalidationReason::NativeRescanRequired);
1334    }
1335    if let EventKind::Modify(ModifyKind::Name(mode)) = event.kind {
1336        return match (mode, paths.as_slice()) {
1337            (RenameMode::Both, [from, to]) => Ok(vec![WatchChange::Renamed {
1338                from: from.clone(),
1339                to: to.clone(),
1340            }]),
1341            // inotify pairs both halves by cookie before they reach here
1342            // (see `map_native_event`), so a lone half on Linux means the
1343            // other half was lost.
1344            #[cfg(target_os = "linux")]
1345            _ => Err(WatchInvalidationReason::AmbiguousRename),
1346            // FSEvents never pairs renames, and `ReadDirectoryChangesW`
1347            // delivers each half as its own event, so a lone half is the
1348            // ordinary case there. It proves the same thing a creation hint
1349            // does: this path changed, look at it. The capture resolves a
1350            // modified hint by stat, treating a vanished path as removed
1351            // (subtree and all), so both ends of a rename land exactly
1352            // without guessing which was which. Invalidating instead made
1353            // every atomic save, `mv`, and `git checkout` cost a full rescan.
1354            #[cfg(not(target_os = "linux"))]
1355            (_, paths) => Ok(paths.iter().cloned().map(WatchChange::Modified).collect()),
1356        };
1357    }
1358    let mut changes = Vec::new();
1359    changes
1360        .try_reserve_exact(paths.len())
1361        .map_err(|_| WatchInvalidationReason::UnrepresentablePath)?;
1362    for path in paths {
1363        changes.push(match event.kind {
1364            // FSEvents coalesces flags and can replay creation hints for
1365            // paths that predate the caller's baseline, so a Darwin creation
1366            // event only proves the path changed. The created hint's
1367            // identity-replacing capture semantics require the exact
1368            // notifications inotify and `ReadDirectoryChangesW` deliver.
1369            #[cfg(target_os = "macos")]
1370            EventKind::Create(_) => WatchChange::Modified(path),
1371            #[cfg(not(target_os = "macos"))]
1372            EventKind::Create(_) => WatchChange::Created(path),
1373            EventKind::Remove(_) => WatchChange::Removed(path),
1374            // The same coalescing applies to metadata hints: a Darwin
1375            // metadata event may stand for a coalesced data write, so it only
1376            // proves the path changed. Treating it as metadata-only lets a
1377            // capture skip restaging content that did change.
1378            #[cfg(target_os = "macos")]
1379            EventKind::Modify(ModifyKind::Metadata(_)) => WatchChange::Modified(path),
1380            #[cfg(not(target_os = "macos"))]
1381            EventKind::Modify(ModifyKind::Metadata(_)) => WatchChange::MetadataChanged(path),
1382            EventKind::Modify(_) | EventKind::Any | EventKind::Other => WatchChange::Modified(path),
1383            EventKind::Access(_) => continue,
1384        });
1385    }
1386    Ok(changes)
1387}
1388
1389fn relative_namespace_path(
1390    root: &Path,
1391    path: &Path,
1392    profile: FilesystemProfile,
1393    limits: VolumeLimits,
1394) -> Result<NamespacePath, WatchInvalidationReason> {
1395    let relative = path
1396        .strip_prefix(root)
1397        .map_err(|_| WatchInvalidationReason::UnrepresentablePath)?;
1398    let mut components = Vec::new();
1399    for component in relative.components() {
1400        let Component::Normal(name) = component else {
1401            return Err(WatchInvalidationReason::UnrepresentablePath);
1402        };
1403        let (encoding, bytes) =
1404            crate::native_name::host_name_bytes(name, profile, limits.maximum_component_bytes)
1405                .map_err(|_| WatchInvalidationReason::UnrepresentablePath)?;
1406        let logical = LogicalName::new(encoding, bytes, limits.maximum_component_bytes)
1407            .map_err(|_| WatchInvalidationReason::UnrepresentablePath)?;
1408        components.push(logical);
1409    }
1410    NamespacePath::new(components, limits)
1411        .map_err(|_error: NamespacePathError| WatchInvalidationReason::UnrepresentablePath)
1412}
1413
1414/// Native watcher failures that are not recoverable through a baseline rescan.
1415#[derive(Debug, Error)]
1416pub enum NativeWatchError {
1417    /// Queue or volume bounds are invalid.
1418    #[error("native watcher options are invalid")]
1419    InvalidOptions,
1420    /// Watch root is not a directory.
1421    #[error("native watcher root is not a directory")]
1422    RootIsNotDirectory,
1423    /// A demanded directory traverses a symbolic link and cannot be mapped
1424    /// back to one exact namespace path.
1425    #[error("native watcher directory traverses a symbolic link")]
1426    SymbolicLinkDirectory,
1427    /// A demanded observer path is not a directory.
1428    #[error("native watcher observed path is not a directory")]
1429    ObservedPathNotDirectory,
1430    /// A demanded namespace path cannot be represented exactly on this host.
1431    #[error("native watcher directory is not representable on this host")]
1432    UnrepresentablePath,
1433    /// A separately opened capture root does not match the watched directory.
1434    #[error("native watcher root changed identity")]
1435    RootChanged,
1436    /// Root path I/O failed.
1437    #[error("native watcher I/O failed: {0}")]
1438    Io(String),
1439    /// Native backend setup failed.
1440    #[error("native watcher backend failed: {0}")]
1441    Backend(String),
1442    /// Watcher state synchronization was poisoned.
1443    #[error("native watcher state is poisoned")]
1444    StatePoisoned,
1445    /// A baseline scan is already active.
1446    #[error("native watcher baseline scan is already active")]
1447    RescanAlreadyInProgress,
1448    /// No baseline scan is active.
1449    #[error("native watcher baseline scan is not active")]
1450    NoRescanInProgress,
1451    /// Polling while a baseline is being built is ambiguous.
1452    #[error("native watcher baseline scan is in progress")]
1453    RescanInProgress,
1454    /// Poll output must have a positive bound.
1455    #[error("native watcher poll limit must be non-zero")]
1456    ZeroPollLimit,
1457    /// Process-local cursor arithmetic overflowed.
1458    #[error("native watcher sequence is exhausted")]
1459    SequenceExhausted,
1460    /// Bounded output allocation failed.
1461    #[error("native watcher output allocation failed")]
1462    AllocationFailed,
1463    /// Polling was cooperatively cancelled.
1464    #[error(transparent)]
1465    Cancelled(#[from] CancellationError),
1466    /// Exact work overflowed or exceeded the admitted budget.
1467    #[error(transparent)]
1468    Work(#[from] WorkError),
1469}
1470
1471#[cfg(test)]
1472#[path = "tests/watch.rs"]
1473mod tests;