Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions crates/vykar-core/src/commands/backup/walk/inode_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions crates/vykar-core/src/commands/backup/walk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<String, Vec<u8>>> {
let names = match xattr::list(path) {
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions crates/vykar-types/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -263,6 +288,26 @@ mod tests {
assert!(is_soft_backup_io_error(&e));
}

// Symptom: nightly backups aborted with "readdir error in <dir>: 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");
Expand Down