Skip to content

v0.1.6: robust .old/ + staging cleanup under OneDrive / AV / Search#10

Merged
StuartMeeks merged 1 commit into
mainfrom
robust-dir-delete-onedrive
May 25, 2026
Merged

v0.1.6: robust .old/ + staging cleanup under OneDrive / AV / Search#10
StuartMeeks merged 1 commit into
mainfrom
robust-dir-delete-onedrive

Conversation

@StuartMeeks
Copy link
Copy Markdown
Owner

@StuartMeeks StuartMeeks commented May 25, 2026

Summary

  • On Windows with OneDrive syncing the install dir, .old/ cleanup leaves a few stragglers because OneDrive holds short-lived sync handles on files the swap just moved in. The current recursive Directory.Delete(... recursive: true) walks happily through most files, races a handle on the last few, throws IOException("being used by another process"), and the catch-all swallows it. Antivirus, Windows Search, indexers, and backup agents cause the same race. Read-only files extracted from archives cause a parallel UnauthorizedAccessException.
  • Fix: new internal UpdateInstaller.DeleteDirectoryRobustly(path) helper that clears ReadOnly attributes on every descendant file and retries the recursive delete at 200/400/800 ms on IOException / UnauthorizedAccessException — ~1.4 s budget tuned for OneDrive's typical handle-release latency. Applied to all four recursive-delete sites (CleanupOldInstall, SwapAsync's .old/ reset, ResetStaging, TryDeleteDirectory).
  • Exception-propagation contracts unchanged. CleanupOldInstall still swallows on final failure (non-fatal — next startup retries). The other three still throw via UpdateException so the install fails loudly when staging can't be reset.

Why retry instead of OneDrive detection

OneDrive detection is fragile (registry queries, reparse-point sniffing) and the retry strategy generalises to every other class of transient handle holder. Cost when no contention exists: one no-op attribute walk over a tree we're about to delete.

Tests

5 new (172 → 177). Inject deleter and sleeper seams so the retry-with-backoff logic can be exercised deterministically:

  • DeleteDirectoryRobustly_when_path_missing_is_noop
  • DeleteDirectoryRobustly_succeeds_first_try_calls_deleter_once_no_sleeps
  • DeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure — fails twice with IOException, succeeds on third call; asserts exactly 200ms and 400ms sleeps between.
  • DeleteDirectoryRobustly_throws_after_exhausting_retries — asserts 4 deleter calls + 3 sleeps at 200/400/800, then the original exception propagates.
  • DeleteDirectoryRobustly_clears_readonly_attribute_on_descendant_files — real Directory.Delete against a tree containing a ReadOnly-marked file; verifies the helper clears the attribute so the underlying delete succeeds.

Test plan

  • dotnet build — clean.
  • dotnet test — 177 passing.
  • dotnet build -c Release — produces NextIteration.SpectreConsole.SelfUpdate.0.1.6.nupkg.
  • Maintainer to verify against the pl-app scenario (OneDrive-synced install dir, post-update .old/ should clean up on next startup with the new helper's retry budget).

🤖 Generated with Claude Code

The swap moves the previous install's contents into .old/ and the next
startup's CleanupOldInstall removes that tree. On Windows with OneDrive
syncing the install directory, OneDrive grabs sync handles on the
freshly-moved files within seconds; the recursive Directory.Delete
walks happily through most of them but races a handle on the last few,
throws IOException("being used by another process"), and the existing
catch-all swallows it. Result: .old/ persists across startups, usually
near-empty save for whatever OneDrive was still touching. Antivirus
scanners, Windows Search, indexers, and backup agents create the same
race. Files extracted from archives with the ReadOnly attribute hit a
parallel UnauthorizedAccessException for similar timing reasons.

Fix: new internal UpdateInstaller.DeleteDirectoryRobustly(path) helper
that clears the ReadOnly attribute on every descendant file and retries
the recursive delete at 200/400/800 ms on IOException /
UnauthorizedAccessException — ~1.4 s total budget tuned to OneDrive's
typical handle-release latency. Applied to all four recursive-delete
sites: CleanupOldInstall, SwapAsync's .old/ reset, ResetStaging,
TryDeleteDirectory. Exception-propagation contracts unchanged
(CleanupOldInstall still swallows; the other three still throw via
UpdateException).

OneDrive detection deliberately avoided — fragile (registry queries,
reparse-point sniffing) and the retry generalises to every other
class of transient handle holder. Cost when no contention exists:
one no-op attribute walk over a tree we're about to delete.

Tests inject deleter + sleeper seams so the retry-with-backoff logic
can be exercised deterministically without simulating real Windows
locks. 172 → 177 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@StuartMeeks StuartMeeks merged commit a0d8b4f into main May 25, 2026
4 checks passed
@StuartMeeks StuartMeeks deleted the robust-dir-delete-onedrive branch May 25, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant