From 2005a2f87b09edde948f364ed71b56b26f8837ec Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 3 Nov 2026 15:27:58 -0500 Subject: [PATCH 1/2] fix(hyperlight): add cancellation regression coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d6807e7-70a6-4c43-8dc1-7bab20376cd4 --- .../SandboxExecutorTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs index 7e347669efc..8a9d9309b60 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -64,6 +64,37 @@ await Assert.ThrowsAnyAsync( () => executor.ExecuteAsync(snapshot, "void 0;", cancellation.Token)); } + [Fact] + public async Task ExecuteAsync_CancellationDuringGuestExecutionReleasesLockAsync() + { + // Arrange + using var cancellation = new CancellationTokenSource(); + var cancelRun = AIFunctionFactory.Create(() => + { + cancellation.Cancel(); + return "cancelled"; + }, name: "cancel_run"); + var snapshot = new SandboxExecutor.RunSnapshot( + [cancelRun], [], [], new HyperlightCodeActProviderOptions { IncludeDetailedErrors = true }); + using var executor = new SandboxExecutor(); + var warmup = await executor.ExecuteAsync(snapshot, "console.log('ready');", CancellationToken.None); + using var warmupJson = JsonDocument.Parse(warmup); + Assert.True( + warmupJson.RootElement.GetProperty("success").GetBoolean(), + warmupJson.RootElement.GetProperty("stderr").GetString()); + + // Act / Assert + await Assert.ThrowsAnyAsync( + () => executor.ExecuteAsync(snapshot, "call_tool('cancel_run', {});", cancellation.Token)); + + var recovered = await executor.ExecuteAsync(snapshot, "console.log('recovered');", CancellationToken.None); + using var recoveredJson = JsonDocument.Parse(recovered); + Assert.True( + recoveredJson.RootElement.GetProperty("success").GetBoolean(), + recoveredJson.RootElement.GetProperty("stderr").GetString()); + Assert.Contains("recovered", recoveredJson.RootElement.GetProperty("stdout").GetString(), StringComparison.Ordinal); + } + [Fact] public void Fingerprint_DifferentToolSets_DifferentFingerprints() { From 8c08448e0af2b5bd84feac439cc97672819630d6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 3 Nov 2026 19:30:28 -0500 Subject: [PATCH 2/2] fix(hyperlight): propagate cancellation and limit guest execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d6807e7-70a6-4c43-8dc1-7bab20376cd4 --- .../HyperlightCodeActProviderOptions.cs | 15 +++ .../Internal/SandboxExecutor.cs | 30 ++++-- .../Microsoft.Agents.AI.Hyperlight/README.md | 29 +++++ ...soft.Agents.AI.Hyperlight.UnitTests.csproj | 6 +- .../SandboxExecutorTests.cs | 100 +++++++++++++++++- .../references/integrations.md | 7 ++ 6 files changed, 176 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs index c6e28f68b4b..f07f609f86a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs @@ -18,6 +18,7 @@ namespace Microsoft.Agents.AI.Hyperlight; /// public sealed class HyperlightCodeActProviderOptions { + /// /// Initializes a new instance configured for the JavaScript backend. /// Equivalent to . @@ -69,6 +70,20 @@ public static HyperlightCodeActProviderOptions CreateForJavaScript() /// public string? StackSize { get; set; } + /// + /// Gets or sets the maximum execution time in seconds. Defaults to 30. + /// The value must be between 1 and 4,294,967 seconds. + /// + /// + /// The limit interrupts guest execution but cannot interrupt runtime initialization + /// or a host tool callback. Host tools must enforce their own cancellation policy. + /// + public int ExecutionTimeoutSeconds + { + get; + set => field = Throw.IfOutOfRange(value, 1, 4_294_967); + } = 30; + /// /// Gets or sets the initial set of provider-owned CodeAct tools made available /// inside the sandbox via call_tool(...). diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index 27f8e184e60..147c3b01f93 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -51,6 +51,7 @@ public RunSnapshot( this.ModulePath = options.ModulePath; this.HeapSize = options.HeapSize; this.StackSize = options.StackSize; + this.ExecutionTimeout = TimeSpan.FromSeconds(options.ExecutionTimeoutSeconds); this.HostInputDirectory = options.HostInputDirectory; this.IncludeDetailedErrors = options.IncludeDetailedErrors; this.ToolRegistryVersion = toolRegistryVersion; @@ -81,6 +82,8 @@ public RunSnapshot( public string? StackSize { get; } + public TimeSpan ExecutionTimeout { get; } + public string? HostInputDirectory { get; } public bool IncludeDetailedErrors { get; } @@ -224,14 +227,23 @@ public async Task ExecuteAsync(RunSnapshot snapshot, string code, Cancel await this._executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - this.EnsureInitialized(snapshot); + using var timeLimit = new CancellationTokenSource(snapshot.ExecutionTimeout); + using var executionCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeLimit.Token); + try + { + this.EnsureInitialized(snapshot, executionCancellation.Token); + + if (this._warmSnapshot is not null) + { + this._sandbox!.Restore(this._warmSnapshot); + } - if (this._warmSnapshot is not null) + return BuildResult(this._sandbox!.Run(code, executionCancellation.Token)); + } + catch (OperationCanceledException) when (timeLimit.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - this._sandbox!.Restore(this._warmSnapshot); + return BuildErrorResult("Code execution exceeded its time limit."); } - - return BuildResult(this._sandbox!.Run(code)); } #pragma warning disable CA1031 // Return ordinary lifecycle failures as policy-controlled execution errors. catch (Exception ex) when (ex is not OperationCanceledException) @@ -255,7 +267,7 @@ public async Task ExecuteAsync(RunSnapshot snapshot, string code, Cancel } } - private void EnsureInitialized(RunSnapshot snapshot) + private void EnsureInitialized(RunSnapshot snapshot, CancellationToken cancellationToken) { if (this._sandbox is not null && string.Equals(this._lastConfigFingerprint, snapshot.ConfigFingerprint, StringComparison.Ordinal)) { @@ -265,7 +277,7 @@ private void EnsureInitialized(RunSnapshot snapshot) // Configuration changed (or first run) — dispose the previous sandbox // so the new one picks up the current capabilities and runtime options. this.ClearSandboxState(); - this.BuildAndWarmUp(snapshot); + this.BuildAndWarmUp(snapshot, cancellationToken); } private void ClearSandboxState() @@ -285,7 +297,7 @@ private void ClearSandboxState() } } - private void BuildAndWarmUp(RunSnapshot snapshot) + private void BuildAndWarmUp(RunSnapshot snapshot, CancellationToken cancellationToken) { var builder = new SandboxBuilder() .WithBackend(snapshot.Backend); @@ -336,7 +348,7 @@ private void BuildAndWarmUp(RunSnapshot snapshot) // Backend-specific no-op used to trigger lazy guest runtime initialization // before the warm snapshot is captured. Matches the values used by the // upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference. - _ = sandbox.Run(snapshot.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); + _ = sandbox.Run(snapshot.Backend == SandboxBackend.JavaScript ? "void 0;" : "None", cancellationToken); this._warmSnapshot = sandbox.Snapshot(); this._lastConfigFingerprint = snapshot.ConfigFingerprint; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md index f6a19d4fe51..d6e3ed55d53 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -54,6 +54,35 @@ The same boundary covers initialization, warm-up, snapshot restore, and executio failures. Ordinary failures dispose and clear failed sandbox state before returning an error; cancellation still propagates. +## Execution limits + +Guest execution has a finite 30-second limit by default. Configure +`HyperlightCodeActProviderOptions.ExecutionTimeoutSeconds` to change it; values +must be positive and within the supported timer range. Run snapshots capture the +limit independently of later option changes. + +Caller cancellation interrupts running guest code and propagates as +`OperationCanceledException`. Reaching the configured limit returns an unsuccessful +execution result. The next invocation restores the warm snapshot before reuse. +Native execution finishes before another invocation can enter the sandbox. + +Runtime initialization and host tool callbacks cannot be interrupted by the guest +limit. Host tools must enforce their own cancellation and execution limits. +This behavior requires an upstream SDK version with the cancellation-aware `Run` overload. + +### KVM guest regression tests + +Ordinary unit tests retain reflection-disabled JSON settings. To run the guest +regressions on a KVM-capable host with the corresponding native SDK installed: + +```powershell +$env:HYPERLIGHT_RUN_GUEST_TESTS = "true" +dotnet test --project dotnet\tests\Microsoft.Agents.AI.Hyperlight.UnitTests -f net10.0 -p:HyperlightGuestTests=true +``` + +The explicit guest configuration enables reflection-based serialization required +by the upstream SDK; without opt-in, guest-only tests are skipped. + ## Network access and guest packages `HyperlightCodeActProviderOptions.AllowedDomains` controls outbound requests diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj index 0964d4e4ada..ce104c5b947 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -4,10 +4,14 @@ $(TargetFrameworksCore) - + false + + true + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs index 8a9d9309b60..5ab0c77b42f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -68,6 +68,9 @@ await Assert.ThrowsAnyAsync( public async Task ExecuteAsync_CancellationDuringGuestExecutionReleasesLockAsync() { // Arrange + Assert.SkipUnless( + string.Equals(Environment.GetEnvironmentVariable("HYPERLIGHT_RUN_GUEST_TESTS"), "true", StringComparison.OrdinalIgnoreCase), + "Requires explicit Hyperlight KVM guest-test configuration."); using var cancellation = new CancellationTokenSource(); var cancelRun = AIFunctionFactory.Create(() => { @@ -87,7 +90,102 @@ public async Task ExecuteAsync_CancellationDuringGuestExecutionReleasesLockAsync await Assert.ThrowsAnyAsync( () => executor.ExecuteAsync(snapshot, "call_tool('cancel_run', {});", cancellation.Token)); - var recovered = await executor.ExecuteAsync(snapshot, "console.log('recovered');", CancellationToken.None); + using var recoveryCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var recovered = await executor.ExecuteAsync(snapshot, "console.log('recovered');", recoveryCancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(10)); + using var recoveredJson = JsonDocument.Parse(recovered); + Assert.True( + recoveredJson.RootElement.GetProperty("success").GetBoolean(), + recoveredJson.RootElement.GetProperty("stderr").GetString()); + Assert.Contains("recovered", recoveredJson.RootElement.GetProperty("stdout").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteAsync_DefaultTimeoutInterruptsGuestAndAllowsReuseAsync() + { + // Arrange + Assert.SkipUnless( + string.Equals(Environment.GetEnvironmentVariable("HYPERLIGHT_RUN_GUEST_TESTS"), "true", StringComparison.OrdinalIgnoreCase), + "Requires explicit Hyperlight KVM guest-test configuration."); + var snapshot = new SandboxExecutor.RunSnapshot( + [], [], [], new HyperlightCodeActProviderOptions { IncludeDetailedErrors = true }); + using var executor = new SandboxExecutor(); + var warmup = await executor.ExecuteAsync(snapshot, "console.log('ready');", CancellationToken.None); + using var warmupJson = JsonDocument.Parse(warmup); + Assert.True( + warmupJson.RootElement.GetProperty("success").GetBoolean(), + warmupJson.RootElement.GetProperty("stderr").GetString()); + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + + // Act + var result = await executor.ExecuteAsync(snapshot, """ + const end = Date.now() + 32000; + while (Date.now() < end) {} + console.log('finished'); + """, CancellationToken.None); + + // Assert + using var json = JsonDocument.Parse(result); + Assert.False(json.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("Code execution exceeded its time limit.", json.RootElement.GetProperty("stderr").GetString()); + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(31), "The default execution timeout was not enforced."); + using var recoveryCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var recovered = await executor.ExecuteAsync(snapshot, "console.log('recovered');", recoveryCancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(10)); + using var recoveredJson = JsonDocument.Parse(recovered); + Assert.True( + recoveredJson.RootElement.GetProperty("success").GetBoolean(), + recoveredJson.RootElement.GetProperty("stderr").GetString()); + Assert.Contains("recovered", recoveredJson.RootElement.GetProperty("stdout").GetString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExecuteAsync_ConfiguredTimeoutUsesSnapshotAndAllowsReuseAsync(bool useWasm) + { + // Arrange + Assert.SkipUnless( + string.Equals(Environment.GetEnvironmentVariable("HYPERLIGHT_RUN_GUEST_TESTS"), "true", StringComparison.OrdinalIgnoreCase), + "Requires explicit Hyperlight KVM guest-test configuration."); + var options = useWasm + ? HyperlightCodeActProviderOptions.CreateForWasm( + Assert.IsType(Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH"))) + : new HyperlightCodeActProviderOptions(); + using var executor = new SandboxExecutor(); + var warmSnapshot = new SandboxExecutor.RunSnapshot([], [], [], options); + var warmup = await executor.ExecuteAsync(warmSnapshot, useWasm ? "print('ready')" : "console.log('ready');", CancellationToken.None); + using var warmupJson = JsonDocument.Parse(warmup); + Assert.True( + warmupJson.RootElement.GetProperty("success").GetBoolean(), + warmupJson.RootElement.GetProperty("stderr").GetString()); + options.ExecutionTimeoutSeconds = 1; + var limitedSnapshot = new SandboxExecutor.RunSnapshot([], [], [], options); + options.ExecutionTimeoutSeconds = 30; + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + + // Act + var result = await executor.ExecuteAsync(limitedSnapshot, useWasm ? """ + import time + end = time.monotonic() + 5 + while time.monotonic() < end: + pass + print('finished') + """ : """ + const end = Date.now() + 5000; + while (Date.now() < end) {} + console.log('finished'); + """, CancellationToken.None); + + // Assert + using var json = JsonDocument.Parse(result); + Assert.False(json.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("Code execution exceeded its time limit.", json.RootElement.GetProperty("stderr").GetString()); + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(2), "The captured execution timeout was not enforced."); + using var recoveryCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var recovered = await executor.ExecuteAsync( + warmSnapshot, useWasm ? "print('recovered')" : "console.log('recovered');", recoveryCancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(10)); using var recoveredJson = JsonDocument.Parse(recovered); Assert.True( recoveredJson.RootElement.GetProperty("success").GetBoolean(), diff --git a/skills/agent-framework-builder-dotnet/references/integrations.md b/skills/agent-framework-builder-dotnet/references/integrations.md index 444f2c3b88f..022b427a47b 100644 --- a/skills/agent-framework-builder-dotnet/references/integrations.md +++ b/skills/agent-framework-builder-dotnet/references/integrations.md @@ -46,6 +46,13 @@ stack traces and inner exceptions. This is separate from the outer `FunctionInvokingChatClient` error policy and sensitive-data logging; outer redaction cannot repair diagnostics already caught and returned by generated code. +Hyperlight guest execution defaults to a finite 30-second limit, configurable through +`HyperlightCodeActProviderOptions.ExecutionTimeoutSeconds`. Caller cancellation interrupts +guest code, but neither cancellation nor the limit interrupts runtime initialization +or host tool callbacks. Host tools must enforce their own limits. This requires the +upstream SDK's cancellation-aware `Run` API; verify that it is available in the selected +package version before using this option. + For AG-UI, remember that thread and run IDs are correlation identifiers, not authentication boundaries. The application owns authorization, durable snapshots, scaling, and rendering policy.