Back to Spacedrive

Platform-Specific Rename Detection

.tasks/core/WATCH-002-platform-rename-detection.md

0.4.39.6 KB
Original Source

Description

Implement platform-specific rename detection to handle the fact that different operating systems provide varying levels of rename event support. macOS FSEvents doesn't provide native rename tracking, so we implement inode-based detection. Linux inotify provides better support, and Windows ReadDirectoryChangesW provides reasonable tracking.

Problem Statement

When a file is renamed, different platforms behave differently:

PlatformNative Rename SupportFallback Needed
macOS FSEvents❌ No (emits separate create/delete)✅ Inode tracking
Linux inotify✅ Yes (MOVED_FROM/MOVED_TO)⚠️ Buffer for stability
Windows⚠️ Partial (rename provided but needs buffering)✅ Buffer matching

Without rename detection, moving file.txtrenamed.txt would appear as:

  1. Delete event for file.txt
  2. Create event for renamed.txt

This breaks downstream logic that tracks files by UUID - a rename shouldn't create a new entry.

Architecture

macOS: Inode-Based Rename Detection

macOS FSEvents emits separate create/delete events for renames. We detect renames by tracking inodes:

rust
struct MacOSRenameDetector {
    // Maps inode → (path, timestamp) for recently deleted files
    deleted_inodes: HashMap<u64, (PathBuf, SystemTime)>,
    // Cleanup timer
    cleanup_interval: Duration,  // 500ms
}

impl MacOSRenameDetector {
    async fn handle_create(&mut self, path: PathBuf, inode: u64) -> Option<FsEvent> {
        // Check if this inode was recently deleted
        if let Some((old_path, _)) = self.deleted_inodes.remove(&inode) {
            // Same inode created within 500ms = rename!
            return Some(FsEvent {
                path: path.clone(),
                kind: FsEventKind::Rename {
                    from: old_path,
                    to: path,
                },
                timestamp: SystemTime::now(),
                is_directory: None,
            });
        }

        // Not a rename, just a create
        Some(FsEvent {
            path,
            kind: FsEventKind::Create,
            timestamp: SystemTime::now(),
            is_directory: None,
        })
    }

    async fn handle_delete(&mut self, path: PathBuf, inode: u64) {
        // Buffer delete for 500ms
        self.deleted_inodes.insert(inode, (path, SystemTime::now()));

        // After 500ms, if no matching create, emit actual delete
    }

    async fn cleanup_expired(&mut self) -> Vec<FsEvent> {
        let now = SystemTime::now();
        let mut expired = Vec::new();

        self.deleted_inodes.retain(|_, (path, timestamp)| {
            if now.duration_since(*timestamp).unwrap() > self.cleanup_interval {
                // No matching create arrived, emit delete
                expired.push(FsEvent {
                    path: path.clone(),
                    kind: FsEventKind::Remove,
                    timestamp: *timestamp,
                    is_directory: None,
                });
                false  // Remove from map
            } else {
                true  // Keep buffering
            }
        });

        expired
    }
}

Flow:

  1. Delete event arrives → buffer inode with timestamp
  2. Create event arrives within 500ms with same inode → emit Rename
  3. 500ms expires without matching create → emit Delete

Linux: Native Rename with Buffering

Linux inotify provides MOVED_FROM and MOVED_TO events with a cookie linking them:

rust
struct LinuxRenameDetector {
    // Maps cookie → old_path for pending moves
    pending_moves: HashMap<u32, PathBuf>,
}

impl LinuxRenameDetector {
    async fn handle_moved_from(&mut self, path: PathBuf, cookie: u32) {
        // Buffer old path with cookie
        self.pending_moves.insert(cookie, path);
    }

    async fn handle_moved_to(&mut self, path: PathBuf, cookie: u32) -> FsEvent {
        if let Some(old_path) = self.pending_moves.remove(&cookie) {
            // Matching cookie = rename
            FsEvent {
                path: path.clone(),
                kind: FsEventKind::Rename {
                    from: old_path,
                    to: path,
                },
                timestamp: SystemTime::now(),
                is_directory: None,
            }
        } else {
            // No matching cookie, treat as create
            FsEvent {
                path,
                kind: FsEventKind::Create,
                timestamp: SystemTime::now(),
                is_directory: None,
            }
        }
    }
}

Windows: Buffered Rename Detection

Windows ReadDirectoryChangesW provides rename information but needs buffering for reliability:

rust
struct WindowsRenameDetector {
    // Buffer remove events briefly to match with creates
    removed_paths: HashMap<PathBuf, SystemTime>,
}

impl WindowsRenameDetector {
    async fn handle_remove(&mut self, path: PathBuf) {
        self.removed_paths.insert(path, SystemTime::now());
    }

    async fn handle_create(&mut self, path: PathBuf) -> FsEvent {
        // Check if similar path was removed recently (fuzzy match)
        // Windows rename detection is less precise, so we do best-effort
        // Based on file extension and parent directory matching

        for (removed_path, timestamp) in &self.removed_paths {
            if paths_likely_same_file(&path, removed_path) {
                return FsEvent {
                    path: path.clone(),
                    kind: FsEventKind::Rename {
                        from: removed_path.clone(),
                        to: path,
                    },
                    timestamp: SystemTime::now(),
                    is_directory: None,
                };
            }
        }

        // No match, just a create
        FsEvent {
            path,
            kind: FsEventKind::Create,
            timestamp: SystemTime::now(),
            is_directory: None,
        }
    }
}

Implementation Files

  • crates/fs-watcher/src/platform/macos.rs - macOS inode-based rename detection
  • crates/fs-watcher/src/platform/linux.rs - Linux inotify rename handling
  • crates/fs-watcher/src/platform/windows.rs - Windows rename buffering
  • crates/fs-watcher/src/platform/mod.rs - Platform selection

Acceptance Criteria

macOS

  • Delete events buffered with inode for 500ms
  • Create event with matching inode within 500ms emits Rename
  • Expired buffered deletes emit Remove event
  • Inode tracking handles multiple concurrent renames
  • Cleanup task runs periodically to flush expired buffers

Linux

  • MOVED_FROM events buffered with cookie
  • MOVED_TO events matched by cookie emit Rename
  • Unmatched MOVED_FROM emits Remove
  • Unmatched MOVED_TO emits Create

Windows

  • Remove events buffered briefly
  • Create events checked against buffered removes
  • Fuzzy path matching detects likely renames
  • Unmatched creates emit Create
  • Expired buffered removes emit Remove

Cross-Platform

  • All platforms emit consistent FsEventKind::Rename
  • Rename events include both from and to paths
  • Downstream consumers can rely on rename detection
  • No false positives (separate delete+create not incorrectly merged)

Testing

Unit Tests

Per-platform tests located in crates/fs-watcher/src/platform/:

  • test_macos_inode_rename_detection - Verify inode tracking
  • test_macos_expired_delete - Verify cleanup timer
  • test_linux_cookie_matching - Verify cookie-based matching
  • test_windows_buffered_rename - Verify buffered detection

Integration Tests

Located in crates/fs-watcher/tests/:

  • test_rename_detection_macos - Full rename flow on macOS
  • test_rename_detection_linux - Full rename flow on Linux
  • test_rename_detection_windows - Full rename flow on Windows
  • test_rapid_renames - Multiple quick renames
  • test_cross_directory_rename - Rename across directories

Manual Testing

bash
# macOS
touch /tmp/test.txt
# Wait for watcher to register
mv /tmp/test.txt /tmp/renamed.txt
# Should emit: Rename { from: "/tmp/test.txt", to: "/tmp/renamed.txt" }

# Linux
touch /tmp/test.txt
mv /tmp/test.txt /tmp/renamed.txt
# Should emit: Rename (native inotify support)

# Windows
echo "test" > C:\temp\test.txt
rename C:\temp\test.txt renamed.txt
# Should emit: Rename (buffered detection)

Performance Characteristics

PlatformRename Detection TimeMemory OverheadFalse Positive Rate
macOS~500ms bufferHashMap of recent deletesVery low (<0.1%)
LinuxImmediateHashMap of pending movesNegligible
Windows~100ms bufferHashMap of recent removesLow (~1%)

Trade-off: Small latency (buffering) for accurate rename detection.

Enhancement: Database-Backed Inode Lookup

For even better macOS rename detection, the PersistentIndexService can maintain an inode cache:

rust
// When Remove event received on macOS:
async fn handle_remove_with_db_lookup(path: PathBuf, inode: u64) -> FsEvent {
    // Check if inode exists in database
    if let Some(entry) = db.find_entry_by_inode(inode).await? {
        // This inode is known, might be a rename
        // Buffer it and wait for potential create
        buffer_for_rename_detection(path, inode, entry.id).await;
    } else {
        // Unknown inode, just a delete
        emit_remove_event(path).await;
    }
}

This is implemented in the PersistentIndexService, not in this crate (fs-watcher remains storage-agnostic).

  • WATCH-000 - Filesystem Watcher Epic
  • WATCH-001 - Platform-Agnostic Event System
  • INDEX-004 - Change Detection System (uses rename events)