Deliver watcher events in the spelling the index is keyed on

macOS reaches the writable disk twice. /Users/me and
/System/Volumes/Data/Users/me are the same directory joined by a firmlink,
realpath does not resolve firmlinks, and FSEvents subscribes to exactly one
of the two. Spacedrive resolves a source root through its volume's mount
point, so the spelling it asks for is the one that does not work.

The outbound half of that translation existed: watchable_spelling rewrote the
path handed to FSEvents. The inbound half did not, so events came back in
FSEvents' spelling and never matched a watched root. find_by_path missed and
the watcher built a second tree beside the one the walk had already built.

Both halves now live in one module. /usr/share/firmlinks publishes the
mapping, so a watch resolves to a set of subscriptions, each carrying the
prefix its events are rewritten back to. Above the watcher edge only one
spelling exists, and the two calls to watchable_spelling in the ephemeral
handler collapse back to comparing paths.

A watch on the whole Data volume fans out. /System/Volumes/Data has no short
spelling of its own, so it subscribes to each firmlink beneath it instead;
this is Macintosh HD, which received nothing at all before.

a_watch_on_the_long_spelling_delivers_under_it drives a real watch end to
end. Both halves were checked by removing them: without the outbound rewrite
nothing arrives, without the inbound one the path arrives in the wrong
spelling. It watches a directory under $HOME because a temp directory will
not do — /var/folders is reached through a symlink rather than a firmlink and
FSEvents refuses its resolved spelling, which is a separate quirk.
This commit is contained in:
Jamie Pine committed 2026-08-26 01:38:09 -07:00
1 parent bf670e922a
commit 3cfe8eaee6
4 files changed
+421 -88

No files matched your search

+5 -9
View File
@@ -144,15 +144,11 @@ impl EphemeralEventHandler {
// Check if the parent is being watched (shallow watch = immediate children only)
let watched_paths = context.ephemeral_cache().watched_paths();
// Compare the spelling the platform will actually report. On macOS the
// writable disk is reachable twice, and a watch registered under one
// spelling delivers events under the other, so exact equality here
// silently drops every event for a root stored the long way round.
let parent = sd_fs_watcher::watchable_spelling(parent);
let matching_root = watched_paths.iter().find(|watched| {
// For shallow watches, parent must match the watched path
parent == sd_fs_watcher::watchable_spelling(watched)
});
// Events arrive in the spelling the watch is stored under, translated at
// the watcher edge, so a shallow watch is plain equality on the parent.
let matching_root = watched_paths
.iter()
.find(|watched| parent == watched.as_path());
let Some(root_path) = matching_root else {
// Not under any ephemeral watch
+3 -1
View File
@@ -57,13 +57,15 @@ mod config;
mod error;
mod event;
mod platform;
mod spelling;
mod watcher;
pub use config::{EventFilters, WatchConfig, WatcherConfig};
pub use error::{Result, WatcherError};
pub use event::{FsEvent, FsEventKind, RawEventKind, RawNotifyEvent};
pub use platform::{EventHandler, PlatformHandler};
pub use watcher::{watchable_spelling, FsWatcher, WatchHandle};
pub use spelling::{restore, subscriptions, Subscription};
pub use watcher::{FsWatcher, WatchHandle};
// Re-export notify types that users might need
pub use notify::RecursiveMode;
+269
View File
@@ -0,0 +1,269 @@
//! Translating between the spelling a watch is stored under and the spellings
//! the platform will actually deliver events for.
//!
//! macOS reaches the writable half of the disk twice. `/Users/me` and
//! `/System/Volumes/Data/Users/me` are the same directory: same device, same
//! inode, joined by a firmlink. `realpath` does not resolve firmlinks, so both
//! spellings survive canonicalization, and FSEvents subscribes to exactly one
//! of them. Registered the long way round it accepts the watch, reports no
//! error, and delivers nothing.
//!
//! The mapping is not guesswork: `/usr/share/firmlinks` publishes it, one line
//! per link, root-volume path and Data-volume-relative path separated by a tab.
//!
//! Everything above this module works in one spelling — the one the source
//! stores, which is the long one, because a whole-drive source is rooted at
//! `/System/Volumes/Data` and its records have to sit under their own root.
//! [`subscriptions`] translates outbound, at registration, and [`restore`]
//! translates back inbound, so a path that leaves in FSEvents' spelling returns
//! in Spacedrive's.
use std::path::{Path, PathBuf};
/// One platform subscription backing a watch.
///
/// A watch is usually one of these. It is more than one when the requested root
/// spans several firmlinks, which is the whole-drive case: `/System/Volumes/Data`
/// has no single short spelling, so it fans out into the links beneath it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subscription {
/// The path handed to the platform watcher.
pub subscribe: PathBuf,
/// The prefix an event arriving under `subscribe` is rewritten back to.
pub restore_to: PathBuf,
}
impl Subscription {
/// A subscription that needs no translation.
fn direct(path: &Path) -> Self {
Self {
subscribe: path.to_path_buf(),
restore_to: path.to_path_buf(),
}
}
}
/// Rewrite an event path from the spelling it arrived in to the spelling the
/// watch is stored under.
///
/// Returns `None` when the path is not under this subscription, which is the
/// ordinary answer while looking for the one that owns it.
pub fn restore(subscription: &Subscription, path: &Path) -> Option<PathBuf> {
let rest = path.strip_prefix(&subscription.subscribe).ok()?;
Some(subscription.restore_to.join(rest))
}
/// The platform subscriptions a watch on `path` requires.
///
/// Never empty: a path with no firmlink translation subscribes to itself.
pub fn subscriptions(path: &Path) -> Vec<Subscription> {
#[cfg(target_os = "macos")]
{
macos::subscriptions(path)
}
#[cfg(not(target_os = "macos"))]
{
vec![Subscription::direct(path)]
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::Subscription;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Where the Data volume mounts, and the root of every long spelling.
const DATA_VOLUME: &str = "/System/Volumes/Data";
const FIRMLINKS: &str = "/usr/share/firmlinks";
/// One published firmlink: a path on the root volume, and the same
/// directory's path relative to the Data volume root.
struct Firmlink {
root: PathBuf,
relative: PathBuf,
}
fn table() -> &'static [Firmlink] {
static TABLE: OnceLock<Vec<Firmlink>> = OnceLock::new();
TABLE.get_or_init(|| {
let Ok(contents) = std::fs::read_to_string(FIRMLINKS) else {
return Vec::new();
};
contents
.lines()
.filter_map(|line| {
let (root, relative) = line.split_once('\t')?;
if root.is_empty() || relative.is_empty() {
return None;
}
Some(Firmlink {
root: PathBuf::from(root),
relative: PathBuf::from(relative),
})
})
.collect()
})
}
/// Whether two paths are the same directory, so a rewrite can never land
/// somewhere else because the published table disagreed with the disk.
fn same_directory(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
pub(super) fn subscriptions(path: &Path) -> Vec<Subscription> {
let Ok(rest) = path.strip_prefix(DATA_VOLUME) else {
return vec![Subscription::direct(path)];
};
let mut found = Vec::new();
for link in table() {
// The requested root sits inside this firmlink: one subscription,
// the short spelling of the same directory.
if let Ok(below) = rest.strip_prefix(&link.relative) {
let subscribe = link.root.join(below);
if same_directory(&subscribe, path) {
return vec![Subscription {
subscribe,
restore_to: path.to_path_buf(),
}];
}
continue;
}
// The firmlink sits inside the requested root: part of a fan-out.
if link.relative.starts_with(rest) {
let restore_to = Path::new(DATA_VOLUME).join(&link.relative);
if same_directory(&link.root, &restore_to) {
found.push(Subscription {
subscribe: link.root.clone(),
restore_to,
});
}
}
}
if found.is_empty() {
// A Data-volume path outside every firmlink is only reachable the
// long way round, so watch it as asked.
return vec![Subscription::direct(path)];
}
found
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_with_no_translation_watches_itself() {
let path = Path::new("/Volumes/Archive/Photos");
assert_eq!(subscriptions(path), vec![Subscription::direct(path)]);
}
#[test]
fn an_event_is_rewritten_into_the_stored_spelling() {
let subscription = Subscription {
subscribe: PathBuf::from("/Users/me/Desktop"),
restore_to: PathBuf::from("/System/Volumes/Data/Users/me/Desktop"),
};
assert_eq!(
restore(&subscription, Path::new("/Users/me/Desktop/shot.png")),
Some(PathBuf::from(
"/System/Volumes/Data/Users/me/Desktop/shot.png"
))
);
assert_eq!(
restore(&subscription, Path::new("/Users/me/Desktop")),
Some(PathBuf::from("/System/Volumes/Data/Users/me/Desktop"))
);
}
#[test]
fn an_event_outside_a_subscription_is_not_claimed() {
let subscription = Subscription {
subscribe: PathBuf::from("/Users/me/Desktop"),
restore_to: PathBuf::from("/System/Volumes/Data/Users/me/Desktop"),
};
assert_eq!(
restore(&subscription, Path::new("/Users/me/Documents/notes.txt")),
None
);
}
/// A path that merely begins with the same bytes is a different directory
/// and must not be rewritten into one.
#[test]
fn a_lookalike_prefix_is_left_alone() {
let impostor = Path::new("/System/Volumes/Data-not-really/Users");
assert_eq!(
subscriptions(impostor),
vec![Subscription::direct(impostor)]
);
}
#[cfg(target_os = "macos")]
#[test]
fn a_long_spelling_subscribes_to_the_short_one() {
let long = Path::new("/System/Volumes/Data/Users");
let subs = subscriptions(long);
assert_eq!(subs.len(), 1, "one directory, one subscription");
assert_eq!(subs[0].subscribe, Path::new("/Users"));
assert_eq!(subs[0].restore_to, long);
}
/// The whole Data volume has no short spelling of its own, so a watch on it
/// only reaches the disk by subscribing to each firmlink beneath it.
#[cfg(target_os = "macos")]
#[test]
fn the_whole_data_volume_fans_out_into_its_firmlinks() {
let subs = subscriptions(Path::new("/System/Volumes/Data"));
assert!(
subs.len() > 1,
"expected a fan-out, got {:?}",
subs.iter().map(|s| &s.subscribe).collect::<Vec<_>>()
);
assert!(
subs.iter().any(|s| s.subscribe == Path::new("/Users")),
"the home directories are the point of this watch"
);
for subscription in &subs {
assert!(
subscription.restore_to.starts_with("/System/Volumes/Data"),
"{:?} would deliver events outside the watch",
subscription
);
}
}
/// The round trip is what the watcher relies on: subscribe under one
/// spelling, receive under it, hand back the other.
#[cfg(target_os = "macos")]
#[test]
fn a_fanned_out_event_returns_in_the_stored_spelling() {
let subs = subscriptions(Path::new("/System/Volumes/Data"));
let users = subs
.iter()
.find(|s| s.subscribe == Path::new("/Users"))
.expect("the /Users firmlink");
assert_eq!(
restore(users, Path::new("/Users/me/Desktop/shot.png")),
Some(PathBuf::from(
"/System/Volumes/Data/Users/me/Desktop/shot.png"
))
);
}
}
+144 -78
View File
@@ -8,6 +8,7 @@ use crate::config::{WatchConfig, WatcherConfig};
use crate::error::{Result, WatcherError};
use crate::event::{FsEvent, RawNotifyEvent};
use crate::platform::PlatformHandler;
use crate::spelling::{self, Subscription};
use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
/// The mode to hand notify for a watch.
///
@@ -21,46 +22,6 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
/// above, by callers that already compare an event's parent against the root
/// they asked for. Watching wider than asked costs events that get filtered;
/// watching non-recursively costs every event.
/// The spelling of `path` that the platform's watcher will actually accept.
///
/// macOS reaches the writable half of the disk twice: `/Users/me` and
/// `/System/Volumes/Data/Users/me` are the same directory, same device, same
/// inode, joined by a firmlink. `realpath` does not resolve firmlinks, so both
/// spellings survive canonicalization, and FSEvents subscribes to exactly one
/// of them. Registered under the `/System/Volumes/Data` spelling it accepts the
/// watch, reports no error, and delivers nothing — the same silent failure as a
/// non-recursive subscription, from a different cause.
///
/// The stripped path is only used when it is demonstrably the same directory,
/// so a path that merely starts with those bytes is left alone rather than
/// rewritten into something else.
#[cfg(target_os = "macos")]
pub fn watchable_spelling(path: &Path) -> PathBuf {
const DATA_VOLUME: &str = "/System/Volumes/Data";
let Ok(rest) = path.strip_prefix(DATA_VOLUME) else {
return path.to_path_buf();
};
let stripped = Path::new("/").join(rest);
match (std::fs::metadata(path), std::fs::metadata(&stripped)) {
(Ok(a), Ok(b)) if same_file(&a, &b) => stripped,
_ => path.to_path_buf(),
}
}
#[cfg(target_os = "macos")]
fn same_file(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
a.dev() == b.dev() && a.ino() == b.ino()
}
#[cfg(not(target_os = "macos"))]
pub fn watchable_spelling(path: &Path) -> PathBuf {
path.to_path_buf()
}
fn notify_mode(recursive: bool) -> RecursiveMode {
if recursive || cfg!(target_os = "macos") {
RecursiveMode::Recursive
@@ -111,6 +72,9 @@ impl Drop for WatchHandle {
struct WatchState {
config: WatchConfig,
ref_count: usize,
/// The platform subscriptions backing this watch. More than one when the
/// root spans several firmlinks; see [`crate::spelling`].
subscriptions: Vec<Subscription>,
}
/// Internal watcher state
@@ -153,17 +117,21 @@ impl FsWatcherInner {
return Err(WatcherError::PathNotFound(path));
}
let subscriptions = spelling::subscriptions(&path);
// Register with notify if we're running
if self.is_running.load(Ordering::SeqCst) {
if let Some(watcher) = self.notify_watcher.write().await.as_mut() {
let mode = notify_mode(config.recursive);
watcher
.watch(&watchable_spelling(&path), mode)
.map_err(|e| WatcherError::WatchFailed {
path: path.clone(),
reason: e.to_string(),
for subscription in &subscriptions {
watcher.watch(&subscription.subscribe, mode).map_err(|e| {
WatcherError::WatchFailed {
path: path.clone(),
reason: e.to_string(),
}
})?;
}
}
}
@@ -172,6 +140,7 @@ impl FsWatcherInner {
WatchState {
config,
ref_count: 1,
subscriptions,
},
);
@@ -179,12 +148,45 @@ impl FsWatcherInner {
Ok(())
}
/// Rewrite an event's paths from the spelling the platform delivered into
/// the spelling its watch is stored under.
///
/// Subscriptions and events are two halves of one translation: a watch
/// registered under a firmlink's short spelling reports events under it
/// too, and everything above the watcher works in the long one. Without
/// this the paths never match a watched root, and an index keyed one way
/// grows a second tree keyed the other.
async fn restore_spelling(&self, event: &mut RawNotifyEvent) {
let watched = self.watched_paths.read().await;
let translations: Vec<&Subscription> = watched
.values()
.flat_map(|state| state.subscriptions.iter())
.filter(|subscription| subscription.subscribe != subscription.restore_to)
.collect();
if translations.is_empty() {
return;
}
for path in &mut event.paths {
if let Some(restored) = translations
.iter()
.find_map(|subscription| spelling::restore(subscription, path))
{
*path = restored;
}
}
}
/// Release a watch (decrement ref count, unwatch if zero)
async fn release_watch(&self, path: &Path) -> Result<()> {
let mut watched = self.watched_paths.write().await;
let subscriptions;
let should_unwatch = if let Some(state) = watched.get_mut(path) {
state.ref_count -= 1;
subscriptions = state.subscriptions.clone();
debug!(
"Decremented ref count for {}: {}",
path.display(),
@@ -201,8 +203,14 @@ impl FsWatcherInner {
// Unregister from notify if we're running
if self.is_running.load(Ordering::SeqCst) {
if let Some(watcher) = self.notify_watcher.write().await.as_mut() {
if let Err(e) = watcher.unwatch(path) {
warn!("Failed to unwatch {}: {}", path.display(), e);
for subscription in &subscriptions {
if let Err(e) = watcher.unwatch(&subscription.subscribe) {
warn!(
"Failed to unwatch {}: {}",
subscription.subscribe.display(),
e
);
}
}
}
}
@@ -389,10 +397,21 @@ impl FsWatcher {
for (path, state) in watched.iter() {
let mode = notify_mode(state.config.recursive);
if let Err(e) = watcher.watch(&watchable_spelling(path), mode) {
warn!("Failed to register watch for {}: {}", path.display(), e);
} else {
debug!("Registered watch for: {}", path.display());
for subscription in &state.subscriptions {
if let Err(e) = watcher.watch(&subscription.subscribe, mode) {
warn!(
"Failed to register watch for {} as {}: {}",
path.display(),
subscription.subscribe.display(),
e
);
} else {
debug!(
"Registered watch for {} as {}",
path.display(),
subscription.subscribe.display()
);
}
}
}
}
@@ -419,7 +438,9 @@ impl FsWatcher {
tokio::select! {
// Process incoming raw events
Some(raw_event) = raw_rx.recv() => {
Some(mut raw_event) = raw_rx.recv() => {
inner.restore_spelling(&mut raw_event).await;
// Check if path should be filtered
let should_process = if let Some(path) = raw_event.primary_path() {
let watched = inner.watched_paths.read().await;
@@ -527,32 +548,6 @@ mod tests {
watcher.stop().await.unwrap();
}
/// The same directory reached the long way round has to be watchable.
///
/// `/System/Volumes/Data/Users/me` and `/Users/me` are one directory joined
/// by a firmlink, and FSEvents only subscribes to the second. Spacedrive
/// resolves paths through the Data volume's mount point, so the spelling it
/// asks for is the one that does not work.
#[cfg(target_os = "macos")]
#[test]
fn the_data_volume_spelling_is_rewritten() {
let long = Path::new("/System/Volumes/Data/Users");
if !long.exists() {
return;
}
assert_eq!(watchable_spelling(long), Path::new("/Users"));
}
/// Only when it really is the same directory. A path that merely starts
/// with those bytes must survive untouched rather than being rewritten into
/// something else entirely.
#[cfg(target_os = "macos")]
#[test]
fn a_path_that_only_looks_like_it_is_left_alone() {
let impostor = Path::new("/System/Volumes/Data-not-really/x");
assert_eq!(watchable_spelling(impostor), impostor);
}
/// A shallow watch has to actually deliver.
///
/// This is the regression that mattered: on macOS a `NonRecursive`
@@ -602,6 +597,77 @@ mod tests {
watcher.stop().await.unwrap();
}
/// A watch on the long spelling has to deliver under the long spelling.
///
/// This is what `spelling` is for. FSEvents subscribes to the short side of
/// a firmlink and reports events there, while Spacedrive resolves roots
/// through the Data volume's mount point and keys its index on the long
/// side. Registered without translation the watch delivers nothing;
/// translated only outbound it delivers paths that match no watched root,
/// and the index grows a second tree beside the one it already has.
///
/// The subject has to be a real firmlinked directory, and the home
/// directory is the one every install has. A temp directory will not do:
/// `/var/folders` is reached through a symlink rather than a firmlink and
/// FSEvents refuses its resolved spelling outright, which is a separate
/// quirk this translation does not claim to cover.
#[cfg(target_os = "macos")]
#[tokio::test]
async fn a_watch_on_the_long_spelling_delivers_under_it() {
let Ok(home) = std::env::var("HOME") else {
return;
};
// A hidden directory is filtered out before it ever reaches a
// subscriber, and a temp directory is hidden by default.
let dir = tempfile::Builder::new()
.prefix("sd-spelling-")
.tempdir_in(&home)
.unwrap();
let short = dir.path().to_path_buf();
let long = Path::new("/System/Volumes/Data").join(short.strip_prefix("/").unwrap());
assert!(
long.exists(),
"{} should be reachable the long way round",
short.display()
);
let watcher = FsWatcher::new(WatcherConfig::default());
watcher.start().await.unwrap();
let mut events = watcher.subscribe();
let _handle = watcher.watch(&long, WatchConfig::shallow()).await.unwrap();
// The backend needs a moment before it is actually subscribed.
tokio::time::sleep(Duration::from_millis(500)).await;
std::fs::write(short.join("appeared.txt"), b"hello").unwrap();
let expected = long.join("appeared.txt");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(
!remaining.is_zero(),
"no event arrived as {}",
expected.display()
);
match tokio::time::timeout(remaining, events.recv()).await {
Ok(Ok(event)) if event.path == expected => break,
Ok(Ok(event)) => {
assert!(
!event.path.starts_with(&short),
"event arrived as {} but the watch is stored as {}",
event.path.display(),
long.display()
);
continue;
}
Ok(Err(_)) | Err(_) => panic!("no event arrived as {}", expected.display()),
}
}
watcher.stop().await.unwrap();
}
#[tokio::test]
async fn test_watch_handle_drops() {
let watcher = FsWatcher::new(WatcherConfig::default());