From 302ce2a18006e74f02230289b69452dd9f6dd6bd Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:09:34 -0700 Subject: [PATCH 1/4] fix(setup): release the setup lock only after rollback finishes Signed-off-by: Sebastien Tardif --- .../Pages/ProgressPage.xaml.cs | 8 ++- .../SetupWindow.xaml.cs | 12 +++- src/OpenClaw.SetupEngine/SetupCloseCleanup.cs | 24 +++++++ .../SetupCloseCleanupTests.cs | 64 +++++++++++++++++++ .../AppRefactorContractTests.cs | 5 +- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/SetupCloseCleanup.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs index d914e5a48d..c78001e597 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs @@ -200,7 +200,11 @@ private async Task StartPipelineAsync() _pipeline = new SetupPipeline(steps); _pipeline.StepProgress += OnStepProgress; - var result = await Task.Run(() => _pipeline.RunAsync(ctx), cts.Token); + // Do not pass the setup token to Task.Run. Cancel must not complete this + // task before RunAsync finishes its CancellationToken.None rollback. + var pipelineTask = Task.Run(() => _pipeline.RunAsync(ctx)); + SetupWindow.Active?.AttachProgressPipeline(pipelineTask); + var result = await pipelineTask; sw.Stop(); _pipelineFinished = true; @@ -265,7 +269,7 @@ private async Task StartPipelineAsync() } } - private void CancelPipeline() + internal void CancelPipeline() { if (!_pipelineFinished) _runCts?.Cancel(); diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index 0b223c711f..6aa5e40af9 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -18,6 +18,7 @@ public sealed partial class SetupWindow : Window private SetupRunLock? _setupLock; private readonly CancellationTokenSource _lifetimeCts = new(); private Task? _contextApplyTask; + private Task? _progressPipelineTask; private readonly TaskCompletionSource _initialContentReady = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _cleanupCompleted = @@ -82,8 +83,9 @@ public SetupWindow( try { _lifetimeCts.Cancel(); - if (_contextApplyTask is { } contextApplyTask) - await contextApplyTask; + if (RootFrame.Content is ProgressPage progressPage) + progressPage.CancelPipeline(); + await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask); } catch (OperationCanceledException) { @@ -316,6 +318,12 @@ public bool TryNavigateToWizard(bool back = false) return true; } + internal void AttachProgressPipeline(Task progressPipelineTask) + { + ArgumentNullException.ThrowIfNull(progressPipelineTask); + _progressPipelineTask = progressPipelineTask; + } + internal async Task ApplyWindowsNodeContextAsync() { if (_contextApplyTask is { } existingTask) diff --git a/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs b/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs new file mode 100644 index 0000000000..d5553f5856 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs @@ -0,0 +1,24 @@ +namespace OpenClaw.SetupEngine; + +internal static class SetupCloseCleanup +{ + public static async Task WaitForRunningWorkAsync(Task? contextApplyTask, Task? progressPipelineTask) + { + if (contextApplyTask is null && progressPipelineTask is null) + return; + + if (contextApplyTask is null) + { + await progressPipelineTask!.ConfigureAwait(false); + return; + } + + if (progressPipelineTask is null) + { + await contextApplyTask.ConfigureAwait(false); + return; + } + + await Task.WhenAll(contextApplyTask, progressPipelineTask).ConfigureAwait(false); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs new file mode 100644 index 0000000000..faec8c4f09 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs @@ -0,0 +1,64 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class SetupCloseCleanupTests : IDisposable +{ + private readonly string _tempDir; + + public SetupCloseCleanupTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"setup-close-cleanup-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + [Fact] + public async Task Close_ReleasesSetupLockOnlyAfterProgressRollbackFinishes() + { + Assert.True(SetupRunLock.TryAcquire(_tempDir, out var held, out var message)); + Assert.Null(message); + + var rollback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanup = Task.Run(async () => + { + try + { + await SetupCloseCleanup.WaitForRunningWorkAsync(Task.CompletedTask, rollback.Task); + } + finally + { + held!.Dispose(); + } + }); + + var finishedEarly = await Task.WhenAny(cleanup, Task.Delay(TimeSpan.FromMilliseconds(200))); + Assert.NotSame(cleanup, finishedEarly); + Assert.False(SetupRunLock.TryAcquire(_tempDir, out var stolen, out _)); + stolen?.Dispose(); + + rollback.SetResult(); + await cleanup.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(SetupRunLock.TryAcquire(_tempDir, out var next, out var nextMessage)); + Assert.Null(nextMessage); + next?.Dispose(); + } + + [Fact] + public async Task WaitForRunningWork_StillWaitsForPipelineWhenContextFaults() + { + var rollback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var context = Task.FromException(new InvalidOperationException("context failed")); + var waiting = SetupCloseCleanup.WaitForRunningWorkAsync(context, rollback.Task); + + var finishedEarly = await Task.WhenAny(waiting, Task.Delay(TimeSpan.FromMilliseconds(200))); + Assert.NotSame(waiting, finishedEarly); + + rollback.SetResult(); + await Assert.ThrowsAnyAsync(() => waiting); + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 7d72af69cd..9742c96550 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -1108,7 +1108,7 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() AssertInOrder( setupWindow, "_lifetimeCts.Cancel()", - "await contextApplyTask", + "await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask)", "_setupLock?.Dispose()"); Assert.Contains("distroNameOverride: _config.DistroName", wizardPage); Assert.Contains("if (AppIdentity.IsDev)", updateCoordinator); @@ -1272,7 +1272,8 @@ public void SetupWindowOwnership_WaitsForCleanupBeforeAllowingAnotherRun() AssertInOrder( setupWindow, "_lifetimeCts.Cancel()", - "await contextApplyTask", + "progressPage.CancelPipeline()", + "await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask)", "catch (OperationCanceledException)", "_setupLock?.Dispose()", "_cleanupCompleted.TrySetResult(true)"); From 244354c54f3045e6497ccef2bd0176baf1b83f6f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 11:40:20 -0700 Subject: [PATCH 2/4] fix(setup): run close teardown when the pipeline wait faults A faulted context or pipeline task used to leave the close handler before page disposal, secret clearing, and native-session release. The wait now sits in a finally, so those steps still run, and setup.lock is released only after they finish. SetupCloseCleanupTests 3 passed. build.ps1 exit 0. Shared 4263 passed, 33 skipped. Tray 3996 passed. Signed-off-by: Sebastien Tardif --- .../SetupWindow.xaml.cs | 71 +++++++++---------- src/OpenClaw.SetupEngine/SetupCloseCleanup.cs | 17 +++++ .../SetupCloseCleanupTests.cs | 35 +++++++++ .../AppRefactorContractTests.cs | 7 +- 4 files changed, 91 insertions(+), 39 deletions(-) diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index 5ab57ff466..30666efd96 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -176,50 +176,47 @@ public SetupWindow( _lifetimeCts.Cancel(); if (RootFrame.Content is ProgressPage progressPage) progressPage.CancelPipeline(); - await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask); - var nativeCleanup = RootFrame.Content switch - { - NativeGatewaySetupPage nativePage => nativePage.CancelAndWaitAsync(), - WizardPage wizardPage => wizardPage.CancelAndWaitAsync(), - _ => Task.CompletedTask, - }; - var pageCleanup = RootFrame.Content is IAsyncDisposable pageLifetime - ? pageLifetime.DisposeAsync().AsTask() - : Task.CompletedTask; - try - { - try - { - await nativeCleanup; - } - finally - { - if (_contextApplyTask is { } contextApplyTask) - await contextApplyTask; - } - } - finally - { - try - { - await pageCleanup; - } - finally + await SetupCloseCleanup.WaitInsideTeardownAsync( + _contextApplyTask, + _progressPipelineTask, + async () => { + var nativeCleanup = RootFrame.Content switch + { + NativeGatewaySetupPage nativePage => nativePage.CancelAndWaitAsync(), + WizardPage wizardPage => wizardPage.CancelAndWaitAsync(), + _ => Task.CompletedTask, + }; + var pageCleanup = RootFrame.Content is IAsyncDisposable pageLifetime + ? pageLifetime.DisposeAsync().AsTask() + : Task.CompletedTask; try { - await _nativePageCleanupTask; - await _aiPageCleanupTask; - if (_localAiTransitionTask is { } transition) - await transition; + await nativeCleanup; } finally { - AccessDraft?.ClearNativeConnectionSecrets(); - await ReleaseNativeSetupAsync(); + try + { + await pageCleanup; + } + finally + { + try + { + await _nativePageCleanupTask; + await _aiPageCleanupTask; + if (_localAiTransitionTask is { } transition) + await transition; + } + finally + { + AccessDraft?.ClearNativeConnectionSecrets(); + await ReleaseNativeSetupAsync(); + } + } } - } - } + }); } catch (OperationCanceledException) { diff --git a/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs b/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs index d5553f5856..ee09145073 100644 --- a/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs +++ b/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs @@ -21,4 +21,21 @@ public static async Task WaitForRunningWorkAsync(Task? contextApplyTask, Task? p await Task.WhenAll(contextApplyTask, progressPipelineTask).ConfigureAwait(false); } + + // The callback resumes on the caller context so window teardown can touch UI. + public static async Task WaitInsideTeardownAsync( + Task? contextApplyTask, + Task? progressPipelineTask, + Func teardown) + { + ArgumentNullException.ThrowIfNull(teardown); + try + { + await WaitForRunningWorkAsync(contextApplyTask, progressPipelineTask); + } + finally + { + await teardown(); + } + } } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs index faec8c4f09..813474c3f4 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs @@ -61,4 +61,39 @@ public async Task WaitForRunningWork_StillWaitsForPipelineWhenContextFaults() rollback.SetResult(); await Assert.ThrowsAnyAsync(() => waiting); } + + [Fact] + public async Task FaultedContext_RunsTeardownAfterPipelineAndBeforeLockRelease() + { + Assert.True(SetupRunLock.TryAcquire(_tempDir, out var held, out var message)); + Assert.Null(message); + + var pipeline = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var context = Task.FromException(new InvalidOperationException("context failed")); + var teardownStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waiting = SetupCloseCleanup.WaitInsideTeardownAsync( + context, + pipeline.Task, + () => + { + teardownStarted.TrySetResult(); + return Task.CompletedTask; + }); + + var finishedEarly = await Task.WhenAny(teardownStarted.Task, Task.Delay(TimeSpan.FromMilliseconds(200))); + Assert.NotSame(teardownStarted.Task, finishedEarly); + Assert.False(SetupRunLock.TryAcquire(_tempDir, out var stolen, out _)); + stolen?.Dispose(); + + pipeline.SetResult(); + await Assert.ThrowsAnyAsync(() => waiting); + Assert.True(teardownStarted.Task.IsCompletedSuccessfully); + Assert.False(SetupRunLock.TryAcquire(_tempDir, out stolen, out _)); + stolen?.Dispose(); + + held!.Dispose(); + Assert.True(SetupRunLock.TryAcquire(_tempDir, out var next, out var nextMessage)); + Assert.Null(nextMessage); + next?.Dispose(); + } } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index d8a89dd395..e7e1f905e8 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -1140,7 +1140,8 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() AssertInOrder( setupWindow, "_lifetimeCts.Cancel()", - "await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask)", + "await SetupCloseCleanup.WaitInsideTeardownAsync(", + "await pageCleanup", "_setupLock?.Dispose()"); Assert.Contains("distroNameOverride: _config.DistroName", wizardPage); Assert.Contains("if (AppIdentity.IsDev)", updateCoordinator); @@ -1339,7 +1340,9 @@ public void SetupWindowOwnership_WaitsForCleanupBeforeAllowingAnotherRun() setupWindow, "_lifetimeCts.Cancel()", "progressPage.CancelPipeline()", - "await SetupCloseCleanup.WaitForRunningWorkAsync(_contextApplyTask, _progressPipelineTask)", + "await SetupCloseCleanup.WaitInsideTeardownAsync(", + "await pageCleanup", + "await ReleaseNativeSetupAsync()", "catch (OperationCanceledException)", "_setupLock?.Dispose()", "_cleanupCompleted.TrySetResult(true)"); From 03bed856f2b000382cd101b9aed9b7f7158d8cd0 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 12:24:25 -0700 Subject: [PATCH 3/4] fix(setup): leave pipeline lifetime to progress-page disposal Window close already awaits ProgressPage.DisposeAsync before it releases setup.lock. That method cancels and awaits the pipeline, including settlement. The extra raw RunAsync registration waited on a different task, so it is removed. A Task.Run started with the setup token stayed incomplete for 300 ms after that token was cancelled, then finished when the delegate finished. These files now match the merged main. build.ps1 exit 0. Shared 4263 passed, 33 skipped. Tray 3996 passed. Signed-off-by: Sebastien Tardif --- .../Pages/ProgressPage.xaml.cs | 8 +- .../SetupWindow.xaml.cs | 79 +++++++-------- src/OpenClaw.SetupEngine/SetupCloseCleanup.cs | 41 -------- .../SetupCloseCleanupTests.cs | 99 ------------------- .../AppRefactorContractTests.cs | 8 +- 5 files changed, 40 insertions(+), 195 deletions(-) delete mode 100644 src/OpenClaw.SetupEngine/SetupCloseCleanup.cs delete mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs index e6bf89e5bd..21e04abff1 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs @@ -153,13 +153,9 @@ private async Task StartPipelineAsync() _pipeline = new SetupPipeline(steps); _pipeline.StepProgress += OnStepProgress; - // Do not pass the setup token to Task.Run. Cancel must not complete this - // task before RunAsync finishes its CancellationToken.None rollback. var pipeline = _pipeline; - var pipelineTask = Task.Run(() => pipeline.RunAsync(ctx)); - SetupWindow.Active?.AttachProgressPipeline(pipelineTask); var result = await SetupPipeline.RunWithSettlementAsync( - () => pipelineTask, + () => Task.Run(() => pipeline.RunAsync(ctx), cts.Token), outcome => config.NativeLocalAiAcquisition ? Task.CompletedTask : setupOwner?.SettleGatewaySetupAsync(ctx.ExpectedGatewayRegistry, outcome?.Outcome == PipelineOutcome.Success ? config.LocalAiRecoveryGatewayId ?? ctx.GatewayRecordId : null) ?? Task.CompletedTask); @@ -272,7 +268,7 @@ public async ValueTask DisposeAsync() await _pipelineTask; } - internal void CancelPipeline() + private void CancelPipeline() { if (!_pipelineFinished) _runCts?.Cancel(); diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index 30666efd96..eb74842641 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -25,7 +25,6 @@ public sealed partial class SetupWindow : Window private SetupRunLock? _setupLock; private readonly CancellationTokenSource _lifetimeCts = new(); private Task? _contextApplyTask; - private Task? _progressPipelineTask; private Task? _completionTask; private bool _completionDispatched; private string? _expectedConfiguredModelRef; @@ -174,49 +173,49 @@ public SetupWindow( try { _lifetimeCts.Cancel(); - if (RootFrame.Content is ProgressPage progressPage) - progressPage.CancelPipeline(); - await SetupCloseCleanup.WaitInsideTeardownAsync( - _contextApplyTask, - _progressPipelineTask, - async () => + var nativeCleanup = RootFrame.Content switch + { + NativeGatewaySetupPage nativePage => nativePage.CancelAndWaitAsync(), + WizardPage wizardPage => wizardPage.CancelAndWaitAsync(), + _ => Task.CompletedTask, + }; + var pageCleanup = RootFrame.Content is IAsyncDisposable pageLifetime + ? pageLifetime.DisposeAsync().AsTask() + : Task.CompletedTask; + try + { + try + { + await nativeCleanup; + } + finally + { + if (_contextApplyTask is { } contextApplyTask) + await contextApplyTask; + } + } + finally + { + try + { + await pageCleanup; + } + finally { - var nativeCleanup = RootFrame.Content switch - { - NativeGatewaySetupPage nativePage => nativePage.CancelAndWaitAsync(), - WizardPage wizardPage => wizardPage.CancelAndWaitAsync(), - _ => Task.CompletedTask, - }; - var pageCleanup = RootFrame.Content is IAsyncDisposable pageLifetime - ? pageLifetime.DisposeAsync().AsTask() - : Task.CompletedTask; try { - await nativeCleanup; + await _nativePageCleanupTask; + await _aiPageCleanupTask; + if (_localAiTransitionTask is { } transition) + await transition; } finally { - try - { - await pageCleanup; - } - finally - { - try - { - await _nativePageCleanupTask; - await _aiPageCleanupTask; - if (_localAiTransitionTask is { } transition) - await transition; - } - finally - { - AccessDraft?.ClearNativeConnectionSecrets(); - await ReleaseNativeSetupAsync(); - } - } + AccessDraft?.ClearNativeConnectionSecrets(); + await ReleaseNativeSetupAsync(); } - }); + } + } } catch (OperationCanceledException) { @@ -743,12 +742,6 @@ public bool TryNavigateToWizard(bool back = false) return true; } - internal void AttachProgressPipeline(Task progressPipelineTask) - { - ArgumentNullException.ThrowIfNull(progressPipelineTask); - _progressPipelineTask = progressPipelineTask; - } - private AiSetupPageArgs CreateAiSetupArgs() => new AiSetupPageArgs(_config, _dataDir, _localDataDir, TryNavigateToLegacyWizard, CompleteSetupAsync, _expectedConfiguredModelRef, diff --git a/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs b/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs deleted file mode 100644 index ee09145073..0000000000 --- a/src/OpenClaw.SetupEngine/SetupCloseCleanup.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace OpenClaw.SetupEngine; - -internal static class SetupCloseCleanup -{ - public static async Task WaitForRunningWorkAsync(Task? contextApplyTask, Task? progressPipelineTask) - { - if (contextApplyTask is null && progressPipelineTask is null) - return; - - if (contextApplyTask is null) - { - await progressPipelineTask!.ConfigureAwait(false); - return; - } - - if (progressPipelineTask is null) - { - await contextApplyTask.ConfigureAwait(false); - return; - } - - await Task.WhenAll(contextApplyTask, progressPipelineTask).ConfigureAwait(false); - } - - // The callback resumes on the caller context so window teardown can touch UI. - public static async Task WaitInsideTeardownAsync( - Task? contextApplyTask, - Task? progressPipelineTask, - Func teardown) - { - ArgumentNullException.ThrowIfNull(teardown); - try - { - await WaitForRunningWorkAsync(contextApplyTask, progressPipelineTask); - } - finally - { - await teardown(); - } - } -} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs deleted file mode 100644 index 813474c3f4..0000000000 --- a/tests/OpenClaw.SetupEngine.Tests/SetupCloseCleanupTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -namespace OpenClaw.SetupEngine.Tests; - -public sealed class SetupCloseCleanupTests : IDisposable -{ - private readonly string _tempDir; - - public SetupCloseCleanupTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), $"setup-close-cleanup-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. - try { Directory.Delete(_tempDir, recursive: true); } catch { } - } - - [Fact] - public async Task Close_ReleasesSetupLockOnlyAfterProgressRollbackFinishes() - { - Assert.True(SetupRunLock.TryAcquire(_tempDir, out var held, out var message)); - Assert.Null(message); - - var rollback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var cleanup = Task.Run(async () => - { - try - { - await SetupCloseCleanup.WaitForRunningWorkAsync(Task.CompletedTask, rollback.Task); - } - finally - { - held!.Dispose(); - } - }); - - var finishedEarly = await Task.WhenAny(cleanup, Task.Delay(TimeSpan.FromMilliseconds(200))); - Assert.NotSame(cleanup, finishedEarly); - Assert.False(SetupRunLock.TryAcquire(_tempDir, out var stolen, out _)); - stolen?.Dispose(); - - rollback.SetResult(); - await cleanup.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.True(SetupRunLock.TryAcquire(_tempDir, out var next, out var nextMessage)); - Assert.Null(nextMessage); - next?.Dispose(); - } - - [Fact] - public async Task WaitForRunningWork_StillWaitsForPipelineWhenContextFaults() - { - var rollback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var context = Task.FromException(new InvalidOperationException("context failed")); - var waiting = SetupCloseCleanup.WaitForRunningWorkAsync(context, rollback.Task); - - var finishedEarly = await Task.WhenAny(waiting, Task.Delay(TimeSpan.FromMilliseconds(200))); - Assert.NotSame(waiting, finishedEarly); - - rollback.SetResult(); - await Assert.ThrowsAnyAsync(() => waiting); - } - - [Fact] - public async Task FaultedContext_RunsTeardownAfterPipelineAndBeforeLockRelease() - { - Assert.True(SetupRunLock.TryAcquire(_tempDir, out var held, out var message)); - Assert.Null(message); - - var pipeline = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var context = Task.FromException(new InvalidOperationException("context failed")); - var teardownStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var waiting = SetupCloseCleanup.WaitInsideTeardownAsync( - context, - pipeline.Task, - () => - { - teardownStarted.TrySetResult(); - return Task.CompletedTask; - }); - - var finishedEarly = await Task.WhenAny(teardownStarted.Task, Task.Delay(TimeSpan.FromMilliseconds(200))); - Assert.NotSame(teardownStarted.Task, finishedEarly); - Assert.False(SetupRunLock.TryAcquire(_tempDir, out var stolen, out _)); - stolen?.Dispose(); - - pipeline.SetResult(); - await Assert.ThrowsAnyAsync(() => waiting); - Assert.True(teardownStarted.Task.IsCompletedSuccessfully); - Assert.False(SetupRunLock.TryAcquire(_tempDir, out stolen, out _)); - stolen?.Dispose(); - - held!.Dispose(); - Assert.True(SetupRunLock.TryAcquire(_tempDir, out var next, out var nextMessage)); - Assert.Null(nextMessage); - next?.Dispose(); - } -} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index e7e1f905e8..73a94b7ebf 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -1140,8 +1140,7 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() AssertInOrder( setupWindow, "_lifetimeCts.Cancel()", - "await SetupCloseCleanup.WaitInsideTeardownAsync(", - "await pageCleanup", + "await contextApplyTask", "_setupLock?.Dispose()"); Assert.Contains("distroNameOverride: _config.DistroName", wizardPage); Assert.Contains("if (AppIdentity.IsDev)", updateCoordinator); @@ -1339,10 +1338,7 @@ public void SetupWindowOwnership_WaitsForCleanupBeforeAllowingAnotherRun() AssertInOrder( setupWindow, "_lifetimeCts.Cancel()", - "progressPage.CancelPipeline()", - "await SetupCloseCleanup.WaitInsideTeardownAsync(", - "await pageCleanup", - "await ReleaseNativeSetupAsync()", + "await contextApplyTask", "catch (OperationCanceledException)", "_setupLock?.Dispose()", "_cleanupCompleted.TrySetResult(true)"); From d2d1294da99d772869a91f90f06326fdce6602b7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 12:40:41 -0700 Subject: [PATCH 4/4] ci: rerun the shared test lane Core failed one timing check. The post-exit output drain took 1605 ms and the bound is 1 second. The setup files on this head match main. Rerun of the failed job needs admin rights. Signed-off-by: Sebastien Tardif