fix(fuse,core): renames across folders, replaced files, and writes after a create

Found by the seeded simulation runs (docs/BUGS.md).

- B122: a rename across folders goes to Drive as one move-multiple
  request, so a sibling holding the new name in the old folder no longer
  fails it with EIO. The drain uses the same request.
- B123: a second write open on a file just written no longer fails with
  EIO. An empty file counts as complete, our own renames, moves and
  online creates record their echo, a duplicated event is skipped, a
  rename keeps the file's mtime while its revision stays, and a landed
  upload is cached before open handles are rebased onto it.
- B124: a node put back after a failed replacing rename leaves `hidden`
  and its folder is listed again, so it shows in the mount.
- B131: a landed create is read back with the same short retry as an
  online create, and its bytes are cached under the real uid before an
  open handle is repointed at it.
- B135: an online rename settles the tree through the state guard, so
  the database has both folders unlisted before the reply. A lookup
  right after it no longer rebuilds the destination without the file.

The simulation runs no longer tolerate B122 or B131.
This commit is contained in:
nvrl committed 2026-10-03 14:50:12 +02:00
1 parent 1c262eb601
commit cd90c566cd
10 files changed
+679 -190

No files matched your search

+111
View File
@@ -309,4 +309,115 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Write `bytes` at `offset` into an existing file, without truncating it.
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
use std::os::unix::fs::FileExt;
std::fs::OpenOptions::new()
.write(true)
.open(path)?
.write_all_at(bytes, offset)
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_closed_empty_takes_a_second_write_at_once() {
// The empty revision never counted as complete, so the next write
// open before it drained was refused with EIO (B123).
let drive = FakeDrive::new();
let dir = scratch("empty-rewrite");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("empty.txt");
std::fs::write(&path, b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("empty.txt")
}));
std::fs::write(&path, b"").unwrap();
write_at(&path, 0, b"filled").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("empty.txt") == Some(&Entry::File(Arc::new(b"filled".to_vec())))
}),
"the second write landed: {:?}",
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn the_echo_of_our_own_rename_keeps_the_cached_bytes() {
// The echo read as someone else's change and evicted the bytes, so a
// partial write could not gap-fill, and the next write open on the
// file was refused with EIO (B123).
let drive = FakeDrive::new();
let dir = scratch("rename-echo");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let before = daemon.mountpoint.join("a.txt");
std::fs::write(&before, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("a.txt")
}));
assert_eq!(std::fs::read(&before).unwrap(), b"0123456789");
let after = daemon.mountpoint.join("b.txt");
std::fs::rename(&before, &after).unwrap();
// Long enough for the next event poll to bring the echo.
std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3));
write_at(&after, 3, b"x").unwrap();
write_at(&after, 5, b"y").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
drive.tree().get("b.txt") == Some(&Entry::File(Arc::new(b"012x4y6789".to_vec())))
}),
"both writes landed: {:?}",
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_rename_across_folders_ignores_the_new_name_in_the_old_folder() {
// The rename went to Drive as a rename in the old folder and then a
// move, so a sibling there with the new name failed it with EIO
// (B122).
let drive = FakeDrive::new();
let dir = scratch("rename-across");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mount = &daemon.mountpoint;
std::fs::create_dir(mount.join("x")).unwrap();
std::fs::create_dir(mount.join("z")).unwrap();
std::fs::write(mount.join("x/f.txt"), b"moved").unwrap();
std::fs::write(mount.join("x/d"), b"sibling").unwrap();
std::fs::write(mount.join("z/d"), b"replaced").unwrap();
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
assert!(wait_until(Duration::from_secs(30), || {
let tree = drive.tree();
[
("x/f.txt", b"moved".as_slice()),
("x/d", b"sibling"),
("z/d", b"replaced"),
]
.iter()
.all(|(path, bytes)| tree.get(*path).cloned() == file(bytes))
}));
std::fs::rename(mount.join("x/f.txt"), mount.join("z/d")).unwrap();
assert_eq!(std::fs::read(mount.join("z/d")).unwrap(), b"moved");
let tree = drive.tree();
assert_eq!(tree.get("z/d").cloned(), file(b"moved"));
assert_eq!(tree.get("x/d").cloned(), file(b"sibling"));
assert!(!tree.contains_key("x/f.txt"));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+11
View File
@@ -537,6 +537,17 @@ impl Faults {
}
}
/// A good link whose event feed is slow and untidy: every echo of our own
/// changes comes late, often twice, often out of order.
pub(crate) fn echoes() -> Self {
Self {
event_delay: Duration::from_secs(1),
duplicate_events: 0.3,
reorder_events: 0.3,
..Self::lan()
}
}
/// The Wi-Fi of the 2026-10 account runs: slow and lossy, with listings
/// and events well behind.
pub(crate) fn wifi() -> Self {
+24 -55
View File
@@ -65,25 +65,6 @@ struct Known {
}
const KNOWN: &[Known] = &[
// A rename across folders renames in the source folder first, so a
// sibling there with the new name refuses it.
Known {
bug: "B122",
is: |model, op, errno, _, _| {
let FsOp::Rename { from, to } = op else {
return false;
};
errno == libc::EIO
&& parent_of(from) != parent_of(to)
&& name_of(from) != name_of(to)
&& model
.tree()
.contains_key(&join(parent_of(from), name_of(to)))
},
// The destination it was replacing is restored on Drive but stays
// gone from the mount (B124).
ends_seed: |model, op| matches!(op, FsOp::Rename { to, .. } if model.tree().contains_key(to)),
},
// A replacing rename trashes what it replaces first, so Drive refuses the
// name only when it holds a node the mount has forgotten: the create of
// the replaced file landed after its op was dropped.
@@ -99,20 +80,10 @@ const KNOWN: &[Known] = &[
.iter()
.any(|line| line.starts_with("rename failed") && line.contains("AlreadyExists"))
},
// The file it was replacing is gone from the mount (B124).
// The model made the rename, and Drive holds a node the mount does
// not know.
ends_seed: |_, _| true,
},
// A write over a queued revision whose gaps are not filled yet is refused.
Known {
bug: "B123",
is: |_, _, errno, logged, _| {
errno == libc::EIO
&& logged
.iter()
.any(|line| line.starts_with("refusing write over incomplete queued revision"))
},
ends_seed: |_, _| false,
},
// A folder made offline is listed on Drive once its listing has been
// invalidated, and Drive does not know it, or does not list it yet. Only
// the client writes its folder, so a folder of its own is never really
@@ -649,13 +620,6 @@ impl Run {
/// - B128: the file's queued create ran into the name of the file it
/// replaced and took that over, trash and all.
///
/// A conflict copy of an earlier version, which the model no longer
/// holds, takes the drain saying it kept a queued write as that copy
/// after a landed create could not be read back:
///
/// - B131: the tree kept the create's placeholder, so a write based on it
/// found Drive's copy changed.
///
/// A conflict copy of a file the client deleted takes the drain saying it
/// kept that file's write as the copy, because Drive had the file in the
/// trash while the tree still placed it:
@@ -693,16 +657,6 @@ impl Run {
})
})
};
let unread_create = self.logged.iter().any(|line| {
line.starts_with("pending upload failed; will retry uid=local~")
&& line.ends_with("error=fetch node: no such file or folder")
});
let kept_write = |copy: &str| {
let alt = format!("alt={:?}", name_of(copy));
self.logged.iter().any(|line| {
line.starts_with("queued write landed as a conflict copy") && line.ends_with(&alt)
})
};
let kept_deleted = |copy: &str| {
let alt = format!(" alt={:?}", name_of(copy));
self.logged.iter().any(|line| {
@@ -724,15 +678,11 @@ impl Run {
if !is_conflict_copy(copy) || model.contains_key(copy) {
continue;
}
let bug = if unread_create && kept_write(copy) {
"B131"
} else if kept_deleted(copy) {
"B134"
} else {
if !kept_deleted(copy) {
continue;
};
}
damage.push(KnownDamage {
bug,
bug: "B134",
path: copy.clone(),
copy: Some(copy.clone()),
change: FsOp::Create {
@@ -1068,6 +1018,19 @@ mod tests {
drain_budget: Duration::from_secs(20),
};
static ONE_CLIENT_ECHOES: Profile = Profile {
name: "one-client-echoes",
clients: 1,
steps: 60,
faults: Faults::echoes,
link_flaps: false,
restarts: false,
deletes_mid_upload: false,
settle_budget: Some(Duration::from_secs(6)),
syscall_budget: Duration::from_secs(2),
drain_budget: Duration::from_secs(20),
};
static ONE_CLIENT_FLAKY: Profile = Profile {
name: "one-client-flaky",
clients: 1,
@@ -1106,6 +1069,12 @@ mod tests {
run_seeds(&ONE_CLIENT_DELETES, 3);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_with_stale_hashes_and_reordered_echoes() {
run_seeds(&ONE_CLIENT_ECHOES, 6);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_on_a_flaky_link() {