fix(utils):Keep the timestamped output-name fallbacks distinct on a coarse clock (#7071)

* fix(harness): keep timestamped output-name fallbacks distinct

`output_name_candidates()` sampled `datetime.now(UTC)` inside the yield
loop, so its five timestamped fallbacks were only distinct when the host
clock advanced between two generator steps.  On a coarse clock (Windows
advances in sub-millisecond steps) all five produced the same name, and
the capture tools lost retries before failing with
`could not reserve an output filename`.

Sample the clock once when the first fallback is reached and offset each
attempt by one microsecond, so distinctness no longer depends on the host
clock.  Add a regression test that freezes the clock, and update the
exhaustion test to hold all five distinct stamps.

* docs: describe the coarse clock accurately

---------

Co-authored-by: PerryLink <255665900+PerryLink@users.noreply.github.com>
This commit is contained in:
PerryLink 2026-10-25 16:17:15 +08:00 committed by GitHub
parent 338cd26dc2
commit d04ee1d131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 40 additions and 7 deletions

View File

@ -3,13 +3,13 @@
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
# Cap collision-suffix probing so a saturated outputs directory cannot spin forever.
MAX_COLLISION_PROBES = 1000
# Retry bound for the timestamped fallback: the microseconds stamp can only repeat
# when another writer claimed that exact name within the same microsecond.
# Retry bound for the timestamped fallback: each attempt offsets one clock sample,
# so the bound does not depend on the host clock advancing between yields.
MAX_TIMESTAMP_ATTEMPTS = 5
@ -18,11 +18,16 @@ def output_name_candidates(name: str) -> Iterator[str]:
Callers claim each candidate atomically and move on when it is taken; the
generator never checks the filesystem, so naming cannot race the claim.
Timestamps are taken lazily, one per fallback attempt.
The clock is sampled lazily, once, when the first fallback is reached; each
later fallback offsets that sample by one microsecond. A host clock whose
tick is coarser than the microsecond stamp (for example Windows' ~15 ms
system-time tick) therefore cannot repeat a stamp inside one burst, and
every fallback stays a distinct candidate.
"""
yield name
stem, suffix = Path(name).stem, Path(name).suffix
for index in range(1, MAX_COLLISION_PROBES + 1):
yield f"{stem}-{index}{suffix}"
for _ in range(MAX_TIMESTAMP_ATTEMPTS):
yield f"{stem}-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')}{suffix}"
stamp = datetime.now(UTC)
for attempt in range(MAX_TIMESTAMP_ATTEMPTS):
yield f"{stem}-{(stamp + timedelta(microseconds=attempt)).strftime('%Y%m%d-%H%M%S-%f')}{suffix}"

View File

@ -188,7 +188,13 @@ def test_exhausted_timestamp_claims_refuse_to_overwrite(outputs, monkeypatch):
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
monkeypatch.setattr(output_names, "datetime", _FrozenClock)
held = ["report.png", "report-1.png", "report-20261014-120000-123456.png"]
# The timestamped fallbacks offset one frozen clock sample, so they are all
# distinct names; exhausting them means holding every one of them.
held = [
"report.png",
"report-1.png",
*(f"report-20261014-120000-{123456 + offset:06d}.png" for offset in range(output_names.MAX_TIMESTAMP_ATTEMPTS)),
]
for name in held:
(outputs / name).write_bytes(b"earlier")

View File

@ -41,6 +41,28 @@ def test_candidates_keep_the_name_then_number_then_timestamp(monkeypatch):
]
class _FrozenClock(datetime):
"""A coarse host clock: every sample inside one burst returns the same instant."""
@classmethod
def now(cls, tz=None):
return datetime(2026, 10, 18, 12, 0, 0, tzinfo=UTC)
def test_timestamped_fallbacks_stay_distinct_on_a_coarse_clock(monkeypatch):
# A host clock whose tick is coarser than the microsecond stamp (Windows'
# system-time tick is ~15 ms) hands back one value for all five samples in a
# burst. The fallback bound must not depend on the clock advancing between
# yields.
monkeypatch.setattr(output_names, "MAX_COLLISION_PROBES", 1)
monkeypatch.setattr(output_names, "datetime", _FrozenClock)
names = list(output_names.output_name_candidates("report.png"))
assert len(names) == 1 + 1 + output_names.MAX_TIMESTAMP_ATTEMPTS
assert len(set(names)) == len(names)
def test_candidates_read_the_clock_only_when_a_fallback_is_reached(monkeypatch):
monkeypatch.setattr(_StepClock, "ticks", 0)
monkeypatch.setattr(output_names, "datetime", _StepClock)