The update pipeline moved app/ and worker/ to .bak before extraction with no retry, so a just-killed worker whose file handles had not been released yet (WaitForExit returns before the OS flushes them) caused Directory.Move to throw ERROR_SHARING_VIOLATION, stopping the pipeline on a cryptic error screen. - DownloadAndExtractStep: stash/rollback moves+deletes now retry through transient IO/access errors (~5s); a persistent lock returns an actionable message instead of the raw error. - StopWorkerStep: reading MainModule no longer skips Kill on failure, and a short settle follows the kill so handles are released before extraction.
64 lines
2.6 KiB
C#
64 lines
2.6 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using ClaudeDo.Installer.Core;
|
|
|
|
namespace ClaudeDo.Installer.Steps;
|
|
|
|
public sealed class StopWorkerStep : IInstallStep
|
|
{
|
|
public const string LegacyTaskName = "ClaudeDoWorker";
|
|
|
|
// Both must be stopped before the install dir is touched: a running app/worker
|
|
// exe locks its directory, so Directory.Move during extraction would otherwise
|
|
// fail with "Access to the path '...\app' is denied".
|
|
private static readonly string[] ProcessNames = { "ClaudeDo.Worker", "ClaudeDo.App" };
|
|
|
|
public string Name => "Stop Worker";
|
|
|
|
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
|
{
|
|
progress.Report("Stopping ClaudeDo processes (if running)...");
|
|
var installDir = ctx.InstallDirectory;
|
|
var killedAny = false;
|
|
foreach (var name in ProcessNames)
|
|
{
|
|
foreach (var p in Process.GetProcessesByName(name))
|
|
{
|
|
try
|
|
{
|
|
// Scope to THIS install when the module path is readable; if it
|
|
// can't be read (access race / exiting process), fall through and
|
|
// kill anyway — a survivor would lock the install dir during
|
|
// extraction. Reading MainModule must not skip the Kill.
|
|
string? path = null;
|
|
try { path = p.MainModule?.FileName; } catch { /* unreadable — kill anyway */ }
|
|
if (path is not null && !IsUnder(path, installDir)) continue;
|
|
p.Kill(entireProcessTree: true);
|
|
p.WaitForExit(10000);
|
|
killedAny = true;
|
|
}
|
|
catch { /* process may have exited or be inaccessible */ }
|
|
finally { p.Dispose(); }
|
|
}
|
|
}
|
|
|
|
// WaitForExit returns before the OS releases the process's file handles.
|
|
// Give it a moment so DownloadAndExtractStep's Directory.Move doesn't race
|
|
// a still-open handle. (That step also retries, this just avoids the churn.)
|
|
if (killedAny) await Task.Delay(1500, ct);
|
|
return StepResult.Ok();
|
|
}
|
|
|
|
private static bool IsUnder(string filePath, string dir)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
|
|
var full = Path.GetFullPath(filePath);
|
|
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
}
|