@@ -921,6 +921,9 @@ def _stat_and_read_with_sharing_retry(path: Path) -> tuple[os.stat_result, bytes
921921 raise FileNotFoundError (path ) from last_exc
922922
923923
924+ _UTIME_SUPPORTS_FD = os .utime in os .supports_fd
925+
926+
924927def _touch_atime (path : Path , st_before : os .stat_result ) -> None :
925928 """Bump ``path``'s atime to "now", preserving its mtime, iff the
926929 file's stat still matches ``st_before``.
@@ -942,14 +945,52 @@ def _touch_atime(path: Path, st_before: os.stat_result) -> None:
942945 read and this touch, blindly applying ``st_before.st_mtime_ns``
943946 would roll the new entry's mtime back to the old value and confuse
944947 the eviction stat-guard (which checks ``(ino, size, mtime_ns)``)
945- into deleting a freshly-committed file. A mismatch means the racing
946- writer's reader will refresh atime when it next reads, so skipping
947- here is safe.
948+ into deleting a freshly-committed file.
949+
950+ Where ``os.utime`` supports file descriptors (Linux, macOS), the
951+ fstat-then-utime pair runs against the same open fd: even if another
952+ writer replaces the path between our ``os.open`` and the ``fstat``,
953+ the fd still refers to the file we opened, so the comparison and the
954+ utime both target the same inode. This closes the residual TOCTOU
955+ window that a path-based stat + path-based utime would have.
956+
957+ On Windows, ``os.utime`` is path-only; the fallback re-stats the
958+ path and accepts a small TOCTOU window between the second stat and
959+ the utime. That window is microseconds and the worst-case outcome
960+ is the racing writer's mtime being rolled back by a few hundred
961+ nanoseconds -- the eviction stat-guard would then refuse to evict
962+ the slightly-stale entry, costing one cache miss (recompile) but
963+ not a corrupt eviction.
948964
949965 Best-effort: any ``OSError`` (read-only mount, restrictive ACLs,
950966 ...) is swallowed -- size enforcement still bounds the cache, but
951967 eviction degrades toward FIFO.
952968 """
969+ new_atime_ns = time .time_ns ()
970+ if _UTIME_SUPPORTS_FD :
971+ try :
972+ fd = os .open (path , os .O_RDONLY )
973+ except OSError :
974+ return
975+ try :
976+ try :
977+ st_now = os .fstat (fd )
978+ except OSError :
979+ return
980+ if (st_now .st_ino , st_now .st_size , st_now .st_mtime_ns ) != (
981+ st_before .st_ino ,
982+ st_before .st_size ,
983+ st_before .st_mtime_ns ,
984+ ):
985+ return
986+ with contextlib .suppress (OSError ):
987+ os .utime (fd , ns = (new_atime_ns , st_before .st_mtime_ns ))
988+ finally :
989+ os .close (fd )
990+ return
991+
992+ # Path-based fallback (Windows). Best-effort -- residual TOCTOU window
993+ # documented above.
953994 try :
954995 st_now = path .stat ()
955996 except OSError :
@@ -961,7 +1002,7 @@ def _touch_atime(path: Path, st_before: os.stat_result) -> None:
9611002 ):
9621003 return
9631004 with contextlib .suppress (OSError ):
964- os .utime (path , ns = (time . time_ns () , st_before .st_mtime_ns ))
1005+ os .utime (path , ns = (new_atime_ns , st_before .st_mtime_ns ))
9651006
9661007
9671008def _prune_if_stat_unchanged (path : Path , st_before : os .stat_result ) -> None :
0 commit comments