From da0c0cde43d259851512856be8d2a458e0a75d98 Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Mon, 27 Jul 2026 10:57:49 +0200 Subject: [PATCH 1/6] fuse: don't lose inode attribute invalidation racing with inode creation fuse_reverse_inval_inode() drops the invalidation when the inode is not in the icache: fuse_ilookup() misses and we return -ENOENT without recording anything. This loses invalidations that race with inode creation: 1. fuse_lookup_name() samples attr_version/evict_ctr and sends LOOKUP 2. the server builds the reply, the request completes, but the requesting task has not yet run fuse_iget() 3. the inode changes on the server, which sends FUSE_NOTIFY_INVAL_INODE 4. fuse_reverse_inval_inode() misses in the icache and the invalidation is dropped 5. fuse_iget() hashes the inode and installs the pre-invalidation attributes: the new inode has attr_version == 0 so the staleness check in fuse_change_attributes_i() cannot reject them, and evict_ctr is unchanged so fuse_change_attributes_common() clears inval_mask The stale attributes are then served for the whole attribute timeout. Commit 03eacfdec557 ("fuse: fix inode initialization race") closed the window where the inode is already hashed but not yet initialized; this closes the remaining window before the inode is hashed (including the stale-inode retry in fuse_iget() between remove_inode_hash() and re-insertion). Fix it by reusing the evict_ctr mechanism: a missed invalidation bumps the counter, which makes an in-flight LOOKUP/READDIRPLUS reply leave the new inode's attributes invalid (fresh inodes start with inval_mask == ~0), forcing a GETATTR on first use. Retry the lookup after bumping: ilookup5() and iget5_locked() serialize on the inode hash lock, so if the retry still misses, the creator inserted after our lookup and is guaranteed to observe the bumped counter; if it finds the inode, the normal found-path serializes via attr_version. Note the scope and cost of this: - Only the attribute side is protected. The page cache needs nothing here (a not-yet-created inode has no pages), but the dentry invalidation done on the found path (fc->inval_inode_entries / fc->expire_inode_entries) cannot be applied to a dentry that does not exist yet, so a racing lookup still instantiates its dentry with the reply's full entry timeout. Closing that needs the fc->epoch scheme from the upstream fuse tree (see 2396356a945b "fuse: add more control over cache invalidation behaviour" and 64becd224ff9 "fuse: new work queue to invalidate dentries from old epochs") and is left for a separate change. - The counter is connection-global, so an invalidation for an uncached nodeid costs every concurrently created inode one extra GETATTR on first use. Invalidations for cached inodes (the common notify-storm case) take the found path and are unaffected. Signed-off-by: Horst Birthelmer --- fs/fuse/inode.c | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c index a2d496160da4c5..4744be556246c4 100644 --- a/fs/fuse/inode.c +++ b/fs/fuse/inode.c @@ -750,8 +750,23 @@ int fuse_reverse_inval_inode(struct fuse_conn *fc, u64 nodeid, pgoff_t pg_end; inode = fuse_ilookup(fc, nodeid, NULL); - if (!inode) - return -ENOENT; + if (!inode) { + /* + * Not in the icache, but a LOOKUP/READDIRPLUS reply carrying + * pre-invalidation attributes may be about to create it. Bump + * evict_ctr so that such a reply leaves the new inode's + * attributes invalid (see fuse_change_attributes_common()), + * then look up again in case the inode was hashed while we + * were bumping. ilookup5() and iget5_locked() serialize on + * the inode hash lock, so if the second lookup still misses, + * the creator inserted after it and is guaranteed to observe + * the bumped counter. + */ + atomic64_inc(&fc->evict_ctr); + inode = fuse_ilookup(fc, nodeid, NULL); + if (!inode) + return -ENOENT; + } fi = get_fuse_inode(inode); From d13770a290af483d15d9f5af30c9c24c85bdb900 Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Mon, 27 Jul 2026 11:01:54 +0200 Subject: [PATCH 2/6] fuse: protect create paths against missed invalidation fuse_create_open() and create_new_entry() pass evict_ctr = 0 to fuse_iget(), which makes fuse_change_attributes_common() unconditionally clear inval_mask. A reverse invalidation for the new nodeid that arrives between the server generating the CREATE/MKNOD/LINK reply and fuse_iget() hashing the inode misses the icache and is dropped, and the reply then installs the pre-invalidation attributes as valid - the same lost-invalidation race just fixed for LOOKUP/READDIRPLUS, which these paths are currently exempt from. Sample evict_ctr before sending the request and pass it through, like fuse_lookup_name() does. A missed invalidation then leaves the new inode's attributes invalid, forcing a GETATTR on first use. attr_version deliberately stays 0. For a new inode the staleness check in fuse_change_attributes_i() cannot reject anything anyway (fi->attr_version == 0), and for an existing inode (a FUSE_LINK target, or FUSE_CREATE resolving to an already-cached nodeid) the unconditional install is self-correcting: it bumps fi->attr_version, so any later-processed reply carrying older attributes is rejected. Passing a pre-request snapshot instead would invert that - a concurrent reply processed after the snapshot but carrying older server state would cause the fresh CREATE/LINK reply (e.g. the incremented nlink) to be silently dropped while the stale attributes stay valid. Signed-off-by: Horst Birthelmer --- fs/fuse/dir.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fs/fuse/dir.c b/fs/fuse/dir.c index 5ca3ebb36591e6..bc407e9eda7091 100644 --- a/fs/fuse/dir.c +++ b/fs/fuse/dir.c @@ -696,6 +696,7 @@ static int fuse_create_open(struct inode *dir, struct dentry *entry, struct fuse_entry_out outentry; struct fuse_inode *fi; struct fuse_file *ff; + u64 evict_ctr; bool trunc = flags & O_TRUNC; /* Userspace expects S_IFREG in create mode */ @@ -743,6 +744,8 @@ static int fuse_create_open(struct inode *dir, struct dentry *entry, if (err) goto out_free_ff; + evict_ctr = fuse_get_evict_ctr(fm->fc); + err = fuse_simple_request(fm, &args); free_ext_value(&args); if (err) @@ -757,7 +760,7 @@ static int fuse_create_open(struct inode *dir, struct dentry *entry, ff->nodeid = outentry.nodeid; ff->open_flags = outopen.open_flags; inode = fuse_iget(dir->i_sb, outentry.nodeid, outentry.generation, - &outentry.attr, ATTR_TIMEOUT(&outentry), 0, 0); + &outentry.attr, ATTR_TIMEOUT(&outentry), 0, evict_ctr); if (!inode) { flags &= ~(O_CREAT | O_EXCL | O_TRUNC); fuse_sync_release(NULL, ff, flags); @@ -854,6 +857,7 @@ static int create_new_entry(struct fuse_mount *fm, struct fuse_args *args, struct dentry *d; int err; struct fuse_forget_link *forget; + u64 evict_ctr; if (fuse_is_bad(dir)) return -EIO; @@ -874,6 +878,8 @@ static int create_new_entry(struct fuse_mount *fm, struct fuse_args *args, goto out_put_forget_req; } + evict_ctr = fuse_get_evict_ctr(fm->fc); + err = fuse_simple_request(fm, args); free_ext_value(args); if (err) @@ -887,7 +893,7 @@ static int create_new_entry(struct fuse_mount *fm, struct fuse_args *args, goto out_put_forget_req; inode = fuse_iget(dir->i_sb, outarg.nodeid, outarg.generation, - &outarg.attr, ATTR_TIMEOUT(&outarg), 0, 0); + &outarg.attr, ATTR_TIMEOUT(&outarg), 0, evict_ctr); if (!inode) { fuse_queue_forget(fm->fc, forget, outarg.nodeid, 1); return -ENOMEM; From 3d8c9063bb293339234d779221a61c792a51b90f Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Mon, 27 Jul 2026 11:12:42 +0200 Subject: [PATCH 3/6] fuse: fix inode initialization race Backport from upstream patch. Fix a race between fuse_iget() and fuse_reverse_inval_inode() where invalidation can arrive while an inode is being initialized, causing the invalidation to be lost. By keeping the inode state I_NEW as long as the attributes are not valid the invalidation can wait until the inode is fully initialized. Suggested-by: Joanne Koong Signed-off-by: Horst Birthelmer --- fs/fuse/inode.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c index 4744be556246c4..a31da1130ba946 100644 --- a/fs/fuse/inode.c +++ b/fs/fuse/inode.c @@ -619,6 +619,7 @@ struct inode *fuse_iget(struct super_block *sb, u64 nodeid, struct inode *inode; struct fuse_inode *fi; struct fuse_conn *fc = get_fuse_conn_super(sb); + bool is_new_inode = false; /* * Auto mount points get their node id from the submount root, which is @@ -654,13 +655,13 @@ struct inode *fuse_iget(struct super_block *sb, u64 nodeid, if (!inode) return NULL; - if ((inode->i_state & I_NEW)) { + is_new_inode = inode->i_state & I_NEW; + if (is_new_inode) { inode->i_flags |= S_NOATIME; if (!fc->writeback_cache || !S_ISREG(attr->mode)) inode->i_flags |= S_NOCMTIME; inode->i_generation = generation; fuse_init_inode(inode, attr, fc); - unlock_new_inode(inode); } else if (fuse_stale_inode(inode, generation, attr)) { /* nodeid was reused, any I/O on the old inode should fail */ fuse_make_bad(inode); @@ -677,6 +678,8 @@ struct inode *fuse_iget(struct super_block *sb, u64 nodeid, done: fuse_change_attributes_i(inode, attr, NULL, attr_valid, attr_version, evict_ctr); + if (is_new_inode) + unlock_new_inode(inode); return inode; } From 47d7c421a0b4ef5ebd8a874051feb208b3706a60 Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Mon, 27 Jul 2026 11:09:48 +0200 Subject: [PATCH 4/6] fuse: retire the attr_version initialization waitqueue Commit 03eacfdec557 ("fuse: fix inode initialization race") closed the window where fuse_reverse_inval_inode() finds a hashed but not yet initialized inode by making it wait on a waitqueue until fi->attr_version becomes non-zero. The backport of upstream commit 95bb492f26e7 ("fuse: fix inode initialization race") closes the same window structurally: fuse_iget() now keeps the inode I_NEW until the first attribute install completes, and ilookup5() waits for I_NEW to clear, so fuse_ilookup() can no longer return an inode with attr_version == 0. The wait loop is dead code; remove it together with the waitqueue. This also removes an uninterruptible sleep from the /dev/fuse notify path, where a server thread blocked on client task progress, and a wake_up_all() from every attribute install. Signed-off-by: Horst Birthelmer --- fs/fuse/fuse_i.h | 3 --- fs/fuse/inode.c | 11 +---------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/fs/fuse/fuse_i.h b/fs/fuse/fuse_i.h index 72aabf8dd5ff31..902b5e0759c3d8 100644 --- a/fs/fuse/fuse_i.h +++ b/fs/fuse/fuse_i.h @@ -909,9 +909,6 @@ struct fuse_conn { /** Version counter for attribute changes */ atomic64_t attr_version; - /** Waitqueue for attr_version initialization */ - wait_queue_head_t attr_version_waitq; - /** Version counter for evict inode */ atomic64_t evict_ctr; diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c index a31da1130ba946..8f1e9cdee92223 100644 --- a/fs/fuse/inode.c +++ b/fs/fuse/inode.c @@ -384,7 +384,6 @@ void fuse_change_attributes_common(struct inode *inode, struct fuse_attr *attr, set_mask_bits(&fi->inval_mask, STATX_BASIC_STATS, 0); fi->attr_version = atomic64_inc_return(&fc->attr_version); - wake_up_all(&fc->attr_version_waitq); fi->i_time = attr_valid; fi->i_perm_time = attr_valid; @@ -772,17 +771,10 @@ int fuse_reverse_inval_inode(struct fuse_conn *fc, u64 nodeid, } fi = get_fuse_inode(inode); - spin_lock(&fi->lock); - while (fi->attr_version == 0) { - spin_unlock(&fi->lock); - wait_event(fc->attr_version_waitq, READ_ONCE(fi->attr_version) != 0); - spin_lock(&fi->lock); - } - fi->attr_version = atomic64_inc_return(&fc->attr_version); spin_unlock(&fi->lock); - + if (fc->inval_inode_entries) fuse_invalidate_inode_entry(inode); else if (fc->expire_inode_entries) @@ -1200,7 +1192,6 @@ void fuse_conn_init(struct fuse_conn *fc, struct fuse_mount *fm, refcount_set(&fc->count, 1); atomic_set(&fc->dev_count, 1); init_waitqueue_head(&fc->blocked_waitq); - init_waitqueue_head(&fc->attr_version_waitq); fuse_iqueue_init(&fc->iq, fiq_ops, fiq_priv); INIT_LIST_HEAD(&fc->bg_queue); INIT_LIST_HEAD(&fc->entry); From 46dd8d00fbafe9d7176c8628f7adf788805a6f3b Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Mon, 27 Jul 2026 12:07:25 +0200 Subject: [PATCH 5/6] fuse: deduplicate the attribute revalidation guard fuse_change_attributes_common() and fuse_change_attributes_common_sx() carry a verbatim copy of the inval_mask-clearing predicate and its explanatory comment, which have to be edited in lockstep whenever the race protection changes. Move both into a helper, fuse_may_validate_attrs(), and while at it bring the rationale up to date: - evict_ctr is now also bumped by fuse_reverse_inval_inode() when an invalidation misses the icache, so the guard protects against missed reverse invalidations as well as racing evicts - the '!evict_ctr -> this is create' clause was stale: since the create paths sample evict_ctr, a zero means the update does not come from fuse_iget() at all (matching the upstream wording) No functional change. Signed-off-by: Horst Birthelmer --- fs/fuse/inode.c | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c index 8f1e9cdee92223..a7c410f60e5b91 100644 --- a/fs/fuse/inode.c +++ b/fs/fuse/inode.c @@ -216,6 +216,26 @@ static ino_t fuse_squash_ino(u64 ino64) return ino; } +/* + * May attributes returned by a request be marked valid, i.e. cleared + * from inval_mask? + * + * Not if they come from a fuse_iget() call whose request raced with an + * evict or a reverse invalidation - either would have invalidated the + * result if the inode's attr_version would've been preserved. + * + * !evict_ctr -> this is not fuse_iget() + * fi->attr_version != 0 -> this is not a new inode + * evict_ctr == fuse_get_evict_ctr() -> no evicts or missed + * invalidations during the request + */ +static bool fuse_may_validate_attrs(struct fuse_conn *fc, + struct fuse_inode *fi, u64 evict_ctr) +{ + return !evict_ctr || fi->attr_version || + evict_ctr == fuse_get_evict_ctr(fc); +} + /* * Handle statx-specific attribute updates with partial attribute support. */ @@ -231,18 +251,8 @@ static void fuse_change_attributes_common_sx(struct inode *inode, lockdep_assert_held(&fi->lock); - /* - * Clear returned basic stats from invalid mask. - * - * Don't do this if this is coming from a fuse_iget() call and there - * might have been a racing evict which would've invalidated the result - * if the attr_version would've been preserved. - * - * !evict_ctr -> this is create - * fi->attr_version != 0 -> this is not a new inode - * evict_ctr == fuse_get_evict_ctr() -> no evicts while during request - */ - if (!evict_ctr || fi->attr_version || evict_ctr == fuse_get_evict_ctr(fc)) + /* Clear returned basic stats from invalid mask */ + if (fuse_may_validate_attrs(fc, fi, evict_ctr)) set_mask_bits(&fi->inval_mask, returned_attrs, 0); fi->attr_version = atomic64_inc_return(&fc->attr_version); @@ -369,18 +379,8 @@ void fuse_change_attributes_common(struct inode *inode, struct fuse_attr *attr, evict_ctr); } - /* - * Clear basic stats from invalid mask. - * - * Don't do this if this is coming from a fuse_iget() call and there - * might have been a racing evict which would've invalidated the result - * if the attr_version would've been preserved. - * - * !evict_ctr -> this is create - * fi->attr_version != 0 -> this is not a new inode - * evict_ctr == fuse_get_evict_ctr() -> no evicts while during request - */ - if (!evict_ctr || fi->attr_version || evict_ctr == fuse_get_evict_ctr(fc)) + /* Clear basic stats from invalid mask */ + if (fuse_may_validate_attrs(fc, fi, evict_ctr)) set_mask_bits(&fi->inval_mask, STATX_BASIC_STATS, 0); fi->attr_version = atomic64_inc_return(&fc->attr_version); From 0d072ff37ebc43e40815f23c88a9e21394438a5e Mon Sep 17 00:00:00 2001 From: Horst Birthelmer Date: Tue, 4 Aug 2026 11:48:08 +0200 Subject: [PATCH 6/6] fuse: don't let revalidate undo a racing entry invalidation fuse_dentry_revalidate() sends its LOOKUP holding only dget_parent(), not the parent's i_rwsem. fuse_reverse_inval_entry() can therefore clear the entry timeout while the request is in flight, and the reply re-arms it, undoing the invalidation. For FUSE_EXPIRE_ONLY and for fuse_prune_aliases() the dentry also stays hashed, so the expiry is lost outright and the stale entry survives a full entry_valid period. The other lookup paths - ->lookup(), ->atomic_open(), create_new_entry() and readdirplus - all run under the parent's i_rwsem, which fuse_reverse_inval_entry() takes exclusively, so they are already ordered against the invalidation and need no guard. Sample the parent's i_version - the counter fuse_dir_changed() already bumps and the readdir cache already samples - before sending the request, and skip fuse_change_entry_timeout() if it moved. Add fuse_entry_invalidated() to bump it from the invalidation sites that expire entries without going through fuse_dir_changed(), i.e. fuse_prune_aliases() and fuse_invalidate_inode_entry(). Signed-off-by: Horst Birthelmer --- fs/fuse/dir.c | 41 +++++++++++++++++++++++++++++++++++++++-- fs/fuse/fuse_i.h | 2 ++ fs/fuse/inode.c | 3 +++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/fs/fuse/dir.c b/fs/fuse/dir.c index bc407e9eda7091..bc3e495d619d06 100644 --- a/fs/fuse/dir.c +++ b/fs/fuse/dir.c @@ -138,6 +138,26 @@ static void fuse_dir_changed(struct inode *dir) inode_maybe_inc_iversion(dir, false); } +/* + * Note in the parent directory that one of its entries was invalidated. + * + * This bumps the same change counter as fuse_dir_changed(), which doubles as a + * generation for the parent's entry cache: fuse_dentry_revalidate() samples it + * before sending its LOOKUP and refuses to re-arm the entry timeout if it moved + * meanwhile. + * + * Only for invalidations coming from the server - a plain + * fuse_invalidate_entry_cache() (a negative lookup, say) must not bump the + * counter, or it would needlessly drop the readdir cache as well. + */ +void fuse_entry_invalidated(struct dentry *entry) +{ + /* d_lock keeps d_parent stable; a live child pins its parent inode */ + spin_lock(&entry->d_lock); + inode_maybe_inc_iversion(d_inode(entry->d_parent), false); + spin_unlock(&entry->d_lock); +} + /* * Mark the attributes as stale due to an atime change. Avoid the invalidate if * atime is not used. @@ -275,7 +295,7 @@ static int fuse_dentry_revalidate(struct dentry *entry, unsigned int flags) struct fuse_lookupx_out ext_out; struct fuse_statx sx; struct fuse_forget_link *forget; - u64 attr_version; + u64 attr_version, dir_version; uint32_t lookupx_flags = FUSE_LOOKUPX_FOR_REVALIDATE; /* For negative dentries, always do a fresh lookup */ @@ -298,6 +318,19 @@ static int fuse_dentry_revalidate(struct dentry *entry, unsigned int flags) lookupx_flags |= FUSE_LOOKUPX_TARGET_WAS_DIR; parent = dget_parent(entry); + /* + * Unlike ->lookup() and the create paths, revalidate runs + * without the parent's i_rwsem, so it does not exclude + * fuse_reverse_inval_entry(). An invalidation for this name + * can therefore land while the LOOKUP is in flight, and + * re-arming the entry timeout from the reply below would + * silently undo it - permanently so for FUSE_EXPIRE_ONLY and + * for fuse_prune_aliases(), which leave the dentry hashed. + * + * Sample the parent's entry-cache generation here and compare + * it once the reply is in. + */ + dir_version = inode_query_iversion(d_inode(parent)); ret = fuse_do_lookupx(fm, get_node_id(d_inode(parent)), &entry->d_name, &ext_out, lookupx_flags); @@ -329,7 +362,11 @@ static int fuse_dentry_revalidate(struct dentry *entry, unsigned int flags) fuse_change_attributes(inode, &ext_out.entry.attr, &sx, ATTR_TIMEOUT(&ext_out.entry), attr_version); - fuse_change_entry_timeout(entry, &ext_out.entry); + + parent = dget_parent(entry); + if (inode_peek_iversion(d_inode(parent)) == dir_version) + fuse_change_entry_timeout(entry, &ext_out.entry); + dput(parent); } else if (inode) { fi = get_fuse_inode(inode); if (flags & LOOKUP_RCU) { diff --git a/fs/fuse/fuse_i.h b/fs/fuse/fuse_i.h index 902b5e0759c3d8..c8c0b5a1477968 100644 --- a/fs/fuse/fuse_i.h +++ b/fs/fuse/fuse_i.h @@ -1264,6 +1264,8 @@ void fuse_invalidate_attr_mask(struct inode *inode, u32 mask); void fuse_invalidate_entry_cache(struct dentry *entry); +void fuse_entry_invalidated(struct dentry *entry); + void fuse_invalidate_atime(struct inode *inode); u64 fuse_time_to_jiffies(u64 sec, u32 nsec); diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c index a7c410f60e5b91..82c84a81b6e2d3 100644 --- a/fs/fuse/inode.c +++ b/fs/fuse/inode.c @@ -710,6 +710,7 @@ static void fuse_prune_aliases(struct inode *inode) spin_lock(&inode->i_lock); hlist_for_each_entry(dentry, &inode->i_dentry, d_u.d_alias) { + fuse_entry_invalidated(dentry); fuse_invalidate_entry_cache(dentry); } spin_unlock(&inode->i_lock); @@ -726,6 +727,7 @@ static void fuse_invalidate_inode_entry(struct inode *inode) dentry = d_find_alias(inode); if (dentry) { d_invalidate(dentry); + fuse_entry_invalidated(dentry); fuse_invalidate_entry_cache(dentry); dput(dentry); } @@ -737,6 +739,7 @@ static void fuse_invalidate_inode_entry(struct inode *inode) if (!d_unhashed(dentry)) __d_drop(dentry); spin_unlock(&dentry->d_lock); + fuse_entry_invalidated(dentry); fuse_invalidate_entry_cache(dentry); } spin_unlock(&inode->i_lock);