v0.1.6: robust .old/ + staging cleanup under OneDrive / AV / Search#10
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.old/cleanup leaves a few stragglers because OneDrive holds short-lived sync handles on files the swap just moved in. The current recursiveDirectory.Delete(... recursive: true)walks happily through most files, races a handle on the last few, throwsIOException("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 parallelUnauthorizedAccessException.UpdateInstaller.DeleteDirectoryRobustly(path)helper that clearsReadOnlyattributes on every descendant file and retries the recursive delete at 200/400/800 ms onIOException/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).CleanupOldInstallstill swallows on final failure (non-fatal — next startup retries). The other three still throw viaUpdateExceptionso 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
deleterandsleeperseams so the retry-with-backoff logic can be exercised deterministically:DeleteDirectoryRobustly_when_path_missing_is_noopDeleteDirectoryRobustly_succeeds_first_try_calls_deleter_once_no_sleepsDeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure— fails twice withIOException, 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— realDirectory.Deleteagainst a tree containing aReadOnly-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— producesNextIteration.SpectreConsole.SelfUpdate.0.1.6.nupkg..old/should clean up on next startup with the new helper's retry budget).🤖 Generated with Claude Code