Skip to content

Commit 2a263ec

Browse files
Merge pull request #9190 from ThomasWaldmann/backport/pr-9151-to-1.4-maint
Backport: add granularity_sleep, fixes #9150 (from #9151) to 1.4-maint
2 parents 2b1fa7a + e06935f commit 2a263ec

File tree

2 files changed

+52
-16
lines changed

2 files changed

+52
-16
lines changed

src/borg/testsuite/__init__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# Note: this is used by borg.selftest; do not use or import pytest functionality here.
2626

2727
from ..fuse_impl import llfuse, has_pyfuse3, has_llfuse
28+
from ..platformflags import is_win32, is_darwin
2829

2930
# Does this version of llfuse support ns precision?
3031
have_fuse_mtime_ns = hasattr(llfuse.EntryAttributes, 'st_mtime_ns') if llfuse else False
@@ -60,6 +61,46 @@ def same_ts_ns(ts_ns1, ts_ns2):
6061
return diff_ts <= diff_max
6162

6263

64+
def granularity_sleep(*, ctime_quirk=False):
65+
"""Sleep long enough to overcome filesystem timestamp granularity and related platform quirks.
66+
67+
Purpose
68+
- Ensure that successive file operations land on different timestamp "ticks" across filesystems
69+
and operating systems, so tests that compare mtime/ctime are reliable.
70+
71+
Default rationale (ctime_quirk=False)
72+
- macOS: Some volumes may still be HFS+ (1 s timestamp granularity). To be safe across APFS and HFS+,
73+
sleep 1.0 s on Darwin.
74+
- Windows/NTFS: Although NTFS stores timestamps with 100 ns units, actual updates can be delayed by
75+
scheduling/metadata behavior. Sleep a short but noticeable amount (0.2 s).
76+
- Linux/BSD and others: Modern filesystems (ext4, XFS, Btrfs, ZFS, UFS2, etc.) typically have
77+
sub-second granularity; a small delay (0.02 s) is sufficient in practice.
78+
79+
Windows ctime quirk (ctime_quirk=True)
80+
- On Windows, ``stat().st_ctime`` is the file creation time, not "metadata change time" as on Unix.
81+
- NTFS implements a feature called "file system tunneling" that preserves certain metadata — including
82+
creation time — for short intervals when a file is deleted and a new file with the same name is
83+
created in the same directory. The default tunneling window is about 15 seconds.
84+
- Consequence: If a test deletes a file and quickly recreates it with the same name, the creation time
85+
(st_ctime) may remain unchanged for up to ~15 s, causing flakiness when tests expect a changed ctime.
86+
- When ``ctime_quirk=True`` this helper sleeps long enough on Windows (15.0 s) to exceed the tunneling
87+
window so the new file receives a fresh creation time. On non-Windows platforms this flag has no
88+
special effect beyond the normal, short sleep.
89+
90+
Parameters
91+
- ctime_quirk: bool (default False)
92+
If True, apply the Windows NTFS tunneling workaround (15 s sleep on Windows). Ignored elsewhere.
93+
"""
94+
if is_darwin:
95+
duration = 1.0
96+
elif is_win32:
97+
duration = 0.2 if not ctime_quirk else 15.0
98+
else:
99+
# Default for Linux/BSD and others with fine-grained timestamps
100+
duration = 0.02
101+
time.sleep(duration)
102+
103+
63104
@contextmanager
64105
def unopened_tempfile():
65106
with tempfile.TemporaryDirectory() as tempdir:

src/borg/testsuite/archiver.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
from ..remote import RemoteRepository, PathNotAllowed
5858
from ..repository import Repository
5959
from . import has_lchflags, llfuse
60-
from . import BaseTestCase, changedir, environment_variable, no_selinux, same_ts_ns
60+
from . import BaseTestCase, changedir, environment_variable, no_selinux, same_ts_ns, granularity_sleep
6161
from . import are_symlinks_supported, are_hardlinks_supported, are_fifos_supported, is_utime_fully_supported, is_birthtime_fully_supported
6262
from .platform import fakeroot_detected, is_darwin, is_freebsd, is_win32
6363
from .upgrader import make_attic_repo
@@ -383,7 +383,7 @@ def create_test_files(self, create_hardlinks=True):
383383
if e.errno not in (errno.EINVAL, errno.ENOSYS):
384384
raise
385385
have_root = False
386-
time.sleep(1) # "empty" must have newer timestamp than other files
386+
granularity_sleep() # ensure "empty" has a newer timestamp than other files across filesystems
387387
self.create_regular_file('empty', size=0)
388388
return have_root
389389

@@ -2074,7 +2074,7 @@ def test_file_status(self):
20742074
20752075
clearly incomplete: only tests for the weird "unchanged" status for now"""
20762076
self.create_regular_file('file1', size=1024 * 80)
2077-
time.sleep(1) # file2 must have newer timestamps than file1
2077+
granularity_sleep() # file2 must have newer timestamps than file1
20782078
self.create_regular_file('file2', size=1024 * 80)
20792079
self.cmd('init', '--encryption=repokey', self.repository_location)
20802080
output = self.cmd('create', '--list', self.repository_location + '::test', 'input')
@@ -2090,7 +2090,7 @@ def test_file_status(self):
20902090
def test_file_status_cs_cache_mode(self):
20912091
"""test that a changed file with faked "previous" mtime still gets backed up in ctime,size cache_mode"""
20922092
self.create_regular_file('file1', contents=b'123')
2093-
time.sleep(1) # file2 must have newer timestamps than file1
2093+
granularity_sleep() # file2 must have newer timestamps than file1
20942094
self.create_regular_file('file2', size=10)
20952095
self.cmd('init', '--encryption=repokey', self.repository_location)
20962096
output = self.cmd('create', '--list', '--files-cache=ctime,size', self.repository_location + '::test1', 'input')
@@ -2105,7 +2105,7 @@ def test_file_status_cs_cache_mode(self):
21052105
def test_file_status_ms_cache_mode(self):
21062106
"""test that a chmod'ed file with no content changes does not get chunked again in mtime,size cache_mode"""
21072107
self.create_regular_file('file1', size=10)
2108-
time.sleep(1) # file2 must have newer timestamps than file1
2108+
granularity_sleep() # file2 must have newer timestamps than file1
21092109
self.create_regular_file('file2', size=10)
21102110
self.cmd('init', '--encryption=repokey', self.repository_location)
21112111
output = self.cmd('create', '--list', '--files-cache=mtime,size', self.repository_location + '::test1', 'input')
@@ -2119,7 +2119,7 @@ def test_file_status_ms_cache_mode(self):
21192119
def test_file_status_rc_cache_mode(self):
21202120
"""test that files get rechunked unconditionally in rechunk,ctime cache mode"""
21212121
self.create_regular_file('file1', size=10)
2122-
time.sleep(1) # file2 must have newer timestamps than file1
2122+
granularity_sleep() # file2 must have newer timestamps than file1
21232123
self.create_regular_file('file2', size=10)
21242124
self.cmd('init', '--encryption=repokey', self.repository_location)
21252125
output = self.cmd('create', '--list', '--files-cache=rechunk,ctime', self.repository_location + '::test1', 'input')
@@ -2131,7 +2131,7 @@ def test_file_status_excluded(self):
21312131
"""test that excluded paths are listed"""
21322132

21332133
self.create_regular_file('file1', size=1024 * 80)
2134-
time.sleep(1) # file2 must have newer timestamps than file1
2134+
granularity_sleep() # file2 must have newer timestamps than file1
21352135
self.create_regular_file('file2', size=1024 * 80)
21362136
if has_lchflags:
21372137
self.create_regular_file('file3', size=1024 * 80)
@@ -4753,7 +4753,7 @@ def test_basic_functionality(self):
47534753
self.create_regular_file('file_replaced', contents=b'0' * 4096)
47544754
os.unlink('input/file_removed')
47554755
os.unlink('input/file_removed2')
4756-
time.sleep(1) # macOS HFS+ has a 1s timestamp granularity
4756+
granularity_sleep() # cover FS timestamp granularity differences (e.g. HFS+ 1s)
47574757
Path('input/file_touched').touch()
47584758
os.rmdir('input/dir_replaced_with_file')
47594759
self.create_regular_file('dir_replaced_with_file', size=8192)
@@ -5053,20 +5053,15 @@ def test_time_diffs(self):
50535053
self.cmd('init', '--encryption=repokey', self.repository_location)
50545054
self.create_regular_file("test_file", size=10)
50555055
self.cmd('create', self.repository_location + '::archive1', 'input')
5056-
time.sleep(0.1)
5056+
granularity_sleep()
50575057
os.unlink("input/test_file")
5058-
if is_win32:
5059-
# Sleeping for 15s because Windows doesn't refresh ctime if file is deleted and recreated within 15 seconds.
5060-
time.sleep(15)
5061-
elif is_darwin:
5062-
time.sleep(1) # HFS has a 1s timestamp granularity
5058+
granularity_sleep(ctime_quirk=True)
50635059
self.create_regular_file("test_file", size=15)
50645060
self.cmd('create', self.repository_location + '::archive2', 'input')
50655061
output = self.cmd("diff", self.repository_location + "::archive1", "archive2")
50665062
self.assert_in("mtime", output)
50675063
self.assert_in("ctime", output) # Should show up on windows as well since it is a new file.
5068-
if is_darwin:
5069-
time.sleep(1) # HFS has a 1s timestamp granularity
5064+
granularity_sleep()
50705065
os.chmod("input/test_file", 0o777)
50715066
self.cmd('create', self.repository_location + '::archive3', 'input')
50725067
output = self.cmd("diff", self.repository_location + "::archive2", "archive3")

0 commit comments

Comments
 (0)