diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index 71d18f2..99ada5b 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -56,4 +56,40 @@ public static partial class SelfUpdater InstallerAsset: match.Asset, ChecksumsAsset: checksums); } + + public static async Task HandleReplaceSelfAsync( + string oldPath, + string currentExePath, + Func launchProcess, + int maxWaitMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs); + while (DateTime.UtcNow < deadline) + { + try + { + if (File.Exists(oldPath)) + { + File.Delete(oldPath); + } + break; + } + catch (IOException) + { + await Task.Delay(100); + } + catch (UnauthorizedAccessException) + { + await Task.Delay(100); + } + } + + if (File.Exists(oldPath)) + { + return false; + } + + File.Copy(currentExePath, oldPath, overwrite: false); + return launchProcess(oldPath); + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index efb46de..62c33f8 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -124,3 +124,56 @@ public class SelfUpdaterDecisionTests Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } } + +public class SelfUpdaterReplaceSelfTests : IDisposable +{ + private readonly string _tempDir; + + public SelfUpdaterReplaceSelfTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } + + [Fact] + public async Task Replace_DeletesOldAndCopiesCurrent() + { + var oldPath = Path.Combine(_tempDir, "old.exe"); + var currentPath = Path.Combine(_tempDir, "current.exe"); + await File.WriteAllTextAsync(oldPath, "OLD"); + await File.WriteAllTextAsync(currentPath, "NEW"); + + var relaunchedWith = ""; + var result = await SelfUpdater.HandleReplaceSelfAsync( + oldPath: oldPath, + currentExePath: currentPath, + launchProcess: path => { relaunchedWith = path; return true; }, + maxWaitMs: 500); + + Assert.True(result); + Assert.Equal(oldPath, relaunchedWith); + Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath)); + } + + [Fact] + public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse() + { + var oldPath = Path.Combine(_tempDir, "locked.exe"); + var currentPath = Path.Combine(_tempDir, "current.exe"); + await File.WriteAllTextAsync(oldPath, "OLD"); + await File.WriteAllTextAsync(currentPath, "NEW"); + + // Hold an exclusive lock across the wait window. + using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var result = await SelfUpdater.HandleReplaceSelfAsync( + oldPath: oldPath, + currentExePath: currentPath, + launchProcess: _ => true, + maxWaitMs: 200); + + Assert.False(result); + } +}