test(annotations): accept change detection wherever fstat exposes a change time

Native Windows CI on Python 3.13 detected the same-size, mtime-restored
rewrite through the descriptor's change time (3.10-3.12 report the
creation time there and complete the torn copy). The test asserted the
platform, not the signal: it now accepts CaptureChanged plus temp
removal wherever it happens, requires detection on POSIX and on
Windows 3.13+, and otherwise requires the digest to describe the
copied bytes. A new test injects a drifting fstat change time so the
detection path runs on every platform; TESTING.md records the limit as
"Windows before Python 3.13".
This commit is contained in:
Vito Sansevero
2026-10-22 05:58:07 -07:00
parent af26c2fd49
commit 7a47f936da
3 changed files with 47 additions and 8 deletions
+4 -3
View File
@@ -1391,9 +1391,10 @@ until its last use ends. Header-only dimensions (4096 px per side,
16,777,216 px) and video mark Generate unsupported; `verify` re-hashes the
current file without a copy. Every handle closes before unlink (a patched
`os.unlink` asserts it, plus `assert_closed`), and files are unlinked outside
the store lock. Known limit: Windows reports the creation time from `fstat`, so
a same-size rewrite that restores `mtime_ns` during the copy window completes
there with a digest of the copied bytes; POSIX detects it through `ctime`.
the store lock. Known limit: on Windows before Python 3.13 `fstat` reports the
creation time, so a same-size rewrite that restores `mtime_ns` during the copy
window completes there with a digest of the copied bytes; POSIX and Windows on
3.13+ detect it through the descriptor's change time (native CI, 3.13 only).
Vocabulary rename, merge and delete bump the `tag_revision` of every image set
they change (and `image_index_revision`), so a Tag dialog opened before the
change gets `tag-conflict` on Save (`tests/test_pair_tag_maintenance.py`).
+41 -4
View File
@@ -4,6 +4,7 @@ import hashlib
import os
import stat
import struct
import sys
import tempfile
import threading
import time
@@ -292,15 +293,23 @@ class CaptureStoreTestCase(unittest.TestCase):
os.utime(source, ns=(before.st_atime_ns, before.st_mtime_ns))
return data
# Detection depends on whether fstat exposes a change time: POSIX
# always, Windows only on Python 3.13+. Where it is missing the torn
# copy completes and its digest must describe the copied bytes.
with patch.object(CaptureStore, "_read", rewriting):
if os.name == "posix":
with self.assertRaises(CaptureChanged):
store.capture(self.root, "a.png", epoch=EPOCH, scope=SCOPE)
else:
try:
cap = store.capture(self.root, "a.png", epoch=EPOCH, scope=SCOPE)
except CaptureChanged:
detected = True
else:
detected = False
copied = Path(cap.path).read_bytes()
self.assertEqual(cap.sha256, hashlib.sha256(copied).hexdigest())
store.release(cap.token)
if os.name == "posix":
self.assertTrue(detected)
else:
self.assertEqual(detected, sys.version_info >= (3, 13))
self.assert_empty(store)
self.assertEqual(os.stat(source).st_mtime_ns, before.st_mtime_ns)
@@ -749,6 +758,34 @@ class CaptureStoreTestCase(unittest.TestCase):
store.release(cap.token)
self.assert_empty(store)
def test_change_time_moving_between_fstat_reads_is_detected(self):
"""The Windows 3.13 / POSIX signal, forced on every platform: the
descriptor's change time differs after the copy, size and mtime equal."""
self.write("a.png", png_bytes(1, 1, b"\x77" * 40))
store = self.make_store()
original_fstat = os.fstat
calls = []
def drifting(fd):
st = original_fstat(fd)
calls.append(fd)
if len(calls) < 2:
return st
return os.stat_result(
tuple(st)[:10],
{
"st_atime_ns": st.st_atime_ns,
"st_mtime_ns": st.st_mtime_ns,
"st_ctime_ns": st.st_ctime_ns + 1,
},
)
with patch.object(capture_module.os, "fstat", drifting):
with self.assertRaises(CaptureChanged):
self.capture(store, "a.png")
self.assertEqual(len(calls), 2)
self.assert_empty(store)
def test_fresh_file_captures_twice_on_every_platform(self):
data = png_bytes(1, 1, os.urandom(3 * capture_module.CHUNK_SIZE))
self.write("fresh.png", data)
+2 -1
View File
@@ -12,7 +12,8 @@ lifetimes.
The contract is "these bytes at this location, last checked at this time".
Stat equality is never content proof: a same-size rewrite that restores
``mtime_ns`` is visible only through the change time between the two
``fstat`` reads, which on Windows is the creation time (no signal).
``fstat`` reads, which Windows before Python 3.13 reports as the creation time
(no signal).
"""
from __future__ import annotations