From 0d538caf91381c0ed043c30de65e5bcb5b67a89c Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 21:22:55 +0200 Subject: [PATCH 1/5] File the defect: a file's mtime is nanoseconds since boot Stat::mtime is documented and stamped as nanoseconds since boot, DATA stores that number across reboots, the FAT adapter answers local Unix seconds instead, and std and libc read both as time since the epoch. A build tool comparing mtimes is misled by every reboot. Filed so the fix that follows closes it by name. Co-Authored-By: Claude Opus 5.5 --- ...a-files-mtime-is-nanoseconds-since-boot.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md diff --git a/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md b/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md new file mode 100644 index 0000000000..719f10cbb2 --- /dev/null +++ b/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# A file's mtime is nanoseconds since boot, so no build tool can compare two + +`toyos_abi::syscall::Stat::mtime` is documented as nanoseconds since boot, and +the kernel stamps it from `clock::nanos_since_boot` (`kernel/src/object/ops.rs` +at every open-to-create, write and `ftruncate`; `kernel/src/vfs.rs`'s flush of +a file a device view is taken over). DATA's bcachefs stores that number, so a +file written late in one boot reads as newer than one written early in the +next, and a build tool that decides a rebuild by comparing mtimes — ninja, +make — is misled by every reboot. + +The units are not even one epoch across mounts. The FAT adapter answers +`file_mtime` in local Unix *seconds* off the directory entry while a handle +that writes carries since-boot nanoseconds; std's `Metadata::modified` reads every +one of them as nanoseconds since `UNIX_EPOCH`, and libc's `fstat` puts the +nanoseconds into `st_mtime`, which POSIX defines as seconds. + +**Exit condition.** An mtime is nanoseconds since the Unix epoch in UTC, taken +off the wall clock at the write on every mount, stored to the precision the +filesystem keeps, and unchanged across a reboot — judged against the instant +the host stages in the RTC with `-rtc base=`. From 32948daf3f49830c1fad8ab832865e24ab29bd30 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 21:25:32 +0200 Subject: [PATCH 2/5] A file's mtime is wall-clock time: nanoseconds since the Unix epoch, UTC The owner approved this ABI change for the self-hosting track: ninja and make decide rebuilds by mtime, and an mtime since boot made every reboot reorder the tree. Stat::mtime is now nanoseconds since the Unix epoch in UTC, off the wall clock at the write, and never a time since boot. The layout is unchanged (a u64); the meaning is the kernel's to keep: - kernel/src/clock.rs: utc_nanos is the RTC's whole-second anchor carried on by the counter, and utc_secs (SYS_CLOCK_EPOCH) is now its whole seconds, so a stamp taken after the epoch syscall answered s is at least s seconds - the two can never disagree about a second. mtime_now is what a write stamps: utc_nanos, or on a machine whose RTC never answered, a clock that starts at the epoch at boot, so one boot's stamps still order. local_of_utc and utc_of_local convert for a format that stores local time. - object/ops.rs, vfs.rs and the two self-tests stamp with mtime_now where they stamped nanos_since_boot. - fat32_adapter.rs stamps the mtime the VFS hands it (the write's instant, not the flush's) as local seconds, and answers file_mtime as UTC nanoseconds; it used to answer local seconds, a different unit from every other mount. FAT keeps two seconds of local time, which the ABI doc now says, and toyos-fat32 gains a host test that a write time drops the odd second. - bcachefs and tmpfs store the u64 as given, so DATA keeps nanoseconds across a reboot; ROOT's image stamps 0, as src/image.rs always did. - libc's struct stat gains POSIX's st_atim/st_mtim/st_ctim, with st_mtime as the macro POSIX defines; fstat put nanoseconds into st_mtime, which is seconds. - std needs no change: the fork's Metadata::modified already reads the word as nanoseconds since UNIX_EPOCH. sshd's SFTP attributes, which read modified(), now report real times. The contract is the one PR #536's fsd already meets: it reports nanoseconds since the epoch, off SYS_CLOCK_EPOCH's whole seconds. Tests: file_mtime judges /tmp's stamp between two SYS_CLOCK_EPOCH readings and requires a second write's stamp to be later (whole seconds cannot say that), on the shared boot; its write/read modes are driven by file_mtime_survives_a_reboot, which stages the RTC with -rtc base=, checks DATA's stamp against that instant, reads the same stamp off the image with the host's bcachefs reader, and boots again with the RTC a day on to read it back unchanged. Closes issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md. Files what the audit found and this does not fix: userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds, the-last-handle-to-close-stamps-the-file-with-its-own-mtime, a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen, std-answers-an-mtime-of-1970-for-what-it-did-not-stat. Co-Authored-By: Claude Opus 5.5 --- ...-through-its-writer-than-after-a-reopen.md | 17 +++ ...a-files-mtime-is-nanoseconds-since-boot.md | 26 ---- ...-mtime-of-1970-for-what-it-did-not-stat.md | 22 ++++ ...lose-stamps-the-file-with-its-own-mtime.md | 26 ++++ ...le-seconds-and-an-mtime-has-nanoseconds.md | 26 ++++ kernel/src/clock.rs | 37 +++++- kernel/src/fat32_adapter.rs | 21 +-- kernel/src/leak_selftest.rs | 2 +- kernel/src/object/ops.rs | 8 +- kernel/src/revoke_selftest.rs | 2 +- kernel/src/vfs.rs | 5 +- tests/common/wallclock.rs | 123 ++++++++++++++++++ tests/toyos-rust-tests/src/bin/file_mtime.rs | 74 +++++++++++ tests/toyos.rs | 9 ++ toyos-abi/src/syscall.rs | 7 +- toyos-fat32/src/dir.rs | 19 +++ userland/libc/include/sys/stat.h | 10 +- userland/libc/src/posix_io.rs | 15 ++- 18 files changed, 396 insertions(+), 53 deletions(-) create mode 100644 issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md delete mode 100644 issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md create mode 100644 issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md create mode 100644 issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md create mode 100644 issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md create mode 100644 tests/toyos-rust-tests/src/bin/file_mtime.rs diff --git a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md new file mode 100644 index 0000000000..d8b4448a34 --- /dev/null +++ b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md @@ -0,0 +1,17 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# A FAT file's mtime reads finer through its writer than after a reopen + +A handle's `fstat` answers the mtime the handle holds (`kernel/src/object/ops.rs`), +which a write sets to `clock::mtime_now` in nanoseconds. A FAT volume stores a +write time as two seconds of local time (`kernel/src/fat32_adapter.rs`'s +`stamp`), so the same file opened again answers the even second at or below +it: one file, two mtimes, and the one a later reader sees is the older. + +**Exit condition.** A handle holds the mtime its mount stores — rounded to the +mount's precision at the write — or the kernel mounts no FAT volume a process +writes. diff --git a/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md b/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md deleted file mode 100644 index 719f10cbb2..0000000000 --- a/issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# A file's mtime is nanoseconds since boot, so no build tool can compare two - -`toyos_abi::syscall::Stat::mtime` is documented as nanoseconds since boot, and -the kernel stamps it from `clock::nanos_since_boot` (`kernel/src/object/ops.rs` -at every open-to-create, write and `ftruncate`; `kernel/src/vfs.rs`'s flush of -a file a device view is taken over). DATA's bcachefs stores that number, so a -file written late in one boot reads as newer than one written early in the -next, and a build tool that decides a rebuild by comparing mtimes — ninja, -make — is misled by every reboot. - -The units are not even one epoch across mounts. The FAT adapter answers -`file_mtime` in local Unix *seconds* off the directory entry while a handle -that writes carries since-boot nanoseconds; std's `Metadata::modified` reads every -one of them as nanoseconds since `UNIX_EPOCH`, and libc's `fstat` puts the -nanoseconds into `st_mtime`, which POSIX defines as seconds. - -**Exit condition.** An mtime is nanoseconds since the Unix epoch in UTC, taken -off the wall clock at the write on every mount, stored to the precision the -filesystem keeps, and unchanged across a reboot — judged against the instant -the host stages in the RTC with `-rtc base=`. diff --git a/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md b/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md new file mode 100644 index 0000000000..fd2d6a952c --- /dev/null +++ b/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md @@ -0,0 +1,22 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# std answers an mtime of 1970 for what it did not stat, and sets none it is asked to + +The fork's `library/std/src/sys/fs/toyos.rs` reads a file's mtime only off +`SYS_FSTAT`. Everywhere else it invents one: `DirEntry::metadata`, `stat` of a +directory and `lstat` of a symlink all answer `mtime: 0`, which +`Metadata::modified` reports as `UNIX_EPOCH` — a real-looking instant, not an +error. A program walking a tree through `read_dir` and comparing +`entry.metadata()?.modified()?` sees every file as written in 1970. + +And `File::set_times`, `fs::set_times` and `set_times_nofollow` return `Ok(())` +having set nothing, so a tool that stamps an output (`touch`, a build system's +restat) is told it did. + +**Exit condition.** Each answers the mtime the kernel keeps for that name or +an error saying it has none, and a time-setting call sets the stamp through the +kernel or is refused as `Unsupported`. diff --git a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md new file mode 100644 index 0000000000..ac699d7d08 --- /dev/null +++ b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# The last handle to close stamps the file with its own mtime, not the last write's + +An mtime lives on the handle (`kernel/src/object/file.rs`'s +`OpenFileState::mtime`): it is the file's stored stamp at the open, and a write +or `ftruncate` through that handle moves it (`kernel/src/object/ops.rs`). A +close that leaves another handle open enqueues nothing +(`file_cache::release_to_writeback` answers `StillHeld`), and the last close +enqueues the flush with *its* handle's mtime (`kernel/src/writeback.rs`). + +So a file opened for reading at `t0`, written through a second handle at `t1`, +closed by the writer and then by the reader, is flushed with `t0`: the stored +mtime says the file has not changed since before the write, and a build tool +that compares mtimes does not rebuild from it. + +**Mechanism read off the code; not reproduced.** + +**Exit condition.** The mtime is the file's and not a handle's — each write +moves the one stamp every flush of the file stores — with a guest test that +writes through one handle, closes a reader last, and reads the write's stamp +back after a reopen. diff --git a/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md b/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md new file mode 100644 index 0000000000..58930bd1c5 --- /dev/null +++ b/issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# Userland's wall clock has whole seconds and a file's mtime has nanoseconds + +The kernel stamps a file with `clock::mtime_now` (`kernel/src/clock.rs`), the +RTC's second carried on by the counter, so a write inside a second carries the +nanoseconds past it. Userland reads the wall clock only through +`SYS_CLOCK_EPOCH`, which answers whole seconds: std's `SystemTime::now` (the +fork's `library/std/src/sys/time/toyos.rs`) and libc's `clock_gettime(CLOCK_REALTIME)` +and `gettimeofday` (`userland/libc/src/time.rs`) all round down to the second. + +So a file written a moment ago reads as up to a second in the future against +the program's own "now", which a tool comparing at nanoseconds — GNU make's +"modification time in the future" check — reports as clock skew. And a file +server in userland can stamp only what it can read — a stamp it takes off +`SYS_CLOCK_EPOCH` is a whole second, so two writes inside one second carry one +mtime, which a build tool reads as "not newer". + +**Exit condition.** Userland reads the wall clock at the resolution the kernel +stamps at — the boot's UTC anchor published where a process reads the +monotonic clock (`toyos_abi::clock`'s page), or a syscall that answers +nanoseconds — and std, libc and every file server stamp and compare off it. diff --git a/kernel/src/clock.rs b/kernel/src/clock.rs index 439b8c83a4..44309e2967 100644 --- a/kernel/src/clock.rs +++ b/kernel/src/clock.rs @@ -175,9 +175,40 @@ pub fn local_secs() -> Option { .then(|| BOOT_LOCAL_SECS.load(Relaxed) + nanos_since_boot() / 1_000_000_000) } -/// The same instant in Unix seconds (UTC) — what `SYS_CLOCK_EPOCH` serves. +/// The same instant in Unix seconds (UTC) — what `SYS_CLOCK_EPOCH` serves: the +/// whole seconds of [`utc_nanos`], so a file written after this answered `s` +/// carries a stamp of at least `s` seconds. pub fn utc_secs() -> Option { - let local = local_secs()?; - Some(local.saturating_add_signed(UTC_OFFSET_SECS.load(Relaxed))) + utc_nanos().map(|nanos| nanos / NANOS_PER_SEC) +} + +pub const NANOS_PER_SEC: u64 = 1_000_000_000; + +/// Nanoseconds since the Unix epoch, UTC: the RTC's whole-second reading carried +/// on by the counter, so its resolution is the counter's and its accuracy the +/// RTC's second. +pub fn utc_nanos() -> Option { + WALL_KNOWN.load(Acquire).then(|| { + let boot = BOOT_LOCAL_SECS.load(Relaxed).saturating_add_signed(UTC_OFFSET_SECS.load(Relaxed)); + boot.saturating_mul(NANOS_PER_SEC).saturating_add(nanos_since_boot()) + }) +} + +/// What a file written now is stamped with (`toyos_abi::syscall::Stat::mtime`): +/// [`utc_nanos`], and on a machine whose RTC never answered — which +/// [`init_wall`] said by name — a wall clock that starts at the epoch at boot, +/// so one boot's stamps still order. +pub fn mtime_now() -> u64 { + utc_nanos().unwrap_or_else(nanos_since_boot) +} + +/// `utc` Unix seconds in the machine's zone, for a format that stores local time. +pub fn local_of_utc(utc: u64) -> u64 { + utc.saturating_add_signed(-UTC_OFFSET_SECS.load(Relaxed)) +} + +/// The inverse of [`local_of_utc`]. +pub fn utc_of_local(local: u64) -> u64 { + local.saturating_add_signed(UTC_OFFSET_SECS.load(Relaxed)) } diff --git a/kernel/src/fat32_adapter.rs b/kernel/src/fat32_adapter.rs index 6ad2b748d5..153239685c 100644 --- a/kernel/src/fat32_adapter.rs +++ b/kernel/src/fat32_adapter.rs @@ -653,10 +653,15 @@ pub struct FatFs { repair_named: RepairNotice, } -/// What to stamp on an entry: reads `clock` directly, in local time as FAT -/// requires — the VFS's `mtime` is nanoseconds since boot, not a time of day. +/// A VFS `mtime` as FAT stores it: local time, whole seconds, and the two-second +/// field of a write time drops the odd one. +fn stamp(mtime: u64) -> FatTime { + FatTime::from_unix_secs(crate::clock::local_of_utc(mtime / crate::clock::NANOS_PER_SEC)) +} + +/// What to stamp on an entry the VFS gave no `mtime` for. fn now() -> FatTime { - crate::clock::local_secs().map_or(FatTime::EPOCH, FatTime::from_unix_secs) + stamp(crate::clock::mtime_now()) } /// What one of `toyos-fat32`'s errors means to the [`FileSystem`] caller; @@ -917,7 +922,7 @@ impl FileSystem for FatFs { let role = self.role; self.fs .metadata(name) - .map(|m| m.modified_unix) + .map(|m| crate::clock::utc_of_local(m.modified_unix) * crate::clock::NANOS_PER_SEC) .map_err(|e| refused(role, &self.fs, &mut self.repair_named, "metadata", name, e)) } @@ -945,12 +950,12 @@ impl FileSystem for FatFs { Ok((file_id, Some(backing))) } - fn create(&mut self, name: &str, _mtime: u64) -> Result { + fn create(&mut self, name: &str, mtime: u64) -> Result { if let Some(&file_id) = self.by_name.get(name) { return Ok(file_id); } let role = self.role; - let time = now(); + let time = stamp(mtime); self.ensure_parent(name, time)?; let file = match self.fs.create(name, time) { Ok(file) => file, @@ -1043,10 +1048,10 @@ impl FileSystem for FatFs { &mut self, file_id: FileId, size: u64, - _mtime: u64, + mtime: u64, ) -> Result<(), SyscallError> { let role = self.role; - let time = now(); + let time = stamp(mtime); let name = { let known = self.open.get(&file_id).ok_or(SyscallError::NotFound)?; let (name, was) = (known.name.clone(), known.file.len()); diff --git a/kernel/src/leak_selftest.rs b/kernel/src/leak_selftest.rs index ffef8cfd6c..23ece90cfe 100644 --- a/kernel/src/leak_selftest.rs +++ b/kernel/src/leak_selftest.rs @@ -14,7 +14,7 @@ fn fat_reopen_census() { const PATH: &str = "/log/lrfile"; - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let id = match vfs::lock().create_file(PATH, mtime) { Ok(id) => id, Err(e) => { diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs index 3808154fb2..40f6143e77 100644 --- a/kernel/src/object/ops.rs +++ b/kernel/src/object/ops.rs @@ -111,7 +111,7 @@ pub fn open(table: &mut HandleTable, path: &str, flags: OpenFlags) -> u64 { } let built = if truncate && create { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); // `NotFound` is not a failure: truncating past a name that was not there is fine. // Any `vfs.delete` error other than `NotFound` is propagated, not swallowed: truncating past it could silently create a file over one the mount could not confirm was missing. match vfs.delete(target.as_str()) { @@ -129,7 +129,7 @@ pub fn open(table: &mut HandleTable, path: &str, flags: OpenFlags) -> u64 { (file_id, mtime, position) }), Err(SyscallError::NotFound) if create => { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); vfs.create_file(target.as_str(), mtime).map(|file_id| (file_id, mtime, 0)) } Err(e) => Err(e), @@ -523,7 +523,7 @@ pub fn try_write(object: &KObjectRef, buf: &UserBytes) -> Option { } state.position += written; // Dirty state lives in the cache now, set by `write_page`; the handle keeps only the mtime. - state.mtime = crate::clock::nanos_since_boot(); + state.mtime = crate::clock::mtime_now(); Some(written as u64) }), KObjectRef::PipeWrite(w) => write_pipe(w.id(), buf), @@ -818,7 +818,7 @@ pub fn ftruncate(object: &KObjectRef, size: u64) -> u64 { } // The seek pointer is not touched (POSIX ftruncate): a shrink leaves it past EOF. file.with(|state| { - state.mtime = crate::clock::nanos_since_boot(); + state.mtime = crate::clock::mtime_now(); 0 }) } diff --git a/kernel/src/revoke_selftest.rs b/kernel/src/revoke_selftest.rs index d44a28763f..6617b5eeeb 100644 --- a/kernel/src/revoke_selftest.rs +++ b/kernel/src/revoke_selftest.rs @@ -20,7 +20,7 @@ fn fail(path: &str, step: &str) { } fn probe(path: &str) { - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let id = match vfs::lock().create_file(path, mtime) { Ok(id) => id, Err(e) => return fail(path, &alloc::format!("create: {e:?}")), diff --git a/kernel/src/vfs.rs b/kernel/src/vfs.rs index efd94d853a..1a83e089ae 100644 --- a/kernel/src/vfs.rs +++ b/kernel/src/vfs.rs @@ -67,7 +67,8 @@ pub trait FileSystem: Send { /// the mount's — a bcachefs answer of no reads the whole tree. fn is_dir(&mut self, dir: &str) -> Result; - /// When `name` was last written, in whatever epoch the mount keeps. + /// When `name` was last written, as `toyos_abi::syscall::Stat::mtime` says, + /// to the precision the mount stores. fn file_mtime(&mut self, name: &str) -> Result; /// What `name` points at; `Ok(None)` for a non-link or an absent name, never for a device that would not answer. @@ -720,7 +721,7 @@ impl Vfs { }; if let Some(file_id) = dirty { // The flush's own instant: no one handle's last write is the file's. - let mtime = crate::clock::nanos_since_boot(); + let mtime = crate::clock::mtime_now(); let owner = String::from(target.as_str()); self.flush_file(&owner, file_id, mtime)?; } diff --git a/tests/common/wallclock.rs b/tests/common/wallclock.rs index a8a18503cd..029126ce19 100644 --- a/tests/common/wallclock.rs +++ b/tests/common/wallclock.rs @@ -390,3 +390,126 @@ pub fn century_from_the_register( eprintln!(" [clock] century register 0x21: {}, a century past the staged clock", only.name); Ok(()) } + +/// Where [`file_mtime_survives_a_reboot`] writes, on DATA: the one volume a +/// file outlives its boot on. +const MTIME_PATH: &str = "/home/file-mtime.bin"; + +/// The second boot's RTC, a day past [`RTC_BASE`]: a stamp taken again at the +/// mount or the open would carry this day, so an unchanged one was carried. +const RTC_NEXT_DAY: &str = "2033-03-08T09:14:25"; + +/// What `file_mtime` printed for [`MTIME_PATH`], in nanoseconds. +fn printed_mtime(result: &qemu::TestResult) -> Result { + let head = format!("file-mtime: {MTIME_PATH} mtime="); + result + .stdout + .lines() + .find_map(|l| l.trim().strip_prefix(head.as_str())) + .and_then(|n| n.parse().ok()) + .ok_or_else(|| { + format!( + "`{}` printed no {head:?} line (exit {:?})\n{}{}{}", + result.name, result.exit_code, result.before, result.stdout, result.serial + ) + }) +} + +/// One boot of the image `data` carries DATA on, with the RTC at `rtc_base`, +/// running `file_mtime MTIME_PATH`, then shut down. +fn mtime_boot( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], + data: &Path, + rtc_base: &'static str, + mode: &str, +) -> Result { + let mut qemu = QemuInstance::boot_with_options( + test_config, + c_bins, + rust_bins, + BootOptions { + nvme_image: Some(data.to_path_buf()), + rtc_base: Some(rtc_base), + ..Default::default() + }, + ); + let boot = qemu.boot_log().to_string(); + if boot.contains("are a tmpfs") { + return Err(format!("/home fell back to tmpfs, so no file of it outlives the boot:\n{boot}")); + } + let result = qemu.run_test(&format!("test_rs_file_mtime {mode} {MTIME_PATH}"), Duration::from_secs(60)); + let printed = printed_mtime(&result); + writeln!(qemu.stdin_mut(), "run shutdown").expect("write to QEMU stdin"); + qemu.flush_stdin(); + let tail = qemu.drain_serial(Duration::from_secs(20)); + drop(qemu); + for bad in ["PANIC:", "panicked at"] { + if tail.contains(bad) { + return Err(format!("{bad:?} on the way down\n{tail}")); + } + } + if result.exit_code != Some(0) { + return Err(format!( + "`file_mtime {mode}` failed:\n{}\nkernel log while it ran:\n{}{}", + result.stdout, result.before, result.serial + )); + } + printed +} + +/// A file's mtime is the wall clock at its write, and a reboot carries it +/// unchanged. +/// +/// The oracle is the instant the host staged with `-rtc base=`: the guest's +/// stamp for a file on DATA lies within [`MAX_BOOT_DRIFT_SECS`] of it, the +/// DATA volume read off the image by the host's own `bcachefs` reader holds +/// that same stamp, and a second boot with the clock a day on reads it back +/// unchanged. A stamp since boot is decades short of the instant. +pub fn file_mtime_survives_a_reboot( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], +) -> Result<(), String> { + const NANOS_PER_SEC: u64 = 1_000_000_000; + + let data = super::lane::dir().join("file-mtime-data.img"); + toyos_build::build::create_sparse(&data, qemu::NVME_SMALL); + + let written = mtime_boot(test_config, c_bins, rust_bins, &data, RTC_BASE, "write")?; + let drift = (written / NANOS_PER_SEC) as i64 - RTC_BASE_SECS; + if !(0..=MAX_BOOT_DRIFT_SECS).contains(&drift) { + return Err(format!( + "{MTIME_PATH} is stamped {written} ns, {drift} s from the {RTC_BASE} the host set the \ + RTC to" + )); + } + + let io = super::storage::FileBlocks::open(&data)?; + let fs = bcachefs::Mounted::<_, bcachefs::ReadOnly>::open(io) + .map_err(|e| format!("the DATA volume does not mount on the host: {e:?}"))?; + let on_device = fs + .file_mtime(MTIME_PATH.trim_start_matches('/')) + .map_err(|e| format!("reading {MTIME_PATH}'s mtime off the image: {e:?}"))?; + drop(fs); + if on_device != Some(written) { + return Err(format!( + "the guest read {written} ns for {MTIME_PATH} and the device holds {on_device:?}" + )); + } + + let read = mtime_boot(test_config, c_bins, rust_bins, &data, RTC_NEXT_DAY, "read")?; + if read != written { + return Err(format!( + "{MTIME_PATH} was stamped {written} ns and reads {read} ns after a reboot with the RTC \ + at {RTC_NEXT_DAY}" + )); + } + let _ = std::fs::remove_file(&data); + eprintln!( + " [clock] {MTIME_PATH} stamped {drift} s past the staged RTC, the same {written} ns on \ + the device and after a reboot a day on" + ); + Ok(()) +} diff --git a/tests/toyos-rust-tests/src/bin/file_mtime.rs b/tests/toyos-rust-tests/src/bin/file_mtime.rs new file mode 100644 index 0000000000..41a307e843 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/file_mtime.rs @@ -0,0 +1,74 @@ +//! A file's mtime is the wall clock at its write, in nanoseconds since the Unix +//! epoch (`toyos_abi::syscall::Stat::mtime`). +//! +//! With no arguments, on the shared boot, it judges `/tmp`: the stamp lies +//! between two `SYS_CLOCK_EPOCH` readings taken around the write, and a second +//! write's stamp is later than the first's, which a clock of whole seconds +//! cannot say. `write ` makes the first judgement on `path` and `read +//! ` only prints what it finds there; `file_mtime_survives_a_reboot` +//! (`tests/common/wallclock.rs`) drives the two across a reboot and holds the +//! printed stamp against the instant the host staged in the RTC. + +use std::fs; +use std::io::Write; +use std::time::UNIX_EPOCH; + +const NANOS_PER_SEC: u64 = 1_000_000_000; + +fn mtime(path: &str) -> u64 { + let modified = fs::metadata(path) + .unwrap_or_else(|e| panic!("stat {path}: {e}")) + .modified() + .unwrap_or_else(|e| panic!("{path} has no mtime: {e}")); + let since = modified + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| panic!("{path}'s mtime is before the epoch")); + u64::try_from(since.as_nanos()).expect("an mtime past 2554") +} + +fn epoch() -> u64 { + toyos::system::clock_epoch().expect("SYS_CLOCK_EPOCH: this machine will not say what time it is") +} + +/// Writes `path` and returns its stamp, judged against the wall clock around the write. +fn write_judged(path: &str, bytes: &[u8]) -> u64 { + let before = epoch(); + { + let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); + f.write_all(bytes).unwrap_or_else(|e| panic!("write {path}: {e}")); + f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); + } + let after = epoch(); + let stamp = mtime(path); + // `SYS_CLOCK_EPOCH` is the whole seconds of the clock that stamps, so the + // write's instant is at or past `before` and short of the second after `after`. + assert!( + before * NANOS_PER_SEC <= stamp && stamp < (after + 1) * NANOS_PER_SEC, + "{path} is stamped {stamp} ns, and the wall clock read {before} s before the write and \ + {after} s after it", + ); + stamp +} + +fn main() { + let args: Vec = std::env::args().collect(); + match args.as_slice() { + [_] => { + let first = write_judged("/tmp/file-mtime-first", b"first"); + let second = write_judged("/tmp/file-mtime-second", b"second"); + assert!( + second > first, + "a write after another is stamped {second} ns and the one before it {first} ns" + ); + println!("file-mtime: /tmp stamps {first} then {second}"); + } + [_, mode, path] if mode == "write" => { + let stamp = write_judged(path, b"stamped at its write"); + println!("file-mtime: {path} mtime={stamp}"); + } + [_, mode, path] if mode == "read" => { + println!("file-mtime: {path} mtime={}", mtime(path)); + } + _ => panic!("usage: file_mtime [write | read ], got {args:?}"), + } +} diff --git a/tests/toyos.rs b/tests/toyos.rs index ff90ef5aae..4a1ce0b070 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -474,6 +474,9 @@ const DRIVEN_AND_SHARED: &[&str] = &[ // The log-stream arms drive it for the kernel's `exit:` record about it, // not for anything it does: it is the cheapest process this tree starts. "empty_dir_stat", + // Its shared run judges `/tmp`'s stamps; `file_mtime_survives_a_reboot` + // drives it on DATA across a reboot. + "file_mtime", "hierarchy_paths", "null_sink_client_exits", "nvme_home_roundtrip", @@ -1346,6 +1349,8 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("wall_clock_no_century", Sched::Parallel, Tier::Weekly), ("wall_clock_century_register", Sched::Parallel, Tier::Weekly), ("wall_clock_zone", Sched::Parallel, Tier::Weekly), + // Two boots over one DATA image, the stamp judged against the staged RTC. + ("file_mtime_survives_a_reboot", Sched::Parallel, Tier::Nightly), // `xhci_slow_connect`'s shape against the disk's port, but its actuator // masks the port until `BOOT_SCAN_DONE` — a kernel event, not a duration — // so what it stages is an ordering with no wall-clock margin on either @@ -1616,6 +1621,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("wall_clock_no_century", &["test_rs_wall_clock_now"]), ("wall_clock_century_register", &["test_rs_wall_clock_now"]), ("wall_clock_zone", &["test_rs_wall_clock_now"]), + ("file_mtime_survives_a_reboot", &["test_rs_file_mtime"]), ("screen_console_clear", &["test_rs_test_screen_graffiti"]), ("screen_console_scroll", &["test_rs_test_screen_churn"]), ("screen_console_panic", &["test_rs_test_panic_child"]), @@ -11620,6 +11626,9 @@ fn run_machine_test( "wall_clock_zone" => { common::wallclock::zone_from_firmware(test_config, c_bins, rust_bins) } + "file_mtime_survives_a_reboot" => { + common::wallclock::file_mtime_survives_a_reboot(test_config, c_bins, rust_bins) + } "late_storage_connect" => common::volumes::late_storage_connect(test_config, c_bins, rust_bins), "root_candidate_malformed" => { common::volumes::root_candidate_malformed(test_config, c_bins, rust_bins) diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index f471ce741f..ad62978fe0 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -686,7 +686,12 @@ pub struct RealTime { pub struct Stat { pub file_type: FileType, pub size: u64, - /// Last modification time (nanoseconds since boot). + /// When the file was last written: nanoseconds since the Unix epoch, UTC, + /// off the wall clock at the write — never a time since boot. Kept to the + /// precision its filesystem stores (`kernel/src/fat32_adapter.rs`: two + /// seconds; `src/image.rs` stamps ROOT's files 0), and on a machine whose + /// RTC never answered, off a clock that starts at the epoch at boot + /// (`kernel/src/clock.rs`'s `mtime_now`). pub mtime: u64, } diff --git a/toyos-fat32/src/dir.rs b/toyos-fat32/src/dir.rs index f2aa38837b..653f7c687d 100644 --- a/toyos-fat32/src/dir.rs +++ b/toyos-fat32/src/dir.rs @@ -605,3 +605,22 @@ impl Fat32 { self.write_entry_at(offset, &raw) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A write time is the two-second field alone — the odd second a + /// [`FatTime`] carries in `tenths` is a creation time's — so what a volume + /// answers for when a file was written is the even second at or below it. + #[test] + fn a_write_time_keeps_the_even_second() { + // 2033-03-07 09:14:25. + let odd = 1_993_799_665; + let mut raw = RawEntry::zeroed(); + raw.set_write_time(FatTime::from_unix_secs(odd)); + assert_eq!(raw.write_time().to_unix_secs(), odd - 1); + raw.set_write_time(FatTime::from_unix_secs(odd - 1)); + assert_eq!(raw.write_time().to_unix_secs(), odd - 1); + } +} diff --git a/userland/libc/include/sys/stat.h b/userland/libc/include/sys/stat.h index 8c02bb5a37..bf24651b0b 100644 --- a/userland/libc/include/sys/stat.h +++ b/userland/libc/include/sys/stat.h @@ -15,11 +15,15 @@ struct stat { off_t st_size; blksize_t st_blksize; blkcnt_t st_blocks; - time_t st_atime; - time_t st_mtime; - time_t st_ctime; + struct timespec st_atim; + struct timespec st_mtim; + struct timespec st_ctim; }; +#define st_atime st_atim.tv_sec +#define st_mtime st_mtim.tv_sec +#define st_ctime st_ctim.tv_sec + #define S_IFMT 0170000 #define S_IFSOCK 0140000 #define S_IFLNK 0120000 diff --git a/userland/libc/src/posix_io.rs b/userland/libc/src/posix_io.rs index d273417a65..2b9a6e0821 100644 --- a/userland/libc/src/posix_io.rs +++ b/userland/libc/src/posix_io.rs @@ -7,6 +7,8 @@ use core::ptr; use toyos_abi::RawHandle; use toyos_abi::syscall::{self, OpenFlags, SeekFrom}; +use crate::time::Timespec; + // Constants (matching POSIX / Linux values) const O_RDONLY: i32 = 0; @@ -145,7 +147,10 @@ pub unsafe extern "C" fn fstat(raw_fd: i32, buf: *mut Stat) -> i32 { ptr::write_bytes(buf, 0, 1); let s = &mut *buf; s.st_size = st.size as i64; - s.st_mtime = st.mtime as i64; + s.st_mtim = Timespec { + tv_sec: (st.mtime / NANOS_PER_SEC) as i64, + tv_nsec: (st.mtime % NANOS_PER_SEC) as i64, + }; s.st_mode = match st.file_type { syscall::FileType::File => S_IFREG | 0o644, syscall::FileType::Pipe => S_IFIFO | 0o644, @@ -487,11 +492,13 @@ pub struct Stat { pub st_size: i64, pub st_blksize: i64, pub st_blocks: i64, - pub st_atime: i64, - pub st_mtime: i64, - pub st_ctime: i64, + pub st_atim: Timespec, + pub st_mtim: Timespec, + pub st_ctim: Timespec, } +const NANOS_PER_SEC: u64 = 1_000_000_000; + // mmap/munmap (real implementations using toyos-abi) #[no_mangle] From 57cc41e46be9ed967ffcef0e08436d36864c4533 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 28 Sep 2026 23:45:52 +0200 Subject: [PATCH 3/5] Review r1: an undated machine stamps 0, libc's stat is the header's, every stamp site observed - clock: `mtime_now` is `utc_nanos().unwrap_or(0)`. A machine whose RTC never answered has no date, so a file written there is undated (0), never 1970 plus uptime. `NANOS_PER_SEC` is private and used for every second in the module; FAT's two conversions take and give an `mtime`. - toyos-abi: `Stat::mtime` states each mount's resolution and that 0 is undated; the path citations and "never a time since boot" are gone. - std (fork ac63a08077f, `wt-toyos-mtime`): `Metadata::modified` answers `Unsupported` for 0, the kind `SYS_CLOCK_EPOCH` refuses with there. - libc: `st_nlink` is `u64`, as `nlink_t` is. The Rust `Stat` was 112 bytes against C's 120, so C read `st_blksize` as `st_size` and a stamp's nanoseconds as `st_mtime`. `202_stat_mtime` asserts the size, the length, the seconds and the nanoseconds through `fstat` and `stat`. - `file_mtime` judges a create with truncation, a create of a missing file and a truncation against the wall clock, and its `undated` mode, run by the new `file_mtime_undated` on the `rtc-dead` machine, finds a written file undated without ever asking the time. - Issues: the last-handle defect now reaches FAT; the std issue is renamed to what stays true; filed: an undated FAT file reads back as 1980, and the shared-object cache's identity is blind on an undated machine. The so-cache issue's `nanos_since_boot` claim is deleted. Co-Authored-By: Claude Opus 5.5 --- ...-undated-file-on-fat-reads-back-as-1980.md | 19 ++++ ...-mtime-of-1970-for-what-it-did-not-stat.md | 22 ----- .../std-calls-undated-what-it-did-not-stat.md | 21 +++++ ...lose-stamps-the-file-with-its-own-mtime.md | 4 + ...identity-is-blind-on-an-undated-machine.md | 21 +++++ ...es-refusals-are-narrower-than-its-reach.md | 4 +- kernel/src/clock.rs | 28 +++--- kernel/src/fat32_adapter.rs | 4 +- rust | 2 +- tests/common/wallclock.rs | 41 +++++++++ tests/testcases/LICENSE | 4 +- tests/testcases/tinycc/202_stat_mtime.c | 52 +++++++++++ tests/testcases/tinycc/202_stat_mtime.expect | 7 ++ tests/toyos-rust-tests/src/bin/file_mtime.rs | 90 ++++++++++++++----- tests/toyos.rs | 7 +- toyos-abi/src/syscall.rs | 9 +- userland/libc/src/posix_io.rs | 2 +- 17 files changed, 263 insertions(+), 74 deletions(-) create mode 100644 issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md delete mode 100644 issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md create mode 100644 issues/filesystem/std-calls-undated-what-it-did-not-stat.md create mode 100644 issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md create mode 100644 tests/testcases/tinycc/202_stat_mtime.c create mode 100644 tests/testcases/tinycc/202_stat_mtime.expect diff --git a/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md b/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md new file mode 100644 index 0000000000..ac8829d2d2 --- /dev/null +++ b/issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md @@ -0,0 +1,19 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# An undated file on FAT reads back as 1980 + +A file written on a machine whose RTC never answered is undated, an mtime of 0 +(`toyos_abi::syscall::Stat::mtime`). FAT has no undated stamp: +`toyos-fat32`'s `FatTime::from_unix_secs` clamps 0 up to `FatTime::EPOCH`, +1980-01-01, and the mount answers that back as a date. So the file reads as +written in 1980, which std reports as an instant and not as undated. + +**Mechanism read off the code; not reproduced.** + +**Exit condition.** A FAT mount answers 0 for an entry stamped +`FatTime::EPOCH`, with a guest test on the `rtc-dead` machine that writes a +FAT file and finds it undated. diff --git a/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md b/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md deleted file mode 100644 index fd2d6a952c..0000000000 --- a/issues/filesystem/std-answers-an-mtime-of-1970-for-what-it-did-not-stat.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# std answers an mtime of 1970 for what it did not stat, and sets none it is asked to - -The fork's `library/std/src/sys/fs/toyos.rs` reads a file's mtime only off -`SYS_FSTAT`. Everywhere else it invents one: `DirEntry::metadata`, `stat` of a -directory and `lstat` of a symlink all answer `mtime: 0`, which -`Metadata::modified` reports as `UNIX_EPOCH` — a real-looking instant, not an -error. A program walking a tree through `read_dir` and comparing -`entry.metadata()?.modified()?` sees every file as written in 1970. - -And `File::set_times`, `fs::set_times` and `set_times_nofollow` return `Ok(())` -having set nothing, so a tool that stamps an output (`touch`, a build system's -restat) is told it did. - -**Exit condition.** Each answers the mtime the kernel keeps for that name or -an error saying it has none, and a time-setting call sets the stamp through the -kernel or is refused as `Unsupported`. diff --git a/issues/filesystem/std-calls-undated-what-it-did-not-stat.md b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md new file mode 100644 index 0000000000..db0497095e --- /dev/null +++ b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md @@ -0,0 +1,21 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# std calls undated what it did not stat, and sets no mtime it is asked to + +The fork's `library/std/src/sys/fs/toyos.rs` reads a file's mtime only off +`SYS_FSTAT`. `DirEntry::metadata` answers `mtime: 0` without asking, so +`Metadata::modified` reports a file the kernel has a stamp for as undated: a +program walking a tree through `read_dir` and comparing +`entry.metadata()?.modified()?` fails on every entry. + +And `File::set_times`, `fs::set_times` and `set_times_nofollow` return `Ok(())` +having set nothing, so a tool that stamps an output (`touch`, a build system's +restat) is told it did. + +**Exit condition.** `DirEntry::metadata` answers the mtime the kernel keeps +for that name, and a time-setting call sets the stamp through the kernel or is +refused as `Unsupported`. diff --git a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md index ac699d7d08..0d3877e894 100644 --- a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md +++ b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md @@ -18,6 +18,10 @@ closed by the writer and then by the reader, is flushed with `t0`: the stored mtime says the file has not changed since before the write, and a build tool that compares mtimes does not rebuild from it. +It reaches FAT as well as DATA and `/tmp`: `kernel/src/fat32_adapter.rs`'s +`update_metadata` stores the flushing handle's mtime, where before it stored +the flush's own instant. + **Mechanism read off the code; not reproduced.** **Exit condition.** The mtime is the file's and not a handle's — each write diff --git a/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md b/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md new file mode 100644 index 0000000000..6006ef8186 --- /dev/null +++ b/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md @@ -0,0 +1,21 @@ +--- +status: open +kind: defect +opened: 2026-09-28 +--- + +# The shared-object cache's identity is blind on an undated machine + +`vfs::BackingId` is a file's size plus its mount's mtime, and on a machine whose +RTC never answered every write stamps 0 (`kernel/src/clock.rs`'s `mtime_now`). +So a same-size rewrite of a library on any writable mount carries the identity +it had, and `kernel/src/elf/cache.rs` serves the next load the first image — +the staleness its refusal exists to prevent, on every mount rather than only +FAT's two-second one. + +**Mechanism read off the code; not reproduced.** + +**Exit condition.** An identity that does not rest on a clock — a content hash, +a per-file generation the mount bumps on every write, or a `FileId` plus a +write counter — with a test that rewrites a library at the same size on the +`rtc-dead` machine and loads the second image. diff --git a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md index b19b428e9e..8c32a81501 100644 --- a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md +++ b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md @@ -14,9 +14,7 @@ residual: what the refusals do **not** reach. ## The identity cannot see a same-size rewrite on a FAT32 mount -`vfs::BackingId` is size plus the mount's mtime. On `/home` (bcachefs) the mtime -is `nanos_since_boot` at the flush, so two writes are always apart — -`so_cache_policy`'s `stale-mtime` arm asserts exactly that. +`vfs::BackingId` is size plus the mount's mtime. `/log` is FAT32, mounted `UserAccess::ReadWrite` (`kernel/src/main.rs:461`), and **FAT stores seconds in units of two**: `toyos-fat32/src/time.rs:5-15` states diff --git a/kernel/src/clock.rs b/kernel/src/clock.rs index 44309e2967..3086b2fa2b 100644 --- a/kernel/src/clock.rs +++ b/kernel/src/clock.rs @@ -157,7 +157,7 @@ pub fn init_wall(century_reg: Option, utc_offset_minutes: Option) { let local = civil.to_unix_secs(); let offset_secs = utc_offset_minutes.unwrap_or(0) as i64 * 60; - BOOT_LOCAL_SECS.store(local.saturating_sub(nanos_since_boot() / 1_000_000_000), Relaxed); + BOOT_LOCAL_SECS.store(local.saturating_sub(nanos_since_boot() / NANOS_PER_SEC), Relaxed); UTC_OFFSET_SECS.store(offset_secs, Relaxed); WALL_KNOWN.store(true, Release); @@ -172,17 +172,16 @@ pub fn init_wall(century_reg: Option, utc_offset_minutes: Option) { pub fn local_secs() -> Option { WALL_KNOWN .load(Acquire) - .then(|| BOOT_LOCAL_SECS.load(Relaxed) + nanos_since_boot() / 1_000_000_000) + .then(|| BOOT_LOCAL_SECS.load(Relaxed) + nanos_since_boot() / NANOS_PER_SEC) } /// The same instant in Unix seconds (UTC) — what `SYS_CLOCK_EPOCH` serves: the -/// whole seconds of [`utc_nanos`], so a file written after this answered `s` -/// carries a stamp of at least `s` seconds. +/// whole seconds of [`utc_nanos`]. pub fn utc_secs() -> Option { utc_nanos().map(|nanos| nanos / NANOS_PER_SEC) } -pub const NANOS_PER_SEC: u64 = 1_000_000_000; +const NANOS_PER_SEC: u64 = 1_000_000_000; /// Nanoseconds since the Unix epoch, UTC: the RTC's whole-second reading carried /// on by the counter, so its resolution is the counter's and its accuracy the @@ -195,20 +194,19 @@ pub fn utc_nanos() -> Option { } /// What a file written now is stamped with (`toyos_abi::syscall::Stat::mtime`): -/// [`utc_nanos`], and on a machine whose RTC never answered — which -/// [`init_wall`] said by name — a wall clock that starts at the epoch at boot, -/// so one boot's stamps still order. +/// [`utc_nanos`], and 0 — undated — on a machine whose RTC never answered. pub fn mtime_now() -> u64 { - utc_nanos().unwrap_or_else(nanos_since_boot) + utc_nanos().unwrap_or(0) } -/// `utc` Unix seconds in the machine's zone, for a format that stores local time. -pub fn local_of_utc(utc: u64) -> u64 { - utc.saturating_add_signed(-UTC_OFFSET_SECS.load(Relaxed)) +/// A file's `mtime` as whole seconds in the machine's zone, for a format that +/// stores local time. +pub fn local_secs_of(mtime: u64) -> u64 { + (mtime / NANOS_PER_SEC).saturating_add_signed(-UTC_OFFSET_SECS.load(Relaxed)) } -/// The inverse of [`local_of_utc`]. -pub fn utc_of_local(local: u64) -> u64 { - local.saturating_add_signed(UTC_OFFSET_SECS.load(Relaxed)) +/// The inverse of [`local_secs_of`], to the second. +pub fn mtime_of_local(local: u64) -> u64 { + local.saturating_add_signed(UTC_OFFSET_SECS.load(Relaxed)).saturating_mul(NANOS_PER_SEC) } diff --git a/kernel/src/fat32_adapter.rs b/kernel/src/fat32_adapter.rs index 153239685c..2417e11df1 100644 --- a/kernel/src/fat32_adapter.rs +++ b/kernel/src/fat32_adapter.rs @@ -656,7 +656,7 @@ pub struct FatFs { /// A VFS `mtime` as FAT stores it: local time, whole seconds, and the two-second /// field of a write time drops the odd one. fn stamp(mtime: u64) -> FatTime { - FatTime::from_unix_secs(crate::clock::local_of_utc(mtime / crate::clock::NANOS_PER_SEC)) + FatTime::from_unix_secs(crate::clock::local_secs_of(mtime)) } /// What to stamp on an entry the VFS gave no `mtime` for. @@ -922,7 +922,7 @@ impl FileSystem for FatFs { let role = self.role; self.fs .metadata(name) - .map(|m| crate::clock::utc_of_local(m.modified_unix) * crate::clock::NANOS_PER_SEC) + .map(|m| crate::clock::mtime_of_local(m.modified_unix)) .map_err(|e| refused(role, &self.fs, &mut self.repair_named, "metadata", name, e)) } diff --git a/rust b/rust index 1b236638a9..ac63a08077 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 1b236638a905cf078618b5739e9d872b69ecc6e8 +Subproject commit ac63a08077f1c2229cdc5bbae1bb73acff0eaed2 diff --git a/tests/common/wallclock.rs b/tests/common/wallclock.rs index 029126ce19..f0d86e7568 100644 --- a/tests/common/wallclock.rs +++ b/tests/common/wallclock.rs @@ -513,3 +513,44 @@ pub fn file_mtime_survives_a_reboot( ); Ok(()) } + +/// On a machine whose RTC never answered, a file's mtime is undated — 0, which +/// std reports as an error — and never 1970 plus the boot's uptime. +pub fn file_mtime_undated( + test_config: &Path, + c_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], +) -> Result<(), String> { + const SAID: &str = "file-mtime: /tmp/file-mtime-undated is undated"; + let mut qemu = QemuInstance::boot_with_options( + test_config, + c_bins, + rust_bins, + BootOptions { kernel_params: &["rtc-dead"], ..Default::default() }, + ); + let boot = qemu.boot_log().to_string(); + if !boot.contains("clock: this machine will not say what time it is") { + return Err(format!( + "with rtc-dead armed the kernel never refused the clock\n{}", + clock_lines(&boot) + )); + } + let result = qemu.run_test("test_rs_file_mtime undated", Duration::from_secs(60)); + writeln!(qemu.stdin_mut(), "run shutdown").expect("write to QEMU stdin"); + qemu.flush_stdin(); + let tail = qemu.drain_serial(Duration::from_secs(20)); + drop(qemu); + for bad in ["PANIC:", "panicked at"] { + if tail.contains(bad) { + return Err(format!("{bad:?} on the way down\n{tail}")); + } + } + if result.exit_code != Some(0) || !result.stdout.contains(SAID) { + return Err(format!( + "`file_mtime undated` exited {:?}:\n{}\nkernel log while it ran:\n{}{}", + result.exit_code, result.stdout, result.before, result.serial + )); + } + eprintln!(" [clock] rtc-dead: a file written in /tmp is undated"); + Ok(()) +} diff --git a/tests/testcases/LICENSE b/tests/testcases/LICENSE index c4f7131ccb..0a44120eee 100644 --- a/tests/testcases/LICENSE +++ b/tests/testcases/LICENSE @@ -22,9 +22,9 @@ against tinycc upstream at 64552b3faa39ee7948a9ea21bfcc11045b90c70d (repo.or.cz/tinycc.git, 2026-08-05), allowing for the `-` → `_` renames this project made to some filenames. - tinycc/ 312 files: 239 byte-identical to tinycc's `tests/tests2`, + tinycc/ 314 files: 239 byte-identical to tinycc's `tests/tests2`, 17 tinycc files this project modified, - 56 not upstream at all — 55 cases written here, plus + 58 not upstream at all — 57 cases written here, plus `fred.txt`, which is output `40_stdio.c` writes when it runs and which was committed by accident. diff --git a/tests/testcases/tinycc/202_stat_mtime.c b/tests/testcases/tinycc/202_stat_mtime.c new file mode 100644 index 0000000000..dc5647ac7b --- /dev/null +++ b/tests/testcases/tinycc/202_stat_mtime.c @@ -0,0 +1,52 @@ +/* `struct stat` as the C library fills it: the size and the mtime a C caller + reads are the file's, which they are only if libc's layout is the header's. */ +#include +#include +#include +#include +#include + +#define PATH "/tmp/202_stat_mtime" +#define BYTES 17 + +static void judge(const char *how, const struct stat *st, time_t before, time_t after) { + printf("%s st_size %ld\n", how, (long)st->st_size); + if (before <= st->st_mtime && st->st_mtime <= after) + printf("%s st_mtime within the write\n", how); + else + printf("%s st_mtime %ld outside [%ld, %ld]\n", how, (long)st->st_mtime, (long)before, + (long)after); + if (0 <= st->st_mtim.tv_nsec && st->st_mtim.tv_nsec < 1000000000L) + printf("%s tv_nsec below a second\n", how); + else + printf("%s tv_nsec %ld\n", how, (long)st->st_mtim.tv_nsec); +} + +int main(void) { + struct stat st; + time_t before, after; + int fd; + + printf("sizeof(struct stat) %zu\n", sizeof(struct stat)); + + before = time(NULL); + fd = open(PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0 || write(fd, "seventeen bytes!\n", BYTES) != BYTES) { + printf("could not write " PATH "\n"); + return 1; + } + if (fstat(fd, &st) != 0) { + printf("fstat failed\n"); + return 1; + } + close(fd); + after = time(NULL); + judge("fstat", &st, before, after); + + if (stat(PATH, &st) != 0) { + printf("stat failed\n"); + return 1; + } + judge("stat", &st, before, after); + return 0; +} diff --git a/tests/testcases/tinycc/202_stat_mtime.expect b/tests/testcases/tinycc/202_stat_mtime.expect new file mode 100644 index 0000000000..26e6479635 --- /dev/null +++ b/tests/testcases/tinycc/202_stat_mtime.expect @@ -0,0 +1,7 @@ +sizeof(struct stat) 120 +fstat st_size 17 +fstat st_mtime within the write +fstat tv_nsec below a second +stat st_size 17 +stat st_mtime within the write +stat tv_nsec below a second diff --git a/tests/toyos-rust-tests/src/bin/file_mtime.rs b/tests/toyos-rust-tests/src/bin/file_mtime.rs index 41a307e843..db2b48b605 100644 --- a/tests/toyos-rust-tests/src/bin/file_mtime.rs +++ b/tests/toyos-rust-tests/src/bin/file_mtime.rs @@ -1,16 +1,17 @@ //! A file's mtime is the wall clock at its write, in nanoseconds since the Unix //! epoch (`toyos_abi::syscall::Stat::mtime`). //! -//! With no arguments, on the shared boot, it judges `/tmp`: the stamp lies -//! between two `SYS_CLOCK_EPOCH` readings taken around the write, and a second -//! write's stamp is later than the first's, which a clock of whole seconds -//! cannot say. `write ` makes the first judgement on `path` and `read -//! ` only prints what it finds there; `file_mtime_survives_a_reboot` -//! (`tests/common/wallclock.rs`) drives the two across a reboot and holds the -//! printed stamp against the instant the host staged in the RTC. +//! With no arguments, on the shared boot, it judges `/tmp`: each stamp lies +//! between two `SYS_CLOCK_EPOCH` readings taken around what made it — a write, +//! a create with truncation, a create of a missing file, a truncation — and a +//! later write's stamp is later, which a clock of whole seconds cannot say. +//! `write ` makes the first judgement on `path` and `read ` only +//! prints what it finds there. `undated` runs on a machine whose RTC never +//! answered and never asks the time: a file written there is undated, which +//! std reports as an error and not as 1970. -use std::fs; -use std::io::Write; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Write}; use std::time::UNIX_EPOCH; const NANOS_PER_SEC: u64 = 1_000_000_000; @@ -30,26 +31,33 @@ fn epoch() -> u64 { toyos::system::clock_epoch().expect("SYS_CLOCK_EPOCH: this machine will not say what time it is") } -/// Writes `path` and returns its stamp, judged against the wall clock around the write. -fn write_judged(path: &str, bytes: &[u8]) -> u64 { +fn write(path: &str, bytes: &[u8]) { + let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); + f.write_all(bytes).unwrap_or_else(|e| panic!("write {path}: {e}")); + f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); +} + +/// Runs `act` and returns `path`'s stamp after it, judged against the wall +/// clock read around it. +fn judged(path: &str, what: &str, act: impl FnOnce()) -> u64 { let before = epoch(); - { - let mut f = fs::File::create(path).unwrap_or_else(|e| panic!("create {path}: {e}")); - f.write_all(bytes).unwrap_or_else(|e| panic!("write {path}: {e}")); - f.sync_all().unwrap_or_else(|e| panic!("fsync {path}: {e}")); - } + act(); let after = epoch(); let stamp = mtime(path); // `SYS_CLOCK_EPOCH` is the whole seconds of the clock that stamps, so the - // write's instant is at or past `before` and short of the second after `after`. + // stamp is at or past `before` and short of the second after `after`. assert!( before * NANOS_PER_SEC <= stamp && stamp < (after + 1) * NANOS_PER_SEC, - "{path} is stamped {stamp} ns, and the wall clock read {before} s before the write and \ - {after} s after it", + "{path} is stamped {stamp} ns by {what}, and the wall clock read {before} s before it \ + and {after} s after it", ); stamp } +fn write_judged(path: &str, bytes: &[u8]) -> u64 { + judged(path, "a write", || write(path, bytes)) +} + fn main() { let args: Vec = std::env::args().collect(); match args.as_slice() { @@ -60,8 +68,50 @@ fn main() { second > first, "a write after another is stamped {second} ns and the one before it {first} ns" ); + + const TRUNCATED: &str = "/tmp/file-mtime-truncated"; + let created = judged(TRUNCATED, "a create with truncation", || { + fs::File::create(TRUNCATED).unwrap_or_else(|e| panic!("create {TRUNCATED}: {e}")); + }); + + const MISSING: &str = "/tmp/file-mtime-missing"; + assert!(fs::metadata(MISSING).is_err(), "{MISSING} exists before this creates it"); + judged(MISSING, "a create of a missing file", || { + OpenOptions::new() + .write(true) + .create(true) + .open(MISSING) + .unwrap_or_else(|e| panic!("create {MISSING}: {e}")); + }); + + let resized = judged(TRUNCATED, "a truncation", || { + let f = OpenOptions::new() + .write(true) + .open(TRUNCATED) + .unwrap_or_else(|e| panic!("reopen {TRUNCATED}: {e}")); + f.set_len(1).unwrap_or_else(|e| panic!("ftruncate {TRUNCATED}: {e}")); + f.sync_all().unwrap_or_else(|e| panic!("fsync {TRUNCATED}: {e}")); + }); + assert!( + resized > created, + "{TRUNCATED} was created at {created} ns and a later truncation stamped it \ + {resized} ns" + ); println!("file-mtime: /tmp stamps {first} then {second}"); } + [_, mode] if mode == "undated" => { + const UNDATED: &str = "/tmp/file-mtime-undated"; + write(UNDATED, b"no clock answered"); + let meta = fs::metadata(UNDATED).unwrap_or_else(|e| panic!("stat {UNDATED}: {e}")); + match meta.modified() { + Err(e) if e.kind() == ErrorKind::Unsupported => {} + other => panic!( + "{UNDATED} was written on a machine whose RTC never answered, and its mtime \ + reads {other:?}" + ), + } + println!("file-mtime: {UNDATED} is undated"); + } [_, mode, path] if mode == "write" => { let stamp = write_judged(path, b"stamped at its write"); println!("file-mtime: {path} mtime={stamp}"); @@ -69,6 +119,6 @@ fn main() { [_, mode, path] if mode == "read" => { println!("file-mtime: {path} mtime={}", mtime(path)); } - _ => panic!("usage: file_mtime [write | read ], got {args:?}"), + _ => panic!("usage: file_mtime [undated | write | read ], got {args:?}"), } } diff --git a/tests/toyos.rs b/tests/toyos.rs index 4a1ce0b070..0c4a34da08 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -474,8 +474,7 @@ const DRIVEN_AND_SHARED: &[&str] = &[ // The log-stream arms drive it for the kernel's `exit:` record about it, // not for anything it does: it is the cheapest process this tree starts. "empty_dir_stat", - // Its shared run judges `/tmp`'s stamps; `file_mtime_survives_a_reboot` - // drives it on DATA across a reboot. + // Its shared run judges `/tmp`'s stamps; its other modes are machine tests'. "file_mtime", "hierarchy_paths", "null_sink_client_exits", @@ -1349,8 +1348,8 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("wall_clock_no_century", Sched::Parallel, Tier::Weekly), ("wall_clock_century_register", Sched::Parallel, Tier::Weekly), ("wall_clock_zone", Sched::Parallel, Tier::Weekly), - // Two boots over one DATA image, the stamp judged against the staged RTC. ("file_mtime_survives_a_reboot", Sched::Parallel, Tier::Nightly), + ("file_mtime_undated", Sched::Parallel, Tier::Nightly), // `xhci_slow_connect`'s shape against the disk's port, but its actuator // masks the port until `BOOT_SCAN_DONE` — a kernel event, not a duration — // so what it stages is an ordering with no wall-clock margin on either @@ -1622,6 +1621,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("wall_clock_century_register", &["test_rs_wall_clock_now"]), ("wall_clock_zone", &["test_rs_wall_clock_now"]), ("file_mtime_survives_a_reboot", &["test_rs_file_mtime"]), + ("file_mtime_undated", &["test_rs_file_mtime"]), ("screen_console_clear", &["test_rs_test_screen_graffiti"]), ("screen_console_scroll", &["test_rs_test_screen_churn"]), ("screen_console_panic", &["test_rs_test_panic_child"]), @@ -11629,6 +11629,7 @@ fn run_machine_test( "file_mtime_survives_a_reboot" => { common::wallclock::file_mtime_survives_a_reboot(test_config, c_bins, rust_bins) } + "file_mtime_undated" => common::wallclock::file_mtime_undated(test_config, c_bins, rust_bins), "late_storage_connect" => common::volumes::late_storage_connect(test_config, c_bins, rust_bins), "root_candidate_malformed" => { common::volumes::root_candidate_malformed(test_config, c_bins, rust_bins) diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index ad62978fe0..025bf03b80 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -687,11 +687,10 @@ pub struct Stat { pub file_type: FileType, pub size: u64, /// When the file was last written: nanoseconds since the Unix epoch, UTC, - /// off the wall clock at the write — never a time since boot. Kept to the - /// precision its filesystem stores (`kernel/src/fat32_adapter.rs`: two - /// seconds; `src/image.rs` stamps ROOT's files 0), and on a machine whose - /// RTC never answered, off a clock that starts at the epoch at boot - /// (`kernel/src/clock.rs`'s `mtime_now`). + /// off the wall clock at the write, to the resolution its mount keeps — + /// the nanosecond on `/tmp` and DATA, two seconds on FAT. 0 is undated: + /// written on a machine whose RTC never answered, or shipped in ROOT's + /// reproducible image. pub mtime: u64, } diff --git a/userland/libc/src/posix_io.rs b/userland/libc/src/posix_io.rs index 2b9a6e0821..1731f3dba5 100644 --- a/userland/libc/src/posix_io.rs +++ b/userland/libc/src/posix_io.rs @@ -485,7 +485,7 @@ pub struct Stat { pub st_dev: u64, pub st_ino: u64, pub st_mode: u32, - pub st_nlink: u32, + pub st_nlink: u64, pub st_uid: u32, pub st_gid: u32, pub st_rdev: u64, From 28f7ae94025d73eeb69809ee97d795cb620ebc1e Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 29 Sep 2026 13:00:01 +0200 Subject: [PATCH 4/5] FAT stamps the flush's instant again, and file_mtime judges /log Review round 3 on #587: - fat32_adapter.rs: update_metadata stamps now(), the flush's own instant, as main did, not the flushing handle's mtime. The last handle to close can be a reader opened before the last write, so its mtime would store the file as older than its contents. `create` still stamps the mtime the VFS hands it. Stat::mtime's doc says FAT's two seconds are the flush's, and the last-handle issue's paragraph saying the defect reached FAT goes. - file_mtime's shared run writes /log/file-mtime, reopens it and requires its stamp within (before-1)*1e9 ..= after*1e9 and on a whole second, so dropping the adapter's seconds-to-nanoseconds conversion reds it. - the-so-caches-identity-is-blind-on-an-undated-machine.md merges into the same-size-rewrite section of the-so-caches-refusals-are-narrower-than-its- reach.md, whose false fat32_adapter.rs:849 clause goes. - std-calls-undated-what-it-did-not-stat.md also records that a directory's stat and a symlink's lstat answer mtime 0, so modified() on them is Err(Unsupported). - a-fat-files-mtime-reads-finer...md loses "at or below it" and "the one a later reader sees is the older", false now FAT stamps at the flush. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs --- ...-through-its-writer-than-after-a-reopen.md | 4 ++-- .../std-calls-undated-what-it-did-not-stat.md | 11 ++++++---- ...lose-stamps-the-file-with-its-own-mtime.md | 4 ---- ...identity-is-blind-on-an-undated-machine.md | 21 ------------------- ...es-refusals-are-narrower-than-its-reach.md | 14 ++++++++----- kernel/src/fat32_adapter.rs | 8 ++++--- tests/toyos-rust-tests/src/bin/file_mtime.rs | 19 ++++++++++++++++- tests/toyos.rs | 2 +- toyos-abi/src/syscall.rs | 6 +++--- 9 files changed, 45 insertions(+), 44 deletions(-) delete mode 100644 issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md diff --git a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md index b24823433c..44758111fb 100644 --- a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md +++ b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md @@ -9,8 +9,8 @@ opened: 2026-09-28 A handle's `fstat` answers the mtime the handle holds (`kernel/src/object/ops.rs`), which a write sets to `clock::mtime_now` in nanoseconds. A FAT volume stores a write time in two-second units (`kernel/src/fat32_adapter.rs`'s -`stamp`), so the same file opened again answers the even second at or below -it: one file, two mtimes, and the one a later reader sees is the older. +`stamp`), so the same file opened again answers the even second: one file, +two mtimes. **Exit condition.** A handle holds the mtime its mount stores — rounded to the mount's precision at the write — or the kernel mounts no FAT volume a process diff --git a/issues/filesystem/std-calls-undated-what-it-did-not-stat.md b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md index db0497095e..db0efe3b95 100644 --- a/issues/filesystem/std-calls-undated-what-it-did-not-stat.md +++ b/issues/filesystem/std-calls-undated-what-it-did-not-stat.md @@ -10,12 +10,15 @@ The fork's `library/std/src/sys/fs/toyos.rs` reads a file's mtime only off `SYS_FSTAT`. `DirEntry::metadata` answers `mtime: 0` without asking, so `Metadata::modified` reports a file the kernel has a stamp for as undated: a program walking a tree through `read_dir` and comparing -`entry.metadata()?.modified()?` fails on every entry. +`entry.metadata()?.modified()?` fails on every entry. `fs::metadata` of a +directory and `fs::symlink_metadata` of a symlink answer `mtime: 0` too +(`stat`'s `is_dir` arm and `lstat`'s `readlink` arm), so `modified()` on either +is `Err(Unsupported)`. And `File::set_times`, `fs::set_times` and `set_times_nofollow` return `Ok(())` having set nothing, so a tool that stamps an output (`touch`, a build system's restat) is told it did. -**Exit condition.** `DirEntry::metadata` answers the mtime the kernel keeps -for that name, and a time-setting call sets the stamp through the kernel or is -refused as `Unsupported`. +**Exit condition.** `DirEntry::metadata`, a directory's `stat` and a link's +`lstat` answer the mtime the kernel keeps for that name, and a time-setting +call sets the stamp through the kernel or is refused as `Unsupported`. diff --git a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md index 0d3877e894..ac699d7d08 100644 --- a/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md +++ b/issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md @@ -18,10 +18,6 @@ closed by the writer and then by the reader, is flushed with `t0`: the stored mtime says the file has not changed since before the write, and a build tool that compares mtimes does not rebuild from it. -It reaches FAT as well as DATA and `/tmp`: `kernel/src/fat32_adapter.rs`'s -`update_metadata` stores the flushing handle's mtime, where before it stored -the flush's own instant. - **Mechanism read off the code; not reproduced.** **Exit condition.** The mtime is the file's and not a handle's — each write diff --git a/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md b/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md deleted file mode 100644 index 6006ef8186..0000000000 --- a/issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# The shared-object cache's identity is blind on an undated machine - -`vfs::BackingId` is a file's size plus its mount's mtime, and on a machine whose -RTC never answered every write stamps 0 (`kernel/src/clock.rs`'s `mtime_now`). -So a same-size rewrite of a library on any writable mount carries the identity -it had, and `kernel/src/elf/cache.rs` serves the next load the first image — -the staleness its refusal exists to prevent, on every mount rather than only -FAT's two-second one. - -**Mechanism read off the code; not reproduced.** - -**Exit condition.** An identity that does not rest on a clock — a content hash, -a per-file generation the mount bumps on every write, or a `FileId` plus a -write counter — with a test that rewrites a library at the same size on the -`rtc-dead` machine and loads the second image. diff --git a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md index 8c32a81501..782580e145 100644 --- a/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md +++ b/issues/isolation/the-so-caches-refusals-are-narrower-than-its-reach.md @@ -12,16 +12,15 @@ Three things that record carried are not covered by either refusal, and they went out of the tracker with it. They are one file because they are one residual: what the refusals do **not** reach. -## The identity cannot see a same-size rewrite on a FAT32 mount +## The identity cannot see a same-size rewrite `vfs::BackingId` is size plus the mount's mtime. `/log` is FAT32, mounted `UserAccess::ReadWrite` (`kernel/src/main.rs:461`), and **FAT stores seconds in units of two**: `toyos-fat32/src/time.rs:5-15` states the encoding's three lossy properties, `dir.rs:92` passes 0 for the tenths -field, and `kernel/src/fat32_adapter.rs:849` stamps whatever `now()` gives. So -two writes of the same length inside one 2-second bucket carry one mtime, and -the second load is served the first image — the staleness the refusal exists to +field. So two writes of the same length inside one 2-second bucket carry one +mtime, and the second load is served the first image — the staleness the refusal exists to prevent, on the one writable FAT mount a process can reach. **Mechanism read off the code; not reproduced.** Planting it needs a same-size @@ -29,9 +28,14 @@ rewrite of a library inside 2 s on `/log`, and a 1.9 MB write to the USB-backed log volume takes about 5.8 s, so the window closes before the second write lands. +On a machine whose RTC never answered every write stamps 0 +(`kernel/src/clock.rs`'s `mtime_now`), so there a same-size rewrite of a +library on any writable mount carries the identity it had. + *Exit condition:* an identity that does not rest on a clock — a content hash, a per-file generation the mount bumps on every write, or a `FileId` plus a write -counter — or a demonstration that no library can be reached on a FAT mount. +counter — with a test that rewrites a library at the same size on the +`rtc-dead` machine and loads the second image. ## The budget is a machine-wide, boot-permanent denial diff --git a/kernel/src/fat32_adapter.rs b/kernel/src/fat32_adapter.rs index 7685cd5fc5..57a4a1536d 100644 --- a/kernel/src/fat32_adapter.rs +++ b/kernel/src/fat32_adapter.rs @@ -660,7 +660,7 @@ fn stamp(mtime: u64) -> FatTime { FatTime::from_unix_secs(mtime / crate::clock::NANOS_PER_SEC) } -/// What to stamp on an entry the VFS gave no `mtime` for. +/// The wall clock now, as [`stamp`] stores it. fn now() -> FatTime { stamp(crate::clock::mtime_now()) } @@ -1049,10 +1049,12 @@ impl FileSystem for FatFs { &mut self, file_id: FileId, size: u64, - mtime: u64, + _mtime: u64, ) -> Result<(), SyscallError> { let role = self.role; - let time = stamp(mtime); + // The flush's own instant, not the flushing handle's `mtime`: the last + // handle to close may be a reader that opened before the last write. + let time = now(); let name = { let known = self.open.get(&file_id).ok_or(SyscallError::NotFound)?; let (name, was) = (known.name.clone(), known.file.len()); diff --git a/tests/toyos-rust-tests/src/bin/file_mtime.rs b/tests/toyos-rust-tests/src/bin/file_mtime.rs index db2b48b605..d371d81801 100644 --- a/tests/toyos-rust-tests/src/bin/file_mtime.rs +++ b/tests/toyos-rust-tests/src/bin/file_mtime.rs @@ -5,6 +5,8 @@ //! between two `SYS_CLOCK_EPOCH` readings taken around what made it — a write, //! a create with truncation, a create of a missing file, a truncation — and a //! later write's stamp is later, which a clock of whole seconds cannot say. +//! Then `/log`: FAT keeps the whole seconds of its flush, read back after a +//! reopen. //! `write ` makes the first judgement on `path` and `read ` only //! prints what it finds there. `undated` runs on a machine whose RTC never //! answered and never asks the time: a file written there is undated, which @@ -97,7 +99,22 @@ fn main() { "{TRUNCATED} was created at {created} ns and a later truncation stamped it \ {resized} ns" ); - println!("file-mtime: /tmp stamps {first} then {second}"); + + // The flush stamps FAT, which keeps whole seconds and drops an odd one. + const FAT: &str = "/log/file-mtime"; + let before = epoch(); + write(FAT, b"on FAT"); + let fat = mtime(FAT); + let after = epoch(); + assert!( + (before - 1) * NANOS_PER_SEC <= fat + && fat <= after * NANOS_PER_SEC + && fat % NANOS_PER_SEC == 0, + "{FAT} reads back {fat} ns after a reopen, and the wall clock read {before} s \ + before its write and {after} s after", + ); + fs::remove_file(FAT).unwrap_or_else(|e| panic!("remove {FAT}: {e}")); + println!("file-mtime: /tmp stamps {first} then {second}, /log {fat}"); } [_, mode] if mode == "undated" => { const UNDATED: &str = "/tmp/file-mtime-undated"; diff --git a/tests/toyos.rs b/tests/toyos.rs index d8b6898ce9..1c8e65a00a 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -461,7 +461,7 @@ const DRIVEN_AND_SHARED: &[&str] = &[ // The log-stream arms drive it for the kernel's `exit:` record about it, // not for anything it does: it is the cheapest process this tree starts. "empty_dir_stat", - // Its shared run judges `/tmp`'s stamps; its other modes are machine tests'. + // Its shared run judges `/tmp`'s and `/log`'s stamps; its other modes are machine tests'. "file_mtime", "hierarchy_paths", "nvme_home_roundtrip", diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 4fee37e5ab..180d50fe03 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -688,9 +688,9 @@ pub struct Stat { pub size: u64, /// When the file was last written: nanoseconds since the Unix epoch, UTC, /// off the wall clock at the write, to the resolution its mount keeps — - /// the nanosecond on `/tmp` and DATA, two seconds on FAT. 0 is undated: - /// written on a machine whose RTC never answered, or shipped in ROOT's - /// reproducible image. + /// the nanosecond on `/tmp` and DATA, two seconds at the flush on FAT. 0 is + /// undated: written on a machine whose RTC never answered, or shipped in + /// ROOT's reproducible image. pub mtime: u64, } From 2b6397b7af8a85b5a547b6add810631788d24808 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 29 Sep 2026 13:56:39 +0200 Subject: [PATCH 5/5] Apply the final review: drop a doc line, state the FAT mtime exit condition checkably Delete the doc line on `now()`. Rewrite the exit condition of the FAT mtime issue so it names the check, and record there that the flush's own instant (`now()` vs the handle's `_mtime`) is untested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs --- ...-finer-through-its-writer-than-after-a-reopen.md | 13 ++++++++++--- kernel/src/fat32_adapter.rs | 1 - 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md index 44758111fb..ce874858eb 100644 --- a/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md +++ b/issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md @@ -12,6 +12,13 @@ write time in two-second units (`kernel/src/fat32_adapter.rs`'s `stamp`), so the same file opened again answers the even second: one file, two mtimes. -**Exit condition.** A handle holds the mtime its mount stores — rounded to the -mount's precision at the write — or the kernel mounts no FAT volume a process -writes. +**Exit condition.** For a FAT file, the mtime `fstat` answers through a handle +equals the one it answers after the file is closed and opened again, checked by +a test that writes, `fstat`s, reopens and `fstat`s again; or the kernel mounts +no FAT volume a process writes. + +**Untested.** `fat32_adapter.rs`'s flush stamps its own instant (`now()`), not +the flushing handle's `_mtime`, because the last handle to close may be a reader +that opened before the last write. No test closes a reader last and asserts the +stored time is the flush's: it waits on `epoch()` moving two seconds, never a +sleep. diff --git a/kernel/src/fat32_adapter.rs b/kernel/src/fat32_adapter.rs index 57a4a1536d..1753bc7061 100644 --- a/kernel/src/fat32_adapter.rs +++ b/kernel/src/fat32_adapter.rs @@ -660,7 +660,6 @@ fn stamp(mtime: u64) -> FatTime { FatTime::from_unix_secs(mtime / crate::clock::NANOS_PER_SEC) } -/// The wall clock now, as [`stamp`] stores it. fn now() -> FatTime { stamp(crate::clock::mtime_now()) }