From cf5249698f1fa0e33f75f428a4492e55cf4c8c9a Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Mon, 25 May 2026 06:37:38 +0000 Subject: [PATCH] v0.1.7: CleanupOldInstall now also cleans .update/ staging tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.6 wired DeleteDirectoryRobustly into the end-of-install cleanup of .update/, but that pass runs immediately after extraction — exactly when OneDrive is busiest scanning the freshly-written tree. The 1.4s retry budget loses the race often enough that .update// persists across sessions. CleanupOldInstall (the startup hook) previously only touched .old/, so nothing ever retried .update/ after OneDrive had hours to settle. Fix: CleanupOldInstall now cleans both .old/ and .update/. Same helper, same swallow-on-final-failure semantics, just a second call site. The immediate cleanup in InstallAsync's finally stays as a fast-path for contention-free installs. Interface XML doc updated to reflect the broader scope; method name kept (CleanupOldInstall) so consumers' existing Program.cs startup hooks keep working without edits. Two new tests: CleanupOldInstall removes a populated .update/, and the combined case where both .old/ and .update/ exist. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 +++++++ .../IUpdateInstaller.cs | 14 ++++--- ...Iteration.SpectreConsole.SelfUpdate.csproj | 2 +- .../Pipeline/UpdateInstaller.cs | 25 ++++++++----- .../Pipeline/UpdateInstallerTests.cs | 37 +++++++++++++++++++ 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf28a8..aa76c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.1.7] — 2026-05-25 + +### Fixed + +- **`.update/` staging tree persists across sessions on OneDrive-synced installs.** v0.1.6 wired the recursive-delete-with-retry helper into `InstallAsync`'s end-of-install cleanup, but that pass runs immediately after extraction — the worst possible moment for OneDrive contention, since OneDrive is actively scanning a tree that just appeared. The 1.4 s retry budget loses the race often enough that `.update//` accumulates. `CleanupOldInstall` (called at startup) previously only touched `.old/`, so nothing ever retried `.update/` after OneDrive had time to release. +- Fix: `CleanupOldInstall` now cleans both `.old/` and `.update/`. The startup pass is the canonical retry path — by the time the user next launches the CLI, OneDrive has had hours or days to release the handles that defeated the install-time cleanup. Same `DeleteDirectoryRobustly` helper, same swallow-on-final-failure semantics, just a second path. The immediate cleanup in `InstallAsync`'s finally stays as a fast-path for installs where no contention exists. + +### Changed + +- `IUpdateInstaller.CleanupOldInstall`'s XML doc updated to reflect the broader scope. Method name unchanged for back-compat — consumers' `Program.cs` startup hook keeps working without edits. + +--- + ## [0.1.6] — 2026-05-25 ### Fixed @@ -111,6 +124,7 @@ Initial commit. Never published to nuget.org — superseded by 0.1.1 before the - Full XML documentation on the public surface, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest`. - SourceLink, deterministic builds, published symbol packages. +[0.1.7]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.7 [0.1.6]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.6 [0.1.5]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.5 [0.1.4]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.4 diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs index 490985c..c246634 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs @@ -3,8 +3,9 @@ namespace NextIteration.SpectreConsole.SelfUpdate /// /// Downloads a release, runs the verifier pipeline, extracts the archive, /// and atomically swaps the new files into the install directory. The - /// previous install is moved to a sibling .old/ directory and - /// deleted on the next startup via . + /// previous install is moved to a sibling .old/ directory; both it + /// and the .update/ staging tree are deleted on the next startup + /// via . /// public interface IUpdateInstaller { @@ -40,9 +41,12 @@ Task InstallAsync( /// /// Idempotent. Call this once at the very start of the CLI's - /// Main to delete any .old/ directory left behind by a - /// previous successful update — the running new binary is sufficient - /// proof the swap completed. Safe to call when no .old/ + /// Main to delete any .old/ or .update/ + /// directories left behind by a previous successful update — the + /// running new binary is sufficient proof both the swap and the + /// extraction completed. The startup pass is the canonical + /// retry path for OneDrive / antivirus contention that defeated + /// cleanup at install time. Safe to call when neither directory /// exists; failures are swallowed (will retry next startup). /// void CleanupOldInstall(); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index 66d3dce..1feaf5f 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj +++ b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj @@ -11,7 +11,7 @@ NextIteration.SpectreConsole.SelfUpdate - 0.1.6 + 0.1.7 Stuart Meeks Self-update for Spectre.Console CLIs: pluggable update sources (GitHub Releases over HTTP, GitHub Releases via gh CLI for private repos, generic HTTPS manifest, custom), SHA-256 verification, atomic file swap, and a drop-in `update` command. true diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index e641085..1c3574f 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -128,18 +128,23 @@ public async Task InstallAsync( public void CleanupOldInstall() { - try - { - DeleteDirectoryRobustly(Path.Combine(InstallDirectory, OldDirName)); - } + var installDir = InstallDirectory; + // Both .old/ and .update/ are leftover state from a previous + // install. The running new binary is proof neither is needed + // any longer. Cleaning both here is the canonical retry path + // for the OneDrive / antivirus / Windows Search case where + // handles held at install time defeated the immediate cleanup; + // by next startup those handles have had time to release. + TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, OldDirName))); + TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, StagingDirName))); + } + + private static void TrySwallow(Action action) + { + try { action(); } catch { - // Non-fatal — will retry on next startup. Stragglers in - // .old/ are typically the result of OneDrive / antivirus - // / Windows Search holding transient handles on files - // the swap moved in seconds earlier; the helper's retry - // budget covers the common case but a stubborn handle - // can still defeat it. + // Non-fatal — will retry on next startup. } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index d9819cc..53d33f9 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -120,6 +120,43 @@ public void CleanupOldInstall_when_old_directory_exists_deletes_it() Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); } + [Fact] + public void CleanupOldInstall_when_update_staging_exists_deletes_it() + { + // Regression for v0.1.7: the immediate post-install cleanup of + // .update/ races OneDrive's sync handles on the freshly-extracted + // tree. The startup pass is the canonical retry path; without + // this CleanupOldInstall would only touch .old/ and .update/ + // would accumulate across sessions. + using var work = new TempDir(); + var installDir = Path.Combine(work.Path, "install"); + var staging = Path.Combine(installDir, ".update", "v1.4.2"); + Directory.CreateDirectory(staging); + File.WriteAllText(Path.Combine(staging, "leftover.txt"), "stale"); + + var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); + installer.CleanupOldInstall(); + + Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + } + + [Fact] + public void CleanupOldInstall_cleans_both_old_and_update_directories() + { + using var work = new TempDir(); + var installDir = Path.Combine(work.Path, "install"); + Directory.CreateDirectory(Path.Combine(installDir, ".old")); + File.WriteAllText(Path.Combine(installDir, ".old", "garbage.txt"), "stale"); + Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); + File.WriteAllText(Path.Combine(installDir, ".update", "v1.4.2", "archive.zip"), "stale"); + + var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); + installer.CleanupOldInstall(); + + Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); + Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + } + [Fact] public void CleanupOldInstall_when_old_directory_missing_is_noop() {