From e4a7d6e19b56621f6067eec058ccee36e7d90d03 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 6 Oct 2026 17:17:41 -0700 Subject: [PATCH 1/3] fix(mxc): do not report a finished command as a timeout The grace timer can trip while stdout is drained after wxc-exec has exited. The executor already returns TimedOut false for that result. The caller was also treating the cancelled grace token as a timeout and replacing the exit code with -1. Signed-off-by: Sebastien Tardif --- .../Mxc/DirectAppContainerExecutor.cs | 13 +++++++++--- .../Mxc/DirectAppContainerExecutorTests.cs | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs index 1f89e4540..bfef13c64 100644 --- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs +++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs @@ -154,9 +154,10 @@ public async Task ExecuteAsync( if (ct.IsCancellationRequested) ct.ThrowIfCancellationRequested(); - // If our linked token tripped (timeout) and the caller's didn't, surface - // as a TimedOut result rather than throwing. - var timedOut = result.TimedOut || linked.IsCancellationRequested; + // The grace token can trip while stdout is drained after wxc-exec + // has already exited. Only the executor's TimedOut flag means the + // command itself did not finish. + var timedOut = CompletedResultIsTimeout(result, linked.Token); if (timedOut) { return new SandboxExecutionResult( @@ -185,6 +186,12 @@ public async Task ExecuteAsync( } } + internal static bool CompletedResultIsTimeout(MxcResult result, CancellationToken graceToken) + { + _ = graceToken; + return result.TimedOut; + } + private static string CreateScratchDir() { var dir = Path.Combine(Path.GetTempPath(), "openclaw-mxc-" + Guid.NewGuid().ToString("N").Substring(0, 12)); diff --git a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs index 8808a2d03..f3bd4bad3 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs @@ -27,6 +27,26 @@ public class DirectAppContainerExecutorTests TimeoutMs: 30_000), TimeoutMs: 30_000); + [Fact] + public void CompletedResult_IsNotATimeoutWhenTheGraceTokenIsCancelled() + { + using var grace = new CancellationTokenSource(); + grace.Cancel(); + var finished = new MxcResult + { + Success = true, + ExitCode = 7, + Output = "done", + TimedOut = false, + }; + + Assert.False(DirectAppContainerExecutor.CompletedResultIsTimeout(finished, grace.Token)); + Assert.True(grace.IsCancellationRequested); + + var timedOut = new MxcResult { ExitCode = -1, TimedOut = true }; + Assert.True(DirectAppContainerExecutor.CompletedResultIsTimeout(timedOut, grace.Token)); + } + [Fact] public async Task ExecuteAsync_AppContainerUnavailable_Throws() { From b2cfe6ce5a63d7fb6e7df311b28186c573c6fe54 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 10:43:51 -0700 Subject: [PATCH 2/3] test: show the host token cancels a still-running wxc-exec wxc-exec returns on its own script deadline, several seconds before the host grace timer. A sandbox command cannot hold it open that long. Run the production executor with a 60 second sandbox deadline and cancel the host token at 2 seconds. The long cmd loop is still running, so the host reports TimedOut and "Execution was cancelled." On this machine that returned in 2414 ms. The post-exit drain overlap inside ExecuteAsync is still not covered. That path exits about five seconds before the grace timer. Signed-off-by: Sebastien Tardif --- .../Mxc/MxcCommandRunnerIntegrationTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index 8cc306f5a..597501ddb 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -306,6 +306,67 @@ public async Task SystemRun_CustomEnv_DeniesBeforeMxcUnavailableHostFallback() Assert.Equal(0, executor.CallCount); } + [IntegrationFact] + public async Task DirectWxcExec_HostTokenCancelsBeforeSandboxDeadline_ReportsTimeout() + { + var availability = MxcAvailability.Probe(NullLogger.Instance); + if (!availability.CanRunSystemRunSandbox || string.IsNullOrWhiteSpace(availability.WxcExecPath)) + { + Console.WriteLine( + "[mxc-integration] SKIPPING host-token: " + + string.Join("; ", availability.SystemRunSandboxUnsupportedReasons)); + return; + } + + if (!HasSupportedSandboxPath(AppContext.BaseDirectory) + || !HasSupportedSandboxPath(availability.WxcExecPath)) + { + Console.WriteLine("[mxc-integration] SKIPPING host-token: unsupported sandbox path."); + return; + } + + var scratch = Path.Combine(Path.GetTempPath(), "openclaw-mxc-host-token-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(scratch); + try + { + var settings = new SettingsData + { + SystemRunSandboxEnabled = true, + SystemRunAllowOutbound = false, + }; + var policy = MxcPolicyBuilder.ForSystemRun(settings, scratch); + using var argsDoc = JsonDocument.Parse( + """ + {"command":"for /L %i in (1,1,8000000) do @rem","shell":"cmd","args":[],"timeoutMs":60000} + """); + var request = new SandboxExecutionRequest( + "system.run", + argsDoc.RootElement.Clone(), + policy, + TimeoutMs: 60_000, + Cwd: scratch); + var built = MxcConfigBuilder.Build(request, scratch); + var config = built with + { + Process = built.Process with { TimeoutMs = 60_000 }, + }; + + var executor = new MxcExecutor(availability.WxcExecPath!); + using var hostToken = new CancellationTokenSource(TimeSpan.FromMilliseconds(2_000)); + var result = await executor.RunAsync(config, hostToken.Token); + Console.WriteLine( + "[mxc-integration] host-token " + + $"exitCode={result.ExitCode}; timedOut={result.TimedOut}; durationMs={result.DurationMs}; error={result.Error}"); + Assert.True(result.TimedOut, result.Error); + Assert.Contains("cancelled", result.Error ?? "", StringComparison.OrdinalIgnoreCase); + Assert.InRange(result.DurationMs, 1_500, 5_000); + } + finally + { + try { Directory.Delete(scratch, recursive: true); } catch { } + } + } + private static bool HasSupportedSandboxPath(string path) { try From b51954768a04f48df04d9edd32a692e4c2bee78f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 11:05:37 -0700 Subject: [PATCH 3/3] ci: rerun the tray UI lane The UI job failed one onboarding case, Existing route choice 0, on Assert.True(resultBar.IsOpen) after a cancelled check. The Remote case in the same theory passed. The previous head's Build and Test run was green, and this commit does not change that page. GitHub refused gh run rerun without admin rights, so this empty commit starts the lane again. Signed-off-by: Sebastien Tardif