Repository navigation
[v1.0] Bound and classify Windows atomic-replace retries #315
Description
Goal
Retry genuinely transient Windows sharing/lock failures without imposing multi-second latency on permanent permission errors.
Background
Current _replace_with_retry() catches every PermissionError on Windows, retries up to 50 times, and sleeps with linear backoff:
base-cli/lib/python/base_cli/_private_files.py
Lines 182 to 197 in 1df8bc4
| def _replace_with_retry(source: Path, destination: Path) -> None: | |
| """Replace a private file, tolerating transient Windows sharing races.""" | |
| # Antivirus/indexer handles and concurrent writers can hold the destination | |
| # briefly on Windows. Use a bounded, linear backoff long enough for those | |
| # transient sharing violations without making a persistent permission error | |
| # unbounded. | |
| attempts = 1 if os.name != "nt" else 50 | |
| for attempt in range(attempts): | |
| try: | |
| os.replace(source, destination) | |
| return | |
| except PermissionError: | |
| if attempt == attempts - 1: | |
| raise | |
| time.sleep(0.005 * (attempt + 1)) |
The worst-case sleep before the final failure is:
0.005 * (1 + 2 + ... + 49) = 6.125 seconds
The code does not distinguish a transient sharing/lock violation from permanent access denied, a read-only destination, or another non-retryable permission condition. Managed metadata is written in the invocation lifecycle, so a permanent filesystem policy error can turn a fast actionable failure into an unexplained six-second stall. The only regression test covers one transient failure followed by success; it does not assert error classification or a maximum elapsed budget:
base-cli/tests/test_platform_edge_paths.py
Lines 38 to 55 in 1df8bc4
| def test_windows_replace_retries_transient_sharing_failure(self) -> None: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| source = Path(tmpdir) / "source" | |
| destination = Path(tmpdir) / "destination" | |
| source.write_text("payload", encoding="utf-8") | |
| with ( | |
| mock.patch.object(private_files.os, "name", "nt"), | |
| mock.patch.object( | |
| private_files.os, | |
| "replace", | |
| side_effect=[PermissionError("busy"), lambda src, dst: Path(dst).write_text(Path(src).read_text())], | |
| ) as replace, | |
| mock.patch.object(private_files.time, "sleep") as sleep, | |
| ): | |
| private_files._replace_with_retry(source, destination) # pylint: disable=protected-access | |
| self.assertEqual(replace.call_count, 2) | |
| sleep.assert_called_once() | |
Scope
- Classify retryable Windows sharing/lock errors from
winerror/documented platform signals. - Fail immediately on permanent permission and path-policy errors.
- Replace attempt-count-only behavior with an explicit maximum elapsed budget.
- Make delay/clock behavior deterministic in tests and observable in debug diagnostics.
- Preserve atomic replacement and the old destination on failure.
Acceptance criteria
- Sharing/lock violations retry within a documented total deadline.
- Access denied and other non-retryable permission errors fail without sleeping.
- Worst-case retry latency is asserted in tests.
- Successful transient recovery, deadline exhaustion, and permanent failure retain correct temporary-file cleanup.
- Native Windows and mounted/network-filesystem cases are covered or explicitly documented.
Validation
Run focused fault-injection tests plus native Windows lifecycle and metadata persistence jobs.
Project fields
- Status: Backlog
- Priority: P2
- Area: Runtime
- Initiative: Adoption Polish
- Size: S
Ownership
- Assignee: @codeforester
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
fix: bound Windows metadata replacement retries (#326)
codeforester commentedon Sep 2, 2026
Implemented and merged in PR #326 (Windows retry classification and elapsed deadline).