From d04ee1d13124959986036cc024872aefd0aae2f8 Mon Sep 17 00:00:00 2001 From: PerryLink Date: Sun, 25 Oct 2026 16:17:15 +0800 Subject: [PATCH] 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> --- .../harness/deerflow/utils/output_names.py | 17 +++++++++----- ...st_browserless_capture_name_reservation.py | 8 ++++++- backend/tests/test_output_names.py | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/backend/packages/harness/deerflow/utils/output_names.py b/backend/packages/harness/deerflow/utils/output_names.py index 973c542a0f..43b285f85c 100644 --- a/backend/packages/harness/deerflow/utils/output_names.py +++ b/backend/packages/harness/deerflow/utils/output_names.py @@ -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}" diff --git a/backend/tests/test_browserless_capture_name_reservation.py b/backend/tests/test_browserless_capture_name_reservation.py index 1afd54647d..14b65b3477 100644 --- a/backend/tests/test_browserless_capture_name_reservation.py +++ b/backend/tests/test_browserless_capture_name_reservation.py @@ -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") diff --git a/backend/tests/test_output_names.py b/backend/tests/test_output_names.py index 67e34e78d5..6659394639 100644 --- a/backend/tests/test_output_names.py +++ b/backend/tests/test_output_names.py @@ -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)