Skip to content

Update atime on read in the simple example - #739

Merged
cberner merged 1 commit into
masterfrom
claude/xfs-excluded-test-debug-6jz5er
Aug 4, 2026
Merged

Update atime on read in the simple example#739
cberner merged 1 commit into
masterfrom
claude/xfs-excluded-test-debug-6jz5er

Conversation

@cberner

@cberner cberner commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The example set last_accessed when an inode was created and when utimens asked for it, and never again, so atime never moved for a reader. generic/192 was catching that.

721 passing, up from 720.

The policy

Reads follow relatime, the kernel's default: atime moves only when it is no newer than mtime or ctime - that is, when nothing has read the inode since it last changed - or when it has gone stale by more than a day.

Moving it on every read would mean an inode write per read for no gain. What a reader can actually draw from atime is that an atime older than mtime means nobody has looked since the last write, and relatime preserves exactly that. In the steady state a read costs nothing extra: suite runtime is unchanged.

Four paths count as access, as they do on a local filesystem: read(), readdir(), readlink(), and the source side of copy_file_range(). That last one was missed in the first version and caught in review - it reads the source with read_exact_at without going through read(), so listing the handlers named after reading was not enough; the question is which handlers read data. Applying that: readdirplus is not implemented so the kernel falls back to readdir; getattr, lookup and getxattr read metadata rather than data, which Linux does not count; write and fallocate do not read.

read() also honors O_NOATIME on Linux. A read request carries the flag - verified by instrumenting a build rather than assuming:

normal read     flags=0x8000   noatime=false
O_NOATIME read  flags=0x48000  noatime=true    (O_LARGEFILE | O_NOATIME)

readdir() and copy_file_range() are not given open flags, so honoring it there would mean carrying a third bit in the file handle alongside FILE_HANDLE_READ_BIT and FILE_HANDLE_WRITE_BIT. I left that out - no test anywhere exercises O_NOATIME, and a directory or copy source opened with it is obscure next to a backup tool reading file data - and recorded the limitation in the helper's doc comment so it is not silently inconsistent. Say the word if you would rather have it complete.

A mount can ask for noatime or strictatime instead, but no FUSE request reports which the mount used and this example takes no option for it, so the kernel default is the only policy on offer.

Checked against tmpfs

tmpfs also mounts relatime, so it is like-for-like:

case fuser tmpfs
first read moves atime yes yes
second read leaves it (relatime) yes yes
readlink moves atime yes yes
copy_file_range moves source atime yes yes
second copy leaves it (relatime) yes yes
O_NOATIME read suppresses yes yes

generic/192 additionally requires atime to survive a mount cycle, which it does - the inode is on disk.

Worth flagging for review: O_NOATIME is the one part of this change with no coverage in either suite, so a green run says nothing about it. The tmpfs comparison is the only real check on those lines.

generic/003 stays excluded, with its reason corrected

It was filed as "requires atime support", which is not the whole story. It exercises the noatime, relatime and strictatime mount options plus a read-only mount, and fuse-xfstests cannot ask for any of them:

_fuser_mount() {
    local data_dir=$1
    local mnt=$2
    local suid="$4"          # $3, the "-o ..." string, is never read
    fuser --data-dir "$data_dir" --mount-point "$mnt" ${suid} $FUSER_EXTRA_MOUNT_OPTIONS ...
}

Its failures moved rather than disappeared, which is the evidence. Before: four errors saying atime never updates. After:

ERROR: access time has changed after accessing file3 first time            <- noatime phase
ERROR: access time has not been updated after accessing file3 second time  <- strictatime phase
ERROR: access time has not been updated after accessing file3 third time   <- strictatime phase
ERROR: access time has changed for dir in read-only filesystem             <- ro phase

Every one is an option the filesystem never sees. Same category as generic/306 and 452: it needs a change in fuse-xfstests, not in fuser.

Testing

All against the final commit:

  • Full xfstests: passed all 721, 1163s, no regressions and no runtime cost
  • pjdfstest: PASS, 205 files / 2732 tests, 0 failed
  • generic/192 re-enabled and passing
  • cargo clippy --all-targets clean under --deny warnings
  • cargo check --target aarch64-apple-darwin --features=macos-no-mount --examples clean

That last one is new, and I added it because I broke mac-ci and freebsd-ci on an earlier revision of this PR: libc::O_NOATIME is Linux-only and does not exist on either. The flag test is now behind #[cfg(target_os = "linux")], matching how the rename flags are already handled. The darwin cross-check reproduces mac-ci's exact invocation locally, so that class of breakage is catchable before pushing next time.

Known limitation, not addressed here

Review also raised that the atime write is an unguarded read-modify-write that can roll back a concurrent write/setattr under --n-threads > 1. That is real, but it is the example's existing model: there is no locking anywhere in the file, and all 38 write_inode sites have the same shape - two concurrent write()s already race identically. Fixing it means per-inode locking across every handler, which is an architectural change rather than part of an atime PR. The one genuinely new element is that reads were previously pure, so read-vs-write was not a racing pair at all; relatime narrows the window to the first read after a change but does not remove it. Happy to do the locking separately if wanted.

Remaining in the exclusion list

Nothing left that fuser alone can fix. generic/394's assertion already passes and only its cleanup fails, needing CAP_SYS_RESOURCE on the test container. generic/003, 294, 306 and 452 need mount options to reach the filesystem, which is a fuse-xfstests change. The rest are Docker restrictions, missing tooling, slow tests, or genuine design mismatches.

@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: f0e852e361

ℹ️ 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 examples/simple.rs Outdated
Comment on lines +1735 to +1737
if let Ok(mut attrs) = self.get_inode(ino) {
self.touch_atime(&mut attrs);
}

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 Touch atime for copy_file_range sources

When the kernel dispatches FUSE_COPY_FILE_RANGE (for example via copy_file_range(2)), the implementation below reads from src_inode with read_exact_at but never calls this atime updater. Linux advances the source file's atime for successful copies, so a freshly written source can still report atime < mtime after being copied even though normal read now updates it.

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.

Correct, and fixed in c6b1124. Confirmed against tmpfs before changing anything:

copy_file_range, source atime moved?    tmpfs YES    fuser (before) no
                                        tmpfs YES    fuser (after)  YES

relatime still applies on both: a second copy of the same source leaves atime alone.

Worth naming how I missed it, because the gap was in my method rather than a detail I overlooked. I went through read, readdir and readlink - the handlers named after reading - and never asked the other question, which is which handlers read data regardless of their name. copy_file_range reads the source directly with read_exact_at and never goes through read(), so it was invisible to the way I framed the check.

Applying the other question to the rest of the surface: readdirplus is not implemented here, so the kernel falls back to readdir, which is covered. getattr, lookup and getxattr read metadata, not data, and Linux does not move atime for those. write and fallocate do not read. That leaves nothing else, but the enumeration is the point - the previous list happened to be complete for the paths I had changed, not for the paths that access data.

The PR body still says three access paths and there are four; I will correct it.


Generated by Claude Code

@cberner
cberner force-pushed the claude/xfs-excluded-test-debug-6jz5er branch from f0e852e to c6b1124 Compare August 4, 2026 01:32

@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: c6b1124f38

ℹ️ 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 examples/simple.rs Outdated
let mut buffer = vec![0; read_size as usize];
file.read_exact_at(&mut buffer, offset as u64).unwrap();
if let Ok(mut attrs) = self.get_inode(ino) {
self.touch_atime(&mut attrs);

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 Respect O_NOATIME before touching atime

When a caller opens a file with O_NOATIME, Linux leaves atime unchanged for reads through that handle, but this new unconditional call advances it anyway. The FUSE read request already carries the file flags as _flags here, and for paths like readdir/copy_file_range the bit would need to be preserved from open in the file handle before calling touch_atime; otherwise backup/indexing tools that use O_NOATIME will dirty access times on this filesystem.

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.

Right about the divergence, and read() now honors it in 8aeefad. Two things I checked before changing anything, since the answer was not obvious to me.

Does the read request really carry the flag? Yes - I instrumented a build to print it rather than assume:

normal read     flags=0x8000   noatime=false
O_NOATIME read  flags=0x48000  noatime=true    (O_LARGEFILE | O_NOATIME)

So it is one condition in read(), no plumbing. Against tmpfs, before and after:

                O_NOATIME read      normal read
tmpfs           suppressed          moves
fuser (before)  MOVED               moves
fuser (after)   suppressed          moves

readdir() and copy_file_range() I have deliberately left alone, and I want to be straight that this is a judgement call rather than an oversight - it is the same shape of gap you caught in the copy_file_range thread. Neither is given open flags, so honoring O_NOATIME there means carrying a third bit in the file handle alongside FILE_HANDLE_READ_BIT and FILE_HANDLE_WRITE_BIT, set at open()/opendir(). That is roughly twenty lines and follows an existing pattern, so it is not hard - it is a question of whether it belongs in this example.

I came down against it on three grounds: no xfstest exercises O_NOATIME at all (I grepped tests/generic; the only hit anywhere is a src/ helper), opening a directory or a copy source with O_NOATIME is genuinely obscure next to a backup tool reading file data, and this example has been getting trimmed rather than grown.

Rather than leave that silently inconsistent, the limitation is now written into the touch_atime doc comment, naming which paths honor the flag and what carrying it further would take. Happy to add the plumbing if you would rather have it complete.

Worth flagging for review either way: O_NOATIME is the one part of this PR with no coverage in either suite, so a green run says nothing about it. The tmpfs comparison above is the only real check on those lines.


Generated by Claude Code

@cberner
cberner force-pushed the claude/xfs-excluded-test-debug-6jz5er branch from c6b1124 to 8aeefad Compare August 4, 2026 01:52
The example set last_accessed when an inode was created and when utimens
asked for it, and never again, so atime never moved for a reader. That is
what generic/192 was catching.

Reads now follow relatime, the kernel's default: atime moves only when it
is no newer than mtime or ctime - that is, when nothing has read the inode
since it last changed - or when it has gone stale by more than a day. The
alternative, moving it on every read, would mean an inode write per read
for no gain: what a reader can draw from atime is that an atime older than
mtime means nobody has looked since the last write, and relatime preserves
exactly that. read(), readdir(), readlink() and the source side of
copy_file_range() all count as access, as they do on a local filesystem.

read() also honors O_NOATIME, which a read request carries: a caller that
asked to leave no trace does not move atime. readdir() and
copy_file_range() are not given the flag and would have to carry it over
from the open() that set it, which the helper's comment records.

A mount can ask for noatime or strictatime instead, but no FUSE request
reports which the mount used and this example takes no option for it, so
the kernel default is the only policy on offer.

generic/003 stays excluded, with its reason corrected. It exercises the
noatime, relatime and strictatime mount options and a read-only mount, and
fuse-xfstests cannot ask for any of them: _fuser_mount reads $4 for suid
and drops the rest, so "_scratch_mount -o relatime" and
"_scratch_cycle_mount noatime" both arrive as a plain mount. Its failures
have moved from "atime never updates" to four complaints that are purely
about options the filesystem never sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSmtYYZ3resRdsCD1PwqUK
@cberner
cberner force-pushed the claude/xfs-excluded-test-debug-6jz5er branch from 8aeefad to 978d974 Compare August 4, 2026 01:57

@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: 978d974e0a

ℹ️ 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 examples/simple.rs
|| attrs.last_accessed.0 < a_day_ago
{
attrs.last_accessed = now;
self.write_inode(attrs);

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 Avoid overwriting inode metadata from atime updates

When the simple example is mounted with --n-threads > 1, this helper can run in parallel with write(), truncate(), or setattr() on the same inode. It receives a snapshot read before the atime update and write_inode rewrites the entire serialized inode here, so if another request updates size, mtime, mode, or xattrs between the caller's get_inode and this write, a read/readdir/readlink can roll those fields back; a read should not be able to undo a completed metadata or data change. Serialize inode updates or apply the atime change against the latest inode state instead of writing the stale snapshot.

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.

The race is real, but it is the example's existing concurrency model rather than something this change introduces, so I am not fixing it here.

Checked rather than assumed:

  • there is no locking anywhere in examples/simple.rs - no Mutex, no RwLock, nothing
  • there are 38 write_inode call sites, and every one is the same unguarded read-modify-write of the whole serialized record
  • the file already says of its file-handle scheme that it "isn't safe... this implementation is just a toy"

So two concurrent write()s to one inode already roll each other's size and mtime back, and setattr racing write already does the same. Serializing this properly means per-inode locking across all 38 sites, which is an architectural change to the example and not something to bolt onto an atime PR.

One part of your point I do want to separate out, because it is fair and is new: before this change reads were pure, so read-vs-write was not a racing pair at all. Making reads into writers adds a hazard category that did not exist, even though the mechanism is the same one already used everywhere else. relatime narrows it - the write only happens on the first read after a change, not on every read - but narrow is not none.

I considered re-reading the inode inside the helper just before writing. That only shrinks the window while leaving the same time-of-check-to-time-of-use hole, and it would read as if the problem had been handled, so I would rather leave the honest version.

Worth noting --n-threads defaults to 1 and the test suites do not pass it, so nothing in CI exercises this - which is also why I would not want to "fix" it half way on the strength of a green run.

Happy to do the locking as its own change if you want it; it would touch every handler, so it seems like your call rather than mine.


Generated by Claude Code

@cberner
cberner merged commit 2daa2ca into master Aug 4, 2026
9 checks passed
@cberner
cberner deleted the claude/xfs-excluded-test-debug-6jz5er branch August 4, 2026 02:26
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