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:
+4
-3
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user