Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tag>/` 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ namespace NextIteration.SpectreConsole.SelfUpdate
/// <summary>
/// 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 <c>.old/</c> directory and
/// deleted on the next startup via <see cref="CleanupOldInstall"/>.
/// previous install is moved to a sibling <c>.old/</c> directory; both it
/// and the <c>.update/</c> staging tree are deleted on the next startup
/// via <see cref="CleanupOldInstall"/>.
/// </summary>
public interface IUpdateInstaller
{
Expand Down Expand Up @@ -40,9 +41,12 @@ Task InstallAsync(

/// <summary>
/// Idempotent. Call this once at the very start of the CLI's
/// <c>Main</c> to delete any <c>.old/</c> directory left behind by a
/// previous successful update — the running new binary is sufficient
/// proof the swap completed. Safe to call when no <c>.old/</c>
/// <c>Main</c> to delete any <c>.old/</c> or <c>.update/</c>
/// 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).
/// </summary>
void CleanupOldInstall();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<PackageId>NextIteration.SpectreConsole.SelfUpdate</PackageId>
<Version>0.1.6</Version>
<Version>0.1.7</Version>
<Authors>Stuart Meeks</Authors>
<Description>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.</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading