From 54d2dd6471e91dc8d6cb05c46ac24667ab07b6fe Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 13:45:30 -0700 Subject: [PATCH 1/2] fix(setup): keep a fresh WSL distro when the post-install list does not finish A timed-out or failed wsl --list --quiet after a successful install was treated as an unregistered distro. Cleanup then unregistered a distro that terminate and unregister both accepted. Retry that list on timeout or a non-zero exit, and leave the distro registered when every attempt fails. A completed list that omits the name still reports that the distro did not register. Install-failure cleanup and rollback still attempt unregister. Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 99 +++++++++-- .../SetupStepsTests.cs | 157 ++++++++++++++++++ 2 files changed, 238 insertions(+), 18 deletions(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 68dd1c0ba..807e7ee65 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -17,6 +17,12 @@ namespace OpenClaw.SetupEngine; public sealed class CreateWslInstanceStep : SetupStep { private static readonly TimeSpan DistroVersionVerificationTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan[] FreshDistroListTimeouts = + [ + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + ]; private static readonly TimeSpan[] FreshDistroProbeTimeouts = [ TimeSpan.FromSeconds(30), @@ -24,6 +30,8 @@ public sealed class CreateWslInstanceStep : SetupStep TimeSpan.FromSeconds(90), ]; + private readonly record struct FreshDistroVerification(StepResult Result, bool PreserveRegistration); + public override string Id => "wsl-create"; public override string DisplayName => "Create WSL instance"; public override bool CanRetry => false; @@ -92,13 +100,18 @@ or ArgumentException } var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); - if (!verify.IsSuccess) + if (!verify.Result.IsSuccess) { - var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); - return StepResult.Fail($"{verify.Message}{cleanupError}"); + var cleanupError = await CleanupPartialInstall( + ctx, + distro, + installPath, + ct, + verify.PreserveRegistration); + return StepResult.Fail($"{verify.Result.Message}{cleanupError}"); } - return verify; + return verify.Result; } private static StepResult EnsureInstallPathReady(string installPath) @@ -128,18 +141,43 @@ private static StepResult EnsureInstallPathReady(string installPath) return StepResult.Ok(); } - private static async Task VerifyFreshDistro(SetupContext ctx, string distro, string installPath, CancellationToken ct) + private static async Task VerifyFreshDistro(SetupContext ctx, string distro, string installPath, CancellationToken ct) { - var list = await ctx.Commands.RunAsyncAllowingInheritedPipeHandleEscape( - WslConstants.WslExePath, - ["--list", "--quiet"], - TimeSpan.FromSeconds(15), - ct: ct); - if (list.ExitCode != 0 || !WslInstallSupport.ContainsDistro(list.Stdout, distro)) + CommandResult? list = null; + for (var attempt = 0; attempt < FreshDistroListTimeouts.Length; attempt++) + { + list = await ctx.Commands.RunAsyncAllowingInheritedPipeHandleEscape( + WslConstants.WslExePath, + ["--list", "--quiet"], + FreshDistroListTimeouts[attempt], + ct: ct); + if (list.ExitCode == 0 && !list.TimedOut) + break; + + if (attempt < FreshDistroListTimeouts.Length - 1) + { + ctx.Logger.Warn( + $"Fresh WSL distro '{distro}' registration list was not ready " + + $"(attempt {attempt + 1}/{FreshDistroListTimeouts.Length}, exit {list.ExitCode}); retrying."); + } + } + + if (list is not { ExitCode: 0, TimedOut: false } completedList) + { + var environmentIssue = await PreflightWslStep.DetectEnvironmentIssueAsync(ctx, ct); + var baseMessage = $"Fresh WSL install could not list distros after installing '{distro}'."; + return new FreshDistroVerification( + StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage), + PreserveRegistration: true); + } + + if (!WslInstallSupport.ContainsDistro(completedList.Stdout, distro)) { var environmentIssue = await PreflightWslStep.DetectEnvironmentIssueAsync(ctx, ct); var baseMessage = $"Fresh WSL install did not register expected distro '{distro}'."; - return StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage); + return new FreshDistroVerification( + StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage), + PreserveRegistration: false); } var verbose = await ctx.Commands.RunAsyncAllowingInheritedPipeHandleEscape( @@ -148,10 +186,14 @@ private static async Task VerifyFreshDistro(SetupContext ctx, string DistroVersionVerificationTimeout, ct: ct); if (verbose.ExitCode != 0 || !WslInstallSupport.TryGetDistroVersion(verbose.Stdout, distro, out var version)) - return StepResult.Fail($"Fresh WSL install registered '{distro}', but setup could not verify it is WSL2."); + return new FreshDistroVerification( + StepResult.Fail($"Fresh WSL install registered '{distro}', but setup could not verify it is WSL2."), + PreserveRegistration: false); if (version != 2) - return StepResult.Fail($"Fresh WSL install registered '{distro}' as WSL{version}; WSL2 is required."); + return new FreshDistroVerification( + StepResult.Fail($"Fresh WSL install registered '{distro}' as WSL{version}; WSL2 is required."), + PreserveRegistration: false); CommandResult? probe = null; for (var attempt = 0; attempt < FreshDistroProbeTimeouts.Length; attempt++) @@ -180,13 +222,22 @@ private static async Task VerifyFreshDistro(SetupContext ctx, string || !probe.Stdout.Contains("OPENCLAW_FRESH_WSL_READY", StringComparison.Ordinal)) { var detail = probe is null ? "no output" : FirstNonEmpty(probe.Stderr, probe.Stdout); - return StepResult.Fail($"Fresh WSL distro '{distro}' could not run a root verification command: {detail}"); + return new FreshDistroVerification( + StepResult.Fail($"Fresh WSL distro '{distro}' could not run a root verification command: {detail}"), + PreserveRegistration: false); } - return StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"); + return new FreshDistroVerification( + StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"), + PreserveRegistration: false); } - private static async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) + private static async Task CleanupPartialInstall( + SetupContext ctx, + string distro, + string installPath, + CancellationToken ct, + bool preserveRegistration = false) { var cleanupErrors = new List(); var installPathExists = Directory.Exists(installPath) || File.Exists(installPath); @@ -199,7 +250,19 @@ private static async Task CleanupPartialInstall(SetupContext ctx, string var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); var canDeleteInstallPath = registrationStateKnown && !distroExists; - if (!registrationStateKnown) + if (preserveRegistration && (!registrationStateKnown || distroExists)) + { + ctx.Logger.Warn( + $"Leaving WSL distro '{distro}' in place because the post-install distro list did not complete."); + if (distroExists) + { + cleanupErrors.Add( + $"left distro '{distro}' registered because the post-install distro list did not complete"); + } + + canDeleteInstallPath = false; + } + else if (!registrationStateKnown) { ctx.Logger.Warn($"Partial install cleanup could not list WSL distros (exit {list.ExitCode}); attempting best-effort unregister for '{distro}' before deleting app-owned files"); canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 5070515d4..b295f9386 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3306,6 +3306,163 @@ public async Task CreateWslInstance_RetriesTransientFreshDistroRootProbeTimeout( Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); } + [Fact] + public async Task CreateWslInstance_RetriesTimedOutFreshDistroListAndContinues() + { + var installed = false; + var quietLists = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + if (quietLists == 1) + return Ok(""); + if (quietLists == 2) + return new CommandResult(-1, "", "", TimeSpan.FromSeconds(15), TimedOut: true); + + return Ok("OpenClawGateway\n"); + } + + if (args.Contains("--install")) + { + installed = true; + return Ok("Installing Ubuntu-24.04\n"); + } + + if (args.SequenceEqual(["--list", "--verbose"])) + return Ok(" NAME STATE VERSION\n* OpenClawGateway Stopped 2\n"); + if (args.SequenceEqual(["-d", "OpenClawGateway", "-u", "root", "--", "sh", "-lc", "id -u && test -d / && echo OPENCLAW_FRESH_WSL_READY"])) + return Ok("0\nOPENCLAW_FRESH_WSL_READY\n"); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + + var result = await new CreateWslInstanceStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.True(installed); + Assert.Equal(3, quietLists); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + var installCall = Assert.Single(commands.Calls, call => call.Arguments.Contains("--install")); + Assert.Contains("--no-launch", installCall.Arguments); + Assert.Equal( + [ + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + ], + commands.TimedCalls + .Where(call => call.Arguments.SequenceEqual(["--list", "--quiet"])) + .Select(call => call.Timeout) + .ToArray()); + } + + [Fact] + public async Task CreateWslInstance_LeavesDistroRegisteredWhenFreshDistroListNeverCompletes() + { + var quietLists = 0; + var installPath = ""; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + return quietLists switch + { + 1 => Ok(""), + 2 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(15), TimedOut: true), + 3 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(15), TimedOut: false), + 4 => Fail("list failed"), + _ => Ok("OpenClawGateway\n"), + }; + } + + if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(Path.Combine(installPath, "ext4.vhdx"), "partial"); + return Ok("Installing Ubuntu-24.04\n"); + } + + if (args.SequenceEqual(["--status"])) + return Ok("Default Version: 2\n"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + + var result = await new CreateWslInstanceStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("could not list distros after installing 'OpenClawGateway'", result.Message); + Assert.DoesNotContain("did not register expected distro", result.Message); + Assert.Contains("left distro 'OpenClawGateway' registered", result.Message); + Assert.Contains("skipped deleting app-owned install path", result.Message); + Assert.Equal(5, quietLists); + Assert.True(File.Exists(Path.Combine(installPath, "ext4.vhdx"))); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + Assert.Equal( + [ + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(15), + ], + commands.TimedCalls + .Where(call => call.Arguments.SequenceEqual(["--list", "--quiet"])) + .Select(call => call.Timeout) + .ToArray()); + } + + [Fact] + public async Task CreateWslInstance_ReportsMissingDistroWhenFreshListOmitsIt() + { + var quietLists = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + return Ok(""); + } + + if (args.Contains("--install")) + return Ok("Installing Ubuntu-24.04\n"); + if (args.SequenceEqual(["--status"])) + return Ok("Default Version: 2\n"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext(commands: commands); + + var result = await new CreateWslInstanceStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("did not register expected distro 'OpenClawGateway'", result.Message); + Assert.DoesNotContain("could not list distros", result.Message); + Assert.Equal(3, quietLists); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.SequenceEqual(["--list", "--verbose"])); + Assert.Equal( + [TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15)], + commands.TimedCalls + .Where(call => call.Arguments.SequenceEqual(["--list", "--quiet"])) + .Select(call => call.Timeout) + .ToArray()); + } + [Fact] public async Task CreateWslInstance_BoundsPersistentFreshDistroRootProbeTimeouts() { From f7f2ce9a3d8419feb1e59f1dcaa538cfe13f0112 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 13:29:41 -0700 Subject: [PATCH 2/2] fix(setup): keep a fresh WSL distro through failed-step rollback When every post-install list fails, immediate cleanup leaves the distro registered. End-user setup then rolls that step back, and the second cleanup unregistered it. Rollback now reuses the same preservation decision. A completed list that omits the name still unregisters on rollback. SetupPipeline_PreservesFreshDistroWhenPostInstallListFailsAndRollbackRuns and SetupPipeline_UnregistersDistroOnRollbackWhenPostInstallListCompleted cover both paths. Validation: ./build.ps1 exit 0. Shared 4169 passed, 33 skipped. Tray 3184 passed. CreateWslInstance 18 passed. Signed-off-by: Sebastien Tardif --- .../CreateWslInstanceStep.cs | 8 +- .../SetupStepsTests.cs | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 807e7ee65..fb077b172 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -32,12 +32,15 @@ public sealed class CreateWslInstanceStep : SetupStep private readonly record struct FreshDistroVerification(StepResult Result, bool PreserveRegistration); + private bool _preserveRegistrationOnRollback; + public override string Id => "wsl-create"; public override string DisplayName => "Create WSL instance"; public override bool CanRetry => false; public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { + _preserveRegistrationOnRollback = false; var distro = ctx.DistroName!; var baseDistro = ctx.Config.BaseDistro.Trim(); @@ -102,6 +105,7 @@ or ArgumentException var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); if (!verify.Result.IsSuccess) { + _preserveRegistrationOnRollback = verify.PreserveRegistration; var cleanupError = await CleanupPartialInstall( ctx, distro, @@ -348,7 +352,9 @@ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) if (!DistroInstallPathPolicy.TryGetManagedInstallPath(ctx.LocalDataDir, distro, out var vhdDir, out var pathError)) throw new IOException($"[Uninstall] Refusing WSL rollback filesystem cleanup: {pathError}"); - var cleanupError = await CleanupPartialInstall(ctx, distro, vhdDir, ct); + var preserveRegistration = _preserveRegistrationOnRollback; + _preserveRegistrationOnRollback = false; + var cleanupError = await CleanupPartialInstall(ctx, distro, vhdDir, ct, preserveRegistration); if (cleanupError.Length > 0) throw new IOException($"[Uninstall] Refusing unsafe WSL rollback cleanup.{cleanupError}"); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index b295f9386..24620d962 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3423,6 +3423,97 @@ public async Task CreateWslInstance_LeavesDistroRegisteredWhenFreshDistroListNev .ToArray()); } + [Fact] + public async Task SetupPipeline_PreservesFreshDistroWhenPostInstallListFailsAndRollbackRuns() + { + var quietLists = 0; + var installPath = ""; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + return quietLists switch + { + 1 => Ok(""), + 2 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(15), TimedOut: true), + 3 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(15), TimedOut: false), + 4 => Fail("list failed"), + _ => Ok("OpenClawGateway\n"), + }; + } + + if (args.Contains("--install")) + { + Directory.CreateDirectory(installPath); + File.WriteAllText(Path.Combine(installPath, "ext4.vhdx"), "partial"); + return Ok("Installing Ubuntu-24.04\n"); + } + + if (args.SequenceEqual(["--status"])) + return Ok("Default Version: 2\n"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext( + new SetupConfig { RollbackOnFailure = true }, + commands); + installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + var pipeline = new SetupPipeline([new CreateWslInstanceStep()]); + + var result = await pipeline.RunAsync(ctx); + + Assert.Equal(PipelineOutcome.Failed, result.Outcome); + Assert.Equal("wsl-create", result.FailedStepId); + Assert.Contains("could not list distros after installing 'OpenClawGateway'", result.Message); + Assert.Contains("left distro 'OpenClawGateway' registered", result.Message); + Assert.True(File.Exists(Path.Combine(installPath, "ext4.vhdx"))); + Assert.Equal(6, quietLists); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--unregister")); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.Contains("--terminate")); + } + + [Fact] + public async Task SetupPipeline_UnregistersDistroOnRollbackWhenPostInstallListCompleted() + { + var quietLists = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + return quietLists == 4 + ? Ok("OpenClawGateway\n") + : Ok(""); + } + + if (args.Contains("--install")) + return Ok("Installing Ubuntu-24.04\n"); + if (args.SequenceEqual(["--status"])) + return Ok("Default Version: 2\n"); + if (args.SequenceEqual(["--terminate", "OpenClawGateway"]) || + args.SequenceEqual(["--unregister", "OpenClawGateway"])) + return Ok(); + + return Fail($"unexpected args: {string.Join(' ', args)}"); + }); + var ctx = CreateContext( + new SetupConfig { RollbackOnFailure = true }, + commands); + var pipeline = new SetupPipeline([new CreateWslInstanceStep()]); + + var result = await pipeline.RunAsync(ctx); + + Assert.Equal(PipelineOutcome.Failed, result.Outcome); + Assert.Equal("wsl-create", result.FailedStepId); + Assert.Contains("did not register expected distro 'OpenClawGateway'", result.Message); + Assert.Contains(commands.Calls, call => call.Arguments.SequenceEqual(["--unregister", "OpenClawGateway"])); + Assert.Contains(commands.Calls, call => call.Arguments.SequenceEqual(["--terminate", "OpenClawGateway"])); + } + [Fact] public async Task CreateWslInstance_ReportsMissingDistroWhenFreshListOmitsIt() {