Skip to content

Fix races between notify invalidation and creation/handling of inodes - #194

Merged
hbirth merged 6 commits into
DDNStorage:redfs-ubuntu-noble-6.8.0-58.60from
hbirth:redfs-ubuntu-noble-6.8.0-58.60
Aug 6, 2026
Merged

Fix races between notify invalidation and creation/handling of inodes#194
hbirth merged 6 commits into
DDNStorage:redfs-ubuntu-noble-6.8.0-58.60from
hbirth:redfs-ubuntu-noble-6.8.0-58.60

Conversation

@hbirth

@hbirth hbirth commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

hbirth added 6 commits July 27, 2026 12:06
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 03eacfd ("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 2396356
   "fuse: add more control over cache invalidation behaviour" and
   64becd2 "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 <hbirthelmer@ddn.com>
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 <hbirthelmer@ddn.com>
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 <joannelkoong@gmail.com>
Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
Commit 03eacfd ("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 <hbirthelmer@ddn.com>
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 <hbirthelmer@ddn.com>
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 <hbirthelmer@ddn.com>

@yongzech yongzech left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@hbirth
hbirth merged commit a5b9946 into DDNStorage:redfs-ubuntu-noble-6.8.0-58.60 Aug 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants