From 7a47f936da75d1e8b5b2ccd95399863df45302d4 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 05:58:07 -0700 Subject: [PATCH] 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". --- TESTING.md | 7 +++--- tests/test_media_capture.py | 45 +++++++++++++++++++++++++++++++++---- utils/media_capture.py | 3 ++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/TESTING.md b/TESTING.md index 3281eb8b..e9fad994 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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`). diff --git a/tests/test_media_capture.py b/tests/test_media_capture.py index b021ada1..b67c7a0b 100644 --- a/tests/test_media_capture.py +++ b/tests/test_media_capture.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) diff --git a/utils/media_capture.py b/utils/media_capture.py index f7c22c0d..86988dbe 100644 --- a/utils/media_capture.py +++ b/utils/media_capture.py @@ -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