fix(fuse): keep inode stable across rename

relocate() called forget(), dropping the node's by_uid mapping, so
the re-enumeration assigned a fresh inode. The kernel had already
carried the renamed dentry onto the old one and holds it for the
entry TTL, so lookup/getattr/opendir resolved to an inode entries
no longer held and returned ENOENT — while readdir walked children
and reported the new inode. A renamed directory listed in its
parent but could not be entered until the TTL expired.

Rewrite the node in place and invalidate both parents' listings
instead. Also stops the moved node's DB row being deleted and
recreated on every move, which the B1 data-loss fix worked around
This commit is contained in:
nvrl committed 2026-07-19 19:30:05 +02:00
1 parent f9250211b8
commit 532628456e
14 files changed
+1302 -244

No files matched your search

+5 -1
View File
@@ -90,7 +90,11 @@ impl Db {
/// pass over a large cache does not read every row to drop a few, and so the
/// `(kind, last_accessed)` index can answer it as a range scan with an early
/// stop.
pub fn cache_eviction_candidates(&self, kind: &str, limit: usize) -> Result<Vec<(String, u64)>> {
pub fn cache_eviction_candidates(
&self,
kind: &str,
limit: usize,
) -> Result<Vec<(String, u64)>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare_cached(
"SELECT cache_key, size_bytes FROM cache_entries
+9 -3
View File
@@ -373,7 +373,8 @@ fn children_if_listed_gated_on_flag() {
fn a_deleted_child_leaves_a_listed_parent_serving_a_stale_listing() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("dst", Some("root"), "dest")).unwrap();
db.upsert_node(&folder("dst", Some("root"), "dest"))
.unwrap();
db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap();
db.set_listed(&uid("dst"), true).unwrap();
@@ -1302,7 +1303,10 @@ fn next_due_op_respects_backoff() {
let id = db.enqueue_op(&bulk_op(1, &root)).unwrap().0;
db.record_op_failure(id, "boom", 5_000).unwrap();
assert!(db.next_due_op(4_999).unwrap().is_none(), "still backing off");
assert!(
db.next_due_op(4_999).unwrap().is_none(),
"still backing off"
);
assert!(db.next_due_op(5_000).unwrap().is_some(), "due now");
}
@@ -1362,7 +1366,9 @@ fn cache_total_bytes_agrees_with_summing_the_rows() {
// query. The claim that matters — that the *common* path does not touch the
// database at all — is pinned by `budget_check_is_free_when_under_budget`,
// which compares against itself and so is load-independent.
println!("B4: {rounds} budget checks over {N} entries — scan {scan:?}, aggregate {aggregate:?}");
println!(
"B4: {rounds} budget checks over {N} entries — scan {scan:?}, aggregate {aggregate:?}"
);
}
/// Victims still come out least-recently-accessed first, now in bounded batches