From 3a3a5a0e88119f584a29e2b0bd80a1b196726c0b Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Wed, 22 Jul 2026 07:15:55 -0600 Subject: [PATCH] fix(core): don't abort backup on dataless cloud-only directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, calling read_dir on a FileProvider dataless (iCloud cloud-only) directory blocks fileproviderd trying to materialize the listing and returns EDEADLK ("Resource deadlock avoided", os error 11). That error propagated up and aborted the entire backup — no snapshot was written. Skip descent into stat-detected dataless directories (record the dir inode for restore, but never read_dir into it), mirroring the existing dataless-file skip path. Also soft-classify EDEADLK on macOS in is_soft_backup_io_error to cover the race where a directory flips dataless between lstat and read_dir. Scoped to macOS so genuine fcntl/flock deadlocks are not masked elsewhere. Adds regression tests for the descent decision (should_descend_into_dir) and the EDEADLK classification, both polarities. --- .../src/commands/backup/walk/inode_walk.rs | 15 ++++++- .../src/commands/backup/walk/mod.rs | 32 +++++++++++++ crates/vykar-types/src/error.rs | 45 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/vykar-core/src/commands/backup/walk/inode_walk.rs b/crates/vykar-core/src/commands/backup/walk/inode_walk.rs index 5c3c28f..636d125 100644 --- a/crates/vykar-core/src/commands/backup/walk/inode_walk.rs +++ b/crates/vykar-core/src/commands/backup/walk/inode_walk.rs @@ -24,7 +24,7 @@ use crate::platform::fs::{self, MetadataSummary}; use vykar_types::error::{is_soft_backup_io_error, Result, VykarError}; use super::super::source::{ResolvedSource, RootEmission, SourceKind}; -use super::{build_explicit_excludes, should_skip_for_device}; +use super::{build_explicit_excludes, should_descend_into_dir, should_skip_for_device}; #[cfg(unix)] fn dir_entry_inode(entry: &std::fs::DirEntry) -> u64 { @@ -562,8 +562,19 @@ impl InodeSortedWalk { continue; } - if actual_is_dir && !file_type.is_symlink() { + // Record the directory inode itself either way (so restore + // recreates it), but only queue it for descent when it is a real, + // non-symlink, non-dataless directory. Descending into a + // FileProvider dataless (iCloud cloud-only) dir calls `read_dir`, + // which returns EDEADLK and aborts the whole backup — see + // `should_descend_into_dir`. + if should_descend_into_dir(actual_is_dir, file_type.is_symlink(), summary.is_dataless) { pending_subdirs.push_back(raw.path.clone()); + } else if actual_is_dir && !file_type.is_symlink() && summary.is_dataless { + debug!( + path = %raw.path.display(), + "skipping descent into dataless cloud-only directory" + ); } let rel = rel_path_from_abs(&self.abs_source, &raw.path); diff --git a/crates/vykar-core/src/commands/backup/walk/mod.rs b/crates/vykar-core/src/commands/backup/walk/mod.rs index 132d31e..f32d2b7 100644 --- a/crates/vykar-core/src/commands/backup/walk/mod.rs +++ b/crates/vykar-core/src/commands/backup/walk/mod.rs @@ -48,6 +48,23 @@ pub(crate) fn should_skip_for_device( one_file_system && source_dev != entry_dev } +/// Decide whether the walker should descend into a directory entry. +/// +/// Descend only into real directories that are not symlinks and not +/// FileProvider dataless (iCloud cloud-only) placeholders. Calling `read_dir` +/// on a dataless directory blocks `fileproviderd` trying to materialize the +/// listing and returns EDEADLK ("Resource deadlock avoided", os error 11), +/// which aborts the whole backup — so we record the directory inode itself but +/// never descend into it. On non-macOS platforms `is_dataless` is always +/// `false`, so this reduces to "descend into non-symlink dirs". +pub(crate) fn should_descend_into_dir( + actual_is_dir: bool, + is_symlink: bool, + is_dataless: bool, +) -> bool { + actual_is_dir && !is_symlink && !is_dataless +} + #[cfg(unix)] pub(super) fn read_item_xattrs(path: &Path) -> Option>> { let names = match xattr::list(path) { @@ -687,6 +704,21 @@ mod tests { assert!(!should_skip_for_device(false, 42, 43)); } + /// Regression: descending into a dataless (iCloud cloud-only) directory + /// calls `read_dir`, which returns EDEADLK and aborts the whole nightly + /// backup. A dataless dir must be recorded but NOT descended into. + #[test] + fn dataless_directory_is_not_descended() { + // Normal directory: descend. + assert!(should_descend_into_dir(true, false, false)); + // Dataless directory: record inode, skip descent (the bug fix). + assert!(!should_descend_into_dir(true, false, true)); + // Symlink to a directory: never descend. + assert!(!should_descend_into_dir(true, true, false)); + // Regular file: nothing to descend into. + assert!(!should_descend_into_dir(false, false, false)); + } + /// Regression: dataless inodes must NOT trigger `read_item_xattrs`. /// On macOS, `getxattr` on FileProvider-managed attrs round-trips through /// `fileproviderd` and can stall the (single-threaded) walker for seconds diff --git a/crates/vykar-types/src/error.rs b/crates/vykar-types/src/error.rs index 962ec0f..599a185 100644 --- a/crates/vykar-types/src/error.rs +++ b/crates/vykar-types/src/error.rs @@ -173,6 +173,30 @@ fn is_eio(e: &std::io::Error) -> bool { } } +/// macOS: returns `true` if the raw OS error is EDEADLK (errno 11, +/// "Resource deadlock avoided"). +/// +/// A FileProvider dataless (cloud-only) inode returns EDEADLK when +/// `read_dir`/`open`/`read` would have to synchronously materialize it from a +/// context that cannot drive `fileproviderd` (e.g. a background `launchd` +/// backup). The walker already skips descent into directories detected as +/// dataless at stat time, but a directory can flip to dataless between the +/// `lstat` and the `read_dir`, and dataless *files* can deadlock on open/read. +/// Treating EDEADLK as a soft (skip) error keeps the backup running instead of +/// aborting. Scoped to macOS: on other platforms EDEADLK means a genuine +/// `fcntl`/`flock` deadlock that should not be silently swallowed. +fn is_edeadlk(e: &std::io::Error) -> bool { + #[cfg(target_os = "macos")] + { + e.raw_os_error() == Some(11) + } + #[cfg(not(target_os = "macos"))] + { + let _ = e; + false + } +} + /// Soft I/O errors that should yield a per-entry skip rather than abort the /// backup. Used both at raw-`io::Error` call sites (`readlink`, `read_dir`, /// `File::open`, `fstat`, `read_to_end`) and as the `VykarError::Io` arm of @@ -199,6 +223,7 @@ pub fn is_soft_backup_io_error(e: &std::io::Error) -> bool { e.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound, ) || is_eio(e) + || is_edeadlk(e) { return true; } @@ -263,6 +288,26 @@ mod tests { assert!(is_soft_backup_io_error(&e)); } + // Symptom: nightly backups aborted with "readdir error in : Resource + // deadlock avoided (os error 11)" on macOS FileProvider dataless (iCloud + // cloud-only) directories. EDEADLK must be a soft skip on macOS so the + // backup continues instead of failing the whole run. + #[test] + #[cfg(target_os = "macos")] + fn soft_io_error_edeadlk_macos() { + let e = std::io::Error::from_raw_os_error(11); + assert!(is_soft_backup_io_error(&e)); + } + + // On non-macOS platforms EDEADLK is a genuine lock deadlock and must NOT be + // swallowed as a soft skip. + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn edeadlk_not_soft_off_macos() { + let e = std::io::Error::from_raw_os_error(11); + assert!(!is_soft_backup_io_error(&e)); + } + #[test] fn non_soft_io_error() { let e = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");