Skip to content

Expire a directory entry instead of invalidating it - #718

Merged
cberner merged 1 commit into
masterfrom
claude/fuser-issues-triage-7tz8v9
Aug 1, 2026
Merged

Expire a directory entry instead of invalidating it#718
cberner merged 1 commit into
masterfrom
claude/fuser-issues-triage-7tz8v9

Conversation

@cberner

@cberner cberner commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Fixes #367 (ABI 7.38 milestone).

The problem

fuse_notify_inval_entry_out carries a flags word that fuser sent as a hardcoded zero, named padding. Its one defined bit, FUSE_EXPIRE_ONLY, asks the kernel to mark the entry for revalidation rather than forcibly detaching it -- and detaching also detaches anything mounted beneath it. A filesystem that merely suspects an entry is stale had no way to say so, and had to reach for the destructive form.

The change

Name the field and add Notifier::expire_entry(). This follows libfuse, which exposes the same thing as a separate fuse_lowlevel_notify_expire_entry() rather than a flags parameter -- with a single defined bit, a public bitflags type would be more surface than the feature needs.

expire_entry() fails closed: it returns ENOTSUP when the kernel did not advertise FUSE_HAS_EXPIRE_ONLY, rather than sending a request such a kernel would answer by invalidating the entry, detaching submounts, and reporting success. Nothing in the reply distinguishes that case, so leaving it to a documented precondition would mean the one call whose purpose is to be non-destructive could silently be the destructive one.

The guard keys on what the kernel advertised, not on the negotiated set. That distinction is load-bearing: the kernel honours the flag whether or not it was requested during init, so guarding on negotiated would reject every filesystem that never called add_capabilities. Session kept only the negotiated intersection, so this adds kernel_capabilities alongside it, threaded through SessionEventLoop and BackgroundSession into Notifier.

Verification

Two obvious checks turned out not to discriminate, which is worth recording since a reviewer would likely reach for them:

  • Sending an undefined bit (flags=0x2) returns Ok. The kernel does not validate that word at all, so acceptance proves nothing about whether it is read -- and by the same token, nothing could reject FUSE_EXPIRE_ONLY on an older kernel either.
  • Expiring and invalidating both trigger a fresh lookup, so counting lookups proves nothing.

The difference that does show up is submounts. With a bind mount over an entry of a mounted filesystem:

submount before after
inval_entry (flags=0) present detached
expire_entry (FUSE_EXPIRE_ONLY) present survives

Separately, running that probe with and without add_capabilities(FUSE_HAS_EXPIRE_ONLY) gave identical results, which is what establishes that the capability is an advertisement rather than a gate.

Tests: a byte-level assertion that the flags word is 0x01 where inval_entry's is 0x00, plus three over a real Notifier covering both sides of the guard -- refused with ENOTSUP when the capability is absent, sent when present, and inval_entry() unaffected since it depends on nothing. The guard was also re-checked end to end against a running kernel with a filesystem that never calls add_capabilities, the case a negotiated-set guard would have broken.

cargo test --all (82 lib tests), fmt, clippy --all-targets and clippy --no-default-features clean under RUSTFLAGS=--deny warnings; all-features build and x86_64-apple-darwin check clean.

Noticed but not fixed

The delete test in src/ll/notify.rs calls new_inval_entry and sends the result under the FUSE_NOTIFY_DELETE code, so new_delete() and its 24-byte fuse_notify_delete_out layout have no coverage. This PR only adds the new flags argument to that call so it keeps compiling; correcting the test changes what it asserts, which is unrelated to this change. Happy to do it separately.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e7a83d0ca

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/notify.rs
Comment on lines +92 to 94
let notif = Notification::new_inval_entry(parent, name, FUSE_EXPIRE_ONLY)
.map_err(Self::too_big_err)?;
self.send_inval(notify_code::FUSE_NOTIFY_INVAL_ENTRY, &notif)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard expire_entry on negotiated support

When this is called on a kernel that does not advertise FUSE_HAS_EXPIRE_ONLY, the last word is still treated as the old padding field, so older kernels accept the notification as a normal FUSE_NOTIFY_INVAL_ENTRY instead of rejecting it. In that unsupported-kernel scenario, callers who rely on the documented “too old” error can silently detach submounts—the behavior this API is meant to avoid—so the notifier needs to know/guard the negotiated capability or the precondition/error contract should be made explicit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

You are right, and the wrong part was mine: the doc said a kernel too old to support expiring would return an error. Nothing produces that error. An old kernel reads the word as the padding it used to be, invalidates, and reports success.

The evidence was already sitting in the PR description without my noticing what it meant. I had recorded that sending an undefined bit (flags=0x2) returns Ok, and filed it as "this test does not discriminate". It does discriminate, just for a different question: the kernel does no validation of that word at all, so there is no mechanism by which an older kernel could reject bit 0 either.

I then tested the negotiation half directly, which turned up a wrinkle worth recording. Re-running the submount probe without calling add_capabilities(FUSE_HAS_EXPIRE_ONLY):

== WITH negotiation ==      submount before: true   expire_entry -> Ok(())   after: true
== WITHOUT negotiation ==   submount before: true   expire_entry -> Ok(())   after: true

So FUSE_HAS_EXPIRE_ONLY is an advertisement from the kernel, not a gate: a kernel that supports expiring honours the flag whether or not it was requested. That matters for the fix you suggest -- a guard keyed on the negotiated set would reject the common case where a filesystem never requested the capability but the kernel supports it. A correct guard has to key on what the kernel advertised, and Session currently keeps only the negotiated intersection, so that means new session state threaded through to Notifier.

d8fdcce takes the "make the contract explicit" half of your suggestion, since that part is unambiguous and small:

A kernel that predates expiring treats the flag as the padding it used to be: it invalidates the entry, detaching any submounts, and still reports success. There is no error to detect that by, and the kernel honours the flag whether or not it was requested during init, so a filesystem that depends on the entry surviving must check KernelConfig::capabilities() for InitFlags::FUSE_HAS_EXPIRE_ONLY during init and not call this when it is absent.

I have deliberately not added the guard. Failing closed would match what this repo already does for FUSE_SETXATTR_ACL_KILL_SGID in #716, and I think it is the better end state, but it needs Notifier to carry kernel-advertised capabilities and it is a policy call on @cberner's API rather than a defect fix. Happy to add it here or as a follow-up -- your call.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Implemented in edbdb46 -- @cberner asked for the fail-closed version, so expire_entry() now returns ENOTSUP instead of sending a request the kernel would answer by invalidating.

The capability it keys on is the advertised set, not the negotiated one. That distinction is load-bearing: the kernel honours FUSE_EXPIRE_ONLY whether or not it was requested during init, so guarding on negotiated would reject every filesystem that never called add_capabilities. Session kept only the negotiated intersection, so this adds kernel_capabilities alongside it, threaded through SessionEventLoop and BackgroundSession into Notifier.

Verified both directions:

  • Three unit tests over a real Notifier: refused with ENOTSUP when the capability is absent, sent when present, and inval_entry() unaffected since it depends on nothing.
  • End to end against a running kernel, with a filesystem that never calls add_capabilities -- the guard passes on advertised support alone and the submount still survives:
submount before: true
expire_entry -> Ok(())
submount after:  true

That last case is the one that would have broken under a negotiated-set guard, which is why it is worth having a test for.


Generated by Claude Code

@cberner
cberner force-pushed the claude/fuser-issues-triage-7tz8v9 branch from 2e7a83d to d8fdcce Compare August 1, 2026 16:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8fdcce6e5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/ll/notify.rs Outdated
Comment on lines +160 to +161
/// Same notification, but with the flags word the kernel reads set to
/// FUSE_EXPIRE_ONLY instead of the zero that used to be hardcoded there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove transient history from the test comment

This added doc comment explains the test in terms of what “used to be hardcoded” in this commit, which is a transient implementation detail rather than a long-term invariant. Please either remove the comment or keep it focused on the durable behavior being asserted, such as the flags word carrying FUSE_EXPIRE_ONLY.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fair, and it is exactly what AGENTS.md rules out -- comments should carry long-term invariants, not minor implementation details of the current commit. "the zero that used to be hardcoded there" is commit-history prose that the code would have to keep maintaining.

Trimmed in f1f76e8 to the durable contrast the test actually asserts:

/// The same notification as `inval_entry`, but with FUSE_EXPIRE_ONLY in the flags word.

Generated by Claude Code

@cberner
cberner force-pushed the claude/fuser-issues-triage-7tz8v9 branch from d8fdcce to f1f76e8 Compare August 1, 2026 16:20
fuse_notify_inval_entry_out carries a flags word that fuser sent as a
hardcoded zero, named padding. Its one defined bit, FUSE_EXPIRE_ONLY,
asks the kernel to mark the entry for revalidation rather than forcibly
detaching it, which also detaches anything mounted beneath it. A
filesystem that merely suspects an entry is stale had no way to say so
and had to use the destructive form.

Name the field and add Notifier::expire_entry() to set the bit.

A kernel predating the flag reads the word as padding, invalidates, and
reports success, so nothing in the reply reveals that the request was
not honoured. Rather than leave that to a documented precondition, the
notifier now carries what the kernel advertised during init and refuses
to send with ENOTSUP when expiring is unsupported. The capability has to
be the advertised set rather than the negotiated one: the kernel honours
the flag whether or not it was requested, so keying on what was
requested would reject filesystems that never asked for it.

Confirmed against a running kernel that the two forms differ: with a
bind mount over an entry of a mounted filesystem, inval_entry() detaches
it and expire_entry() leaves it in place.

Fixes #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjYWzn3mu8Sc2y2eACtJM9
@cberner
cberner force-pushed the claude/fuser-issues-triage-7tz8v9 branch from f1f76e8 to edbdb46 Compare August 1, 2026 16:56
@cberner
cberner merged commit 4c65119 into master Aug 1, 2026
9 checks passed
@cberner
cberner deleted the claude/fuser-issues-triage-7tz8v9 branch August 1, 2026 17:30
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.

add FUSE_EXPIRE_ONLY flag to fuse_notify_inval_entry

2 participants