diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 6bdbdba6cb..d1a158ddad 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -24,8 +24,18 @@ public sealed class CreateWslInstanceStep : SetupStep TimeSpan.FromSeconds(90), ]; + private readonly record struct FreshDistroVerification(StepResult Result, bool PreserveRegistration); + + private enum FreshDistroListOutcome + { + Registered, + CompletedWithoutDistro, + ListDidNotComplete, + } + private readonly IWslRegistrationInspector _registrationInspector; private bool _createdRegistrationThisAttempt; + private bool _preserveRegistrationOnRollback; public override string Id => "wsl-create"; public override string DisplayName => "Create WSL instance"; @@ -43,6 +53,7 @@ internal CreateWslInstanceStep(IWslRegistrationInspector registrationInspector) public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { + _preserveRegistrationOnRollback = false; _createdRegistrationThisAttempt = false; var distro = ctx.DistroName!; var baseDistro = ctx.Config.BaseDistro.Trim(); @@ -109,13 +120,19 @@ or ArgumentException await GatewayPackageCleanup.RegisterAsync(ctx.Commands, distro, installPath, ct: ct); 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}"); + _preserveRegistrationOnRollback = verify.PreserveRegistration; + 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) @@ -145,15 +162,22 @@ 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) { // wsl.exe can stall on --list right after --install while the service finishes // registering the new distro, so retry with escalating timeouts like the root probe. - if (!await IsDistroRegisteredAsync(ctx, distro, ct)) + // A timed-out or failed list is not proof the new distro is absent. + var registration = await ProbeFreshDistroRegistrationAsync(ctx, distro, ct); + if (registration != FreshDistroListOutcome.Registered) { 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); + var listNeverCompleted = registration == FreshDistroListOutcome.ListDidNotComplete; + var baseMessage = listNeverCompleted + ? $"Fresh WSL install could not list distros after installing '{distro}'." + : $"Fresh WSL install did not register expected distro '{distro}'."; + return new FreshDistroVerification( + StepResult.Fail(environmentIssue != null ? $"{baseMessage} {environmentIssue}" : baseMessage), + PreserveRegistration: listNeverCompleted); } var verbose = await ctx.Commands.RunAsyncAllowingInheritedPipeHandleEscape( @@ -162,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++) @@ -194,14 +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 IsDistroRegisteredAsync(SetupContext ctx, string distro, CancellationToken ct) + private static async Task ProbeFreshDistroRegistrationAsync( + SetupContext ctx, + string distro, + CancellationToken ct) { + var sawCompletedOmission = false; for (var attempt = 0; attempt < FreshDistroProbeTimeouts.Length; attempt++) { var list = await ctx.Commands.RunAsyncAllowingInheritedPipeHandleEscape( @@ -209,8 +245,12 @@ private static async Task IsDistroRegisteredAsync(SetupContext ctx, string ["--list", "--quiet"], FreshDistroProbeTimeouts[attempt], ct: ct); - if (list.ExitCode == 0 && WslInstallSupport.ContainsDistro(list.Stdout, distro)) - return true; + var listCompleted = list.ExitCode == 0 && !list.TimedOut; + if (listCompleted && WslInstallSupport.ContainsDistro(list.Stdout, distro)) + return FreshDistroListOutcome.Registered; + + if (listCompleted) + sawCompletedOmission = true; if (attempt < FreshDistroProbeTimeouts.Length - 1) { @@ -220,10 +260,17 @@ private static async Task IsDistroRegisteredAsync(SetupContext ctx, string } } - return false; + return sawCompletedOmission + ? FreshDistroListOutcome.CompletedWithoutDistro + : FreshDistroListOutcome.ListDidNotComplete; } - private async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) + private 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); @@ -236,7 +283,19 @@ private async Task CleanupPartialInstall(SetupContext ctx, string distro var distroExists = registrationStateKnown && WslInstallSupport.ContainsDistro(list.Stdout, distro); var canDeleteInstallPath = registrationStateKnown && !distroExists; - if (ctx.IsUninstalling) + if (!ctx.IsUninstalling && 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 (ctx.IsUninstalling) { if (!(registrationStateKnown && !distroExists)) canDeleteInstallPath = await TryUnregisterPartialInstall(ctx, distro, cleanupErrors, ct); @@ -356,7 +415,9 @@ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) return; } - 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 38738726aa..5f766929b3 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -3474,6 +3474,275 @@ public async Task CreateWslInstance_RetriesTransientFreshDistroListTimeout() 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(30), 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(30), + TimeSpan.FromSeconds(60), + ], + 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(30), TimedOut: true), + 3 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(60), 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 step = new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)); + + var result = await step.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(30), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(90), + TimeSpan.FromSeconds(15), + ], + commands.TimedCalls + .Where(call => call.Arguments.SequenceEqual(["--list", "--quiet"])) + .Select(call => call.Timeout) + .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(30), TimedOut: true), + 3 => new CommandResult(-1, "", "", TimeSpan.FromSeconds(60), 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(FakeWslRegistrationInspector.Found(installPath)), + ]); + + 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++; + if (quietLists <= 4) + return Ok(""); + if (quietLists == 5) + return Fail("list failed"); + + return Ok("OpenClawGateway\n"); + } + + 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 installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + var pipeline = new SetupPipeline([ + new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)), + ]); + + 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.DoesNotContain("could not list distros", result.Message); + Assert.DoesNotContain("left distro", 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() + { + var quietLists = 0; + var commands = new FakeCommandRunner(args => + { + if (args.SequenceEqual(["--list", "--quiet"])) + { + quietLists++; + return quietLists < 5 ? Ok("") : Ok("OpenClawGateway\n"); + } + + 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 installPath = Path.Combine(ctx.LocalDataDir, "wsl", "OpenClawGateway"); + + var result = await new CreateWslInstanceStep(FakeWslRegistrationInspector.Found(installPath)) + .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.DoesNotContain("left distro", result.Message); + Assert.Equal(5, quietLists); + Assert.Contains(commands.Calls, call => call.Arguments.SequenceEqual(["--unregister", "OpenClawGateway"])); + Assert.Contains(commands.Calls, call => call.Arguments.SequenceEqual(["--terminate", "OpenClawGateway"])); + Assert.DoesNotContain(commands.Calls, call => call.Arguments.SequenceEqual(["--list", "--verbose"])); + Assert.Equal( + [ + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(90), + TimeSpan.FromSeconds(15), + ], + commands.TimedCalls + .Where(call => call.Arguments.SequenceEqual(["--list", "--quiet"])) + .Select(call => call.Timeout) + .ToArray()); + } + [Fact] public async Task CreateWslInstance_BoundsPersistentFreshDistroRootProbeTimeouts() {