From 93f5590010855ab01ca8f6d56f0aefd2c0179647 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Wed, 27 May 2026 02:16:39 +0000 Subject: [PATCH] v0.1.8: UpdateCleanup helper shows progress while clearing .old/.update at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup CleanupOldInstall() is synchronous and, under OneDrive / antivirus / Windows Search contention, the DeleteDirectoryRobustly retry/backoff path can take seconds with no output — the app looks hung. - Add IUpdateInstaller.HasPendingCleanup: cheap, side-effect-free check that is true when a .old/ or .update/ directory left by a previous update still exists. - Add static UpdateCleanup helper (mirrors UpdateBanner). UpdateCleanup.Run shows a "Cleaning up previous update…" status spinner only when HasPendingCleanup is true; otherwise it cleans silently. Run(IServiceProvider, IAnsiConsole?) is the drop-in startup entry point; Run(IUpdateInstaller, IAnsiConsole) is for explicit callers. The installer pipeline stays console-free; UI stays in the UI layer. - Demo Program.cs and README quick start now call UpdateCleanup.Run(serviceProvider). - Tests: HasPendingCleanup cases + new UpdateCleanupTests. Purely additive — CleanupOldInstall() is unchanged, so existing consumers keep compiling; switching the one startup line opts into the message. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 ++++ README.md | 4 +- .../Program.cs | 5 +- .../IUpdateInstaller.cs | 10 +++ ...Iteration.SpectreConsole.SelfUpdate.csproj | 2 +- .../Pipeline/UpdateInstaller.cs | 10 +++ .../UpdateCleanup.cs | 70 ++++++++++++++++++ .../Infrastructure/Stubs.cs | 6 +- .../Pipeline/UpdateInstallerTests.cs | 36 ++++++++++ .../UpdateCleanupTests.cs | 72 +++++++++++++++++++ 10 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/NextIteration.SpectreConsole.SelfUpdate/UpdateCleanup.cs create mode 100644 tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index aa76c30..610af1b 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.8] — 2026-05-27 + +### Added + +- **`UpdateCleanup.Run(...)` startup-cleanup helper.** Startup `CleanupOldInstall()` is synchronous and, under OneDrive / antivirus / Windows Search contention, the `DeleteDirectoryRobustly` retry/backoff path can take several seconds — with no output the app looks hung. New static `UpdateCleanup` helper (mirrors `UpdateBanner`) wraps the cleanup and shows a `Cleaning up previous update…` status spinner **only when there is leftover state to remove**; the common no-leftovers case stays completely silent. `Run(IServiceProvider, IAnsiConsole?)` is the drop-in startup entry point; a `Run(IUpdateInstaller, IAnsiConsole)` overload is provided for explicit/headless callers. +- **`IUpdateInstaller.HasPendingCleanup`.** Cheap, side-effect-free check that returns `true` when a `.old/` or `.update/` directory left by a previous update still exists. Lets a UI layer decide whether to show a cleanup message. + +### Changed + +- The demo `Program.cs` and the README quick start now call `UpdateCleanup.Run(serviceProvider)` instead of `IUpdateInstaller.CleanupOldInstall()` directly. Purely additive — `CleanupOldInstall()` is unchanged, so existing consumers keep compiling; switching the one startup line opts into the message. + +--- + ## [0.1.7] — 2026-05-25 ### Fixed @@ -124,6 +137,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.8]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.SelfUpdate/releases/tag/v0.1.8 [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 diff --git a/README.md b/README.md index c7cd706..e9cd76f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ services.AddSelfUpdater(opts => }); using var sp = services.BuildServiceProvider(); -sp.GetRequiredService().CleanupOldInstall(); +UpdateCleanup.Run(sp); // sweeps .old/.update; shows a message only if there's leftover state var checkTask = UpdateBanner.KickOffCheck(sp); var app = new CommandApp(new YourTypeRegistrar(sp)); @@ -193,7 +193,7 @@ For sources that don't have a channel concept (`HttpManifestSource`), host one m 1. **Acquire lock.** `/.update.lock` opened with `FileShare.None` + `FileOptions.DeleteOnClose`. Concurrent installs lose the race with a clear "another update is in progress" message. 2. **Stage.** Download the asset under `/.update//`, run every registered `IPackageVerifier`, extract. 3. **Swap.** Move every entry in `/` (except the maintenance dirs) into `/.old/`. Copy the extracted files into place. Delete `/.update/`. -4. **Cleanup later.** Next startup, `IUpdateInstaller.CleanupOldInstall()` deletes `/.old/` — the running new binary is proof the swap completed. +4. **Cleanup later.** Next startup, `IUpdateInstaller.CleanupOldInstall()` deletes `/.old/` (and any leftover `/.update/`) — the running new binary is proof the swap completed. Call it via `UpdateCleanup.Run(sp)`, which shows a "cleaning up" status message while it works but only when there is leftover state to remove. This avoids the "EXE is locked while running" problem on Windows without a separate restarter process. diff --git a/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/Program.cs b/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/Program.cs index aee5c38..919cc7c 100644 --- a/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/Program.cs +++ b/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/Program.cs @@ -25,7 +25,10 @@ public static int Main(string[] args) // 1. Sweep up the previous install (the running new binary is proof // the last swap completed). Idempotent — safe to call every run. - serviceProvider.GetRequiredService().CleanupOldInstall(); + // UpdateCleanup shows a status message while it works, but only when + // there's leftover .old/ or .update/ state to remove; otherwise it's + // silent. + UpdateCleanup.Run(serviceProvider); // 2. Kick off the background "is there a new version?" probe. It's // short-timeout and read-through-cached; on the warm path it diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs index c246634..ecc0c73 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/IUpdateInstaller.cs @@ -12,6 +12,16 @@ public interface IUpdateInstaller /// The directory the running CLI lives in — the swap target. string InstallDirectory { get; } + /// + /// when a startup + /// would actually delete something — i.e. a .old/ or + /// .update/ directory left by a previous update still exists. + /// Lets a UI layer (e.g. ) show a + /// "cleaning up" message only when there is work to do. Cheap + /// directory-existence check with no side effects. + /// + bool HasPendingCleanup { get; } + /// /// Apply the given release. Throws /// when any pipeline stage fails; on success returns once all files diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj b/src/NextIteration.SpectreConsole.SelfUpdate/NextIteration.SpectreConsole.SelfUpdate.csproj index 1feaf5f..98968c8 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.7 + 0.1.8 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 1c3574f..1c7cced 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -58,6 +58,16 @@ internal UpdateInstaller( public string InstallDirectory => _installDirResolver(); + public bool HasPendingCleanup + { + get + { + var installDir = InstallDirectory; + return Directory.Exists(Path.Combine(installDir, OldDirName)) + || Directory.Exists(Path.Combine(installDir, StagingDirName)); + } + } + public async Task InstallAsync( RemoteRelease release, IProgress? progress = null, diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateCleanup.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateCleanup.cs new file mode 100644 index 0000000..c741503 --- /dev/null +++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateCleanup.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console; + +namespace NextIteration.SpectreConsole.SelfUpdate +{ + /// + /// One-call helper for the startup cleanup of a previous update's + /// .old/ and .update/ leftovers. Call + /// at the very start of Main in place of a bare + /// call. + /// + /// The delete is synchronous and — under OneDrive / antivirus / Windows + /// Search contention — can take seconds (see the retry/backoff path in + /// the installer). Without feedback the app looks hung. This helper shows + /// a status message while it works, but only when there is actually + /// something to clean (); + /// the common no-leftovers case stays completely silent. + /// + /// + public static class UpdateCleanup + { + /// + /// Status text shown while leftover update state is being removed. + /// + public const string DefaultMessage = "Cleaning up previous update…"; + + /// + /// Resolve from the supplied service + /// provider and run the startup cleanup, showing + /// only when leftover state exists. + /// + /// DI container holding the registered . + /// + /// Optional console override. When , an + /// registered in + /// is used; failing that, . + /// + public static void Run(IServiceProvider services, IAnsiConsole? console = null) + { + ArgumentNullException.ThrowIfNull(services); + var installer = services.GetRequiredService(); + Run(installer, console ?? services.GetService() ?? AnsiConsole.Console); + } + + /// + /// Run the startup cleanup against an explicit installer and console. + /// Shows only when + /// is + /// ; otherwise cleans silently. + /// + /// The installer to clean up. + /// Console used to render the status message. + public static void Run(IUpdateInstaller installer, IAnsiConsole console) + { + ArgumentNullException.ThrowIfNull(installer); + ArgumentNullException.ThrowIfNull(console); + + if (!installer.HasPendingCleanup) + { + installer.CleanupOldInstall(); // nothing to clean — stay silent + return; + } + + // The spinner communicates "in progress" while the synchronous, + // retry-backed delete runs, then clears when it returns. + console.Status().Start(DefaultMessage, _ => installer.CleanupOldInstall()); + } + } +} diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs index ce13dd1..dc88aa2 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/Stubs.cs @@ -56,12 +56,16 @@ internal sealed class StubUpdateInstaller : IUpdateInstaller { public string InstallDirectory { get; set; } = "/tmp/install"; + public bool HasPendingCleanup { get; set; } + + public int CleanupCallCount { get; private set; } + public Task InstallAsync( RemoteRelease release, IProgress? progress = null, Func>? onConflict = null, CancellationToken ct = default) => Task.CompletedTask; - public void CleanupOldInstall() { } + public void CleanupOldInstall() => CleanupCallCount++; } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index 53d33f9..a8cde36 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -168,6 +168,42 @@ public void CleanupOldInstall_when_old_directory_missing_is_noop() installer.CleanupOldInstall(); // does not throw } + [Fact] + public void HasPendingCleanup_when_neither_directory_exists_false() + { + using var work = new TempDir(); + var installDir = Path.Combine(work.Path, "install"); + Directory.CreateDirectory(installDir); + + var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); + + Assert.False(installer.HasPendingCleanup); + } + + [Fact] + public void HasPendingCleanup_when_old_directory_exists_true() + { + using var work = new TempDir(); + var installDir = Path.Combine(work.Path, "install"); + Directory.CreateDirectory(Path.Combine(installDir, ".old")); + + var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); + + Assert.True(installer.HasPendingCleanup); + } + + [Fact] + public void HasPendingCleanup_when_update_directory_exists_true() + { + using var work = new TempDir(); + var installDir = Path.Combine(work.Path, "install"); + Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); + + var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); + + Assert.True(installer.HasPendingCleanup); + } + [Fact] public async Task InstallAsync_when_lock_file_held_throws() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs new file mode 100644 index 0000000..fdfd049 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; + +using NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure; + +using Spectre.Console; +using Spectre.Console.Testing; + +using Xunit; + +namespace NextIteration.SpectreConsole.SelfUpdate.Tests +{ + public sealed class UpdateCleanupTests + { + [Fact] + public void Run_when_no_pending_cleanup_writes_nothing_and_cleans() + { + var installer = new StubUpdateInstaller { HasPendingCleanup = false }; + var console = new TestConsole(); + + UpdateCleanup.Run(installer, console); + + Assert.Equal(1, installer.CleanupCallCount); + Assert.Equal(string.Empty, console.Output); + } + + [Fact] + public void Run_when_pending_cleanup_renders_message_and_cleans() + { + var installer = new StubUpdateInstaller { HasPendingCleanup = true }; + var console = new TestConsole(); + + UpdateCleanup.Run(installer, console); + + Assert.Equal(1, installer.CleanupCallCount); + Assert.Contains("Cleaning up previous update", console.Output, StringComparison.Ordinal); + } + + [Fact] + public void Run_via_service_provider_resolves_installer_and_console() + { + var installer = new StubUpdateInstaller { HasPendingCleanup = true }; + var console = new TestConsole(); + var services = new ServiceCollection(); + services.AddSingleton(installer); + services.AddSingleton(console); + using var sp = services.BuildServiceProvider(); + + UpdateCleanup.Run(sp); + + Assert.Equal(1, installer.CleanupCallCount); + Assert.Contains("Cleaning up previous update", console.Output, StringComparison.Ordinal); + } + + [Fact] + public void Run_with_null_services_throws() + { + Assert.Throws(() => UpdateCleanup.Run((IServiceProvider)null!)); + } + + [Fact] + public void Run_with_null_installer_throws() + { + Assert.Throws(() => UpdateCleanup.Run((IUpdateInstaller)null!, new TestConsole())); + } + + [Fact] + public void Run_with_null_console_throws() + { + Assert.Throws(() => UpdateCleanup.Run(new StubUpdateInstaller(), null!)); + } + } +}