Skip to content

A file's mtime is wall-clock time: nanoseconds since the Unix epoch, UTC - #587

Open
Japabu wants to merge 5 commits into
mainfrom
wt/toyos-mtime
Open

Japabu wants to merge 5 commits into
mainfrom
wt/toyos-mtime

Conversation

@Japabu

@Japabu Japabu commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

The owner approved this ABI change for the self-hosting track (ToyOS builds LLVM on itself, and ninja and make decide rebuilds by mtime): a file's modification time is wall-clock time, in nanoseconds, and a reboot does not change it.

The contract

toyos_abi::syscall::Stat::mtime is nanoseconds since the Unix epoch, UTC, taken off the wall clock at the write, to the resolution its mount keeps:

mount resolution
DATA (bcachefs) and /tmp (tmpfs) the nanosecond: the u64 is stored as given
FAT (/boot, /log) two seconds of local time, read back as UTC nanoseconds of the even second
ROOT undated: src/image.rs stamps 0, for a reproducible image

0 is undated. A machine whose RTC never answered has no date, so a file written there is stamped 0 and never 1970 plus uptime. std's Metadata::modified answers 0 with ErrorKind::Unsupported, the kind SYS_CLOCK_EPOCH's refusal maps to on that machine, where SystemTime::now panics. libc reports 0 as 0.

Accuracy is the RTC's second and resolution the counter's: the anchor truncates the RTC's second, as SYS_CLOCK_EPOCH already did.

The layout of Stat is unchanged (one u64), so no syscall changes. The sysroot key moves, because libc's struct stat and the std fork change.

What changed, per decision

  • kernel/src/clock.rs
    • utc_nanos is the RTC's whole-second anchor carried on by the counter.
    • utc_secs (SYS_CLOCK_EPOCH) is utc_nanos / 10⁹, so the epoch syscall and a stamp cannot disagree about a second. The arithmetic is unchanged: (boot + offset)·10⁹ + nsb over 10⁹ is boot + offset + ⌊nsb/10⁹⌋.
    • mtime_now is what a write stamps: utc_nanos, or 0 with no RTC.
    • local_secs_of and mtime_of_local convert an mtime for FAT, which stores local seconds. NANOS_PER_SEC is private to the module.
  • object/ops.rs, vfs.rs, revoke_selftest.rs, leak_selftest.rs stamp with mtime_now wherever they stamped nanos_since_boot: create with truncation, create of a missing file, write, ftruncate, and the flush open_backing_identified makes.
  • fat32_adapter.rs
    • create and update_metadata stamp the mtime the VFS hands them. Before, they stamped the flush's instant and ignored the argument.
    • file_mtime answers UTC nanoseconds. Before, it answered local seconds, a different unit from every other mount.
    • now() stays for the stamps the VFS gives no time for: reconcile and mkdir.
  • std (fork commit ac63a08077f on ToyOSOrg/rust branch wt-toyos-mtime, pinned here): Metadata::modified answers Unsupported for 0. Otherwise it already read the word as nanoseconds since UNIX_EPOCH, so it is correct now and was wrong before.
  • libc
    • struct stat gets POSIX's st_atim/st_mtim/st_ctim (struct timespec), and st_mtime becomes the macro POSIX defines. fstat used to put nanoseconds into st_mtime, which is seconds.
    • st_nlink is u64, as nlink_t (unsigned long) is. The Rust Stat was 112 bytes against C's 120, so a C caller read st_blksize (0) as st_size and the stamp's tv_nsec as st_mtime, and fstat left C's last 8 bytes unwritten. That mismatch predates this branch; nothing in the tree called stat from C.

Tests

  • 202_stat_mtime (C, Fast): writes 17 bytes to /tmp, then fstats and stats the file. It prints sizeof(struct stat) (120), st_size (17), whether st_mtime lies between two time(NULL) readings around the write, and whether st_mtim.tv_nsec is below a second.
  • file_mtime (Rust, Fast, shared boot) judges /tmp. Each stamp must lie between two SYS_CLOCK_EPOCH readings taken around what made it: before·10⁹ ≤ mtime < (after+1)·10⁹. It covers four stamp sites:
    • two writes, the second strictly later than the first, which a clock of whole seconds cannot say;
    • File::create with no write (create with truncation);
    • OpenOptions::new().write(true).create(true) on a path shown missing first (create of a missing file);
    • a reopen of the first without truncation, then set_len(1) and sync_all (ftruncate), strictly later than its create.
  • file_mtime_undated (Nightly): boots with rtc-dead and checks that the kernel refused the clock by name. It then runs file_mtime undated, which writes /tmp/file-mtime-undated without asking the time and requires modified() to fail with Unsupported.
  • file_mtime_survives_a_reboot (Nightly, two boots of one DATA image):
    1. Boot 1 has the RTC staged at 2033-03-07T09:14:25 with -rtc base= and writes /home/file-mtime.bin. The printed stamp must lie within 0..300 s of that instant.
    2. With the guest gone, the host reads the same stamp off the image.
    3. Boot 2 has the RTC a day on and must read the stamp back unchanged, so a re-stamp at mount or open would show.
  • toyos-fat32 a_write_time_keeps_the_even_second (host) is the pure conversion behind the FAT row: a write time drops the odd second that FatTime keeps only for creation times.

Gates

Run on the committed tree, host only; no QEMU was run.

command exit
cargo test -p toyos-build --lib 0 (412 passed, 3 ignored)
cargo test --workspace --exclude toyos-build 0
cargo test --test toyos-build -- --list 0 (lists Fast 202_stat_mtime, Fast file_mtime, Nightly file_mtime_survives_a_reboot, Nightly file_mtime_undated)
cargo run -- --clippy 0
cargo run -- --build-only 0
202_stat_mtime.c compiled and linked by the toolchain's clang against this key's C sysroot; _Static_asserts on sizeof(struct stat) == 120, offsetof(st_size) == 48, offsetof(st_mtim) == 88 0

High-risk: ABI and filesystems

  • Mutations. Each is a checked patch in the orchestrator's scratchpad (mtime-r2/). Each was applied, built with cargo run -- --build-only (EXIT=0 for all five), and reverted in the same script, leaving the tree clean. Each must turn its test red:
    • nlink-u32.patch (libc back to 32948da's layout) → 202_stat_mtime: st_size reads 0 and st_mtime reads a nanosecond count.
    • site-114.patch, site-132.patch, site-821.patch (each stamp site back to nanos_since_boot() alone) → file_mtime, on its create-with-truncation, create-of-a-missing-file and truncation cases respectively.
    • undated.patch (unwrap_or(0) → unwrap_or_else(nanos_since_boot)) → file_mtime_undated: modified() answers 1970 plus uptime.
  • Negative control. negative-control.patch is git diff HEAD origin/main -- kernel toyos-abi userland/libc: the whole implementation reverted onto e3a1cdc8, except the std pin, which only changes what 0 means. It was applied, built (EXIT=0) and reverted in the same script, leaving the tree clean.
    • file_mtime goes red on its first assertion, since a stamp since boot is about 10⁹·uptime, far below before·10⁹.
    • file_mtime_undated goes red because modified() answers 1970 plus uptime.
    • file_mtime_survives_a_reboot goes red on the oracle: a drift of about −1.99·10⁹ s.
    • 202_stat_mtime goes red by not compiling, because main's header has no st_mtim.
  • Independent oracle. The instant the host stages in the emulated RTC with -rtc base=, the same oracle wall_clock_* uses. For the C layout, clang's own sizeof/offsetof over the header.

What I am unsure of

  • An undated machine stamps every file 0, so the shared-object cache's BackingId (size plus mtime) no longer sees a same-size rewrite on any mount there. Before, nanos_since_boot kept two writes apart. Filed: issues/isolation/the-so-caches-identity-is-blind-on-an-undated-machine.md.
  • FAT cannot store undated. FatTime::from_unix_secs clamps 0 to 1980-01-01, which reads back as a date. Filed: issues/filesystem/an-undated-file-on-fat-reads-back-as-1980.md.
  • Userland reads the wall clock in whole seconds. std's SystemTime::now and libc's clock_gettime do, so a just-written file can read as up to a second in the future against the program's own "now". Filed: issues/filesystem/userlands-wall-clock-has-whole-seconds-and-an-mtime-has-nanoseconds.md.

When merging

#583 and #536 land first.

Closes issues/filesystem/a-files-mtime-is-nanoseconds-since-boot.md. Also filed, found by the audit and not fixed here:

  • issues/filesystem/the-last-handle-to-close-stamps-the-file-with-its-own-mtime.md, which reaches FAT as well since this change.
  • issues/filesystem/a-fat-files-mtime-reads-finer-through-its-writer-than-after-a-reopen.md
  • issues/filesystem/std-calls-undated-what-it-did-not-stat.md: DirEntry::metadata answers 0 for a file the kernel has a stamp for, and set_times returns Ok having set nothing.

🤖 Generated with Claude Code

Japabu and others added 2 commits September 28, 2026 21:22
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@Japabu
Japabu marked this pull request as ready for review September 28, 2026 19:36
@Japabu

Japabu commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 1, at 32948da

Ready for review. CI host concluded success on 32948da (run 36473365989; the earlier run 36472224038 was skipped). The orchestrator's guest runs at 32948da exited 0: file_mtime, file_mtime_survives_a_reboot, kernel_log_file, wall_clock_zone and Fast. The whole-change control exited 1 on both new tests, for the named reason. git merge-tree --write-tree origin/main 32948daf is clean. Net size: +396/−27. Production is +80/−27 (kernel +57/−20, toyos-abi +6/−1, libc +18/−7), tests +225, issues +91.

BLOCKER

  • userland/libc/src/posix_io.rs:488: st_nlink: u32 does not match nlink_t = unsigned long (include/sys/types.h:14). The Rust Stat is 112 bytes with st_mtim at offset 80, and C's struct stat is 120 bytes with st_mtim at 88. So a C caller's st_mtime reads the stamp's tv_nsec, and its st_size (offset 48) reads st_blksize, which is 0. fstat also leaves C's last 8 bytes unwritten. The PR's libc claim is false, and no C source in the tree calls stat or fstat, so nothing measures it. Fix: st_nlink: u64. Add a C test to tests/testcases that writes N bytes, then fstats and stats the file, and asserts sizeof(struct stat) == 120, st_size == N, before <= st_mtime <= time(NULL) and 0 <= st_mtim.tv_nsec < 1000000000. It must be red at 32948da.
  • kernel/src/object/ops.rs:114,132,821: three of the four stamp sites the PR names can each go back to crate::clock::nanos_since_boot() and every test stays green. The sites are create with truncate, create of a missing file, and ftruncate. file_mtime always writes after it creates, so only :526 is observed, and nothing calls ftruncate. Apply each patch alone: replace crate::clock::mtime_now() with crate::clock::nanos_since_boot() at :114, at :132 and at :821. Tests that must turn them red, in file_mtime's shared arm, each checked against write_judged's window:
  • kernel/src/clock.rs:202: the contract's no-RTC clause, which the ABI doc repeats, has no test. unwrap_or_else(nanos_since_boot) → unwrap_or(0) passes every run. The test: a file_mtime mode that never calls SYS_CLOCK_EPOCH, run on the rtc-dead machine (wall_clock_rtc_dead's params). It writes /tmp twice and asserts 0 < first < second < 86_400·10⁹. It must be red under the patch.

NOTE

REMOVE

SEND BACK

Japabu and others added 2 commits September 28, 2026 23:30
…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 <noreply@anthropic.com>
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2, head 57cc41e: gh pr view 587 --json mergeable returns CONFLICTING against origin/main 0368861. git merge-tree --write-tree origin/main 57cc41e4 exits 1 with a content conflict in tests/testcases/LICENSE. host passes on 57cc41e, but it is not measured against the tree this branch would merge into.

NOT READY FOR REVIEW

Recount tests/testcases/LICENSE's tinycc totals from the tree as merged
(318 files, 62 not upstream, 61 written here) instead of either side's
stale count. main's wall-clock boot-drift refactor (MAX_BOOT_DRIFT_SECS
replaced by after_the_base, measured against the boot's own elapsed
time) merged without a textual conflict but left file_mtime_survives_a_reboot
referencing the deleted constant; it now uses after_the_base like main's
other callers, with mtime_boot returning the elapsed Duration alongside
the printed mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant