Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/hyperlight_sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ bytes = "1"
cap-std = "4.0.3"
http = "1"
http-body-util = "0.1"
hyperlight-host = { workspace = true, features = ["kvm", "mshv3", "hvf"] }
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
log = "0.4"
Expand Down
39 changes: 39 additions & 0 deletions src/hyperlight_sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub use cap_fs::{
CapFs, DescriptorFlags, DescriptorStat, DescriptorType, Dir, DirPerms, FilePerms,
FilesystemLimits, FsError, OpenFlags,
};
pub use hyperlight_host::HyperlightError;
pub use network::{HttpMethod, MethodFilter, NetworkPermission, NetworkPermissions};
use serde::{Deserialize, Serialize};
pub use tools::{ArgType, ToolRegistry, ToolSchema};
Expand Down Expand Up @@ -106,6 +107,30 @@ impl<T> Snapshot<T> {
// Guest traits
// ---------------------------------------------------------------------------

/// An independently owned handle for interrupting guest execution.
///
/// Host callbacks are not interrupted. After an interruption, restore a
/// snapshot before reusing the sandbox.
#[derive(Clone)]
pub struct InterruptHandle {
inner: std::sync::Arc<dyn hyperlight_host::hypervisor::InterruptHandle>,
}

impl InterruptHandle {
pub fn new(inner: std::sync::Arc<dyn hyperlight_host::hypervisor::InterruptHandle>) -> Self {
Self { inner }
}

/// Request interruption, returning whether the vCPU was running.
///
/// This may block until the running vCPU stops. A false return during a
/// host callback can still leave cancellation pending until it returns.
/// A request just before guest entry can be missed; callers can retry.
pub fn kill(&self) -> bool {
self.inner.kill()
}
}

pub trait Guest: Sized {
type Sandbox: GuestSandbox;
fn build(
Expand All @@ -129,6 +154,10 @@ pub trait GuestSandbox: Send {
fn snapshot(&mut self) -> Result<Snapshot<Self::SnapshotData>>;
/// Restore a previously captured guest runtime state.
fn restore(&mut self, snapshot: &Snapshot<Self::SnapshotData>) -> Result<()>;
/// Obtain an independently owned interruption handle.
fn interrupt_handle(&self) -> Result<InterruptHandle> {
anyhow::bail!("guest backend does not support interruption")
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -173,6 +202,10 @@ impl<G: Guest> Sandbox<G> {
self.inner.run(code)
}

pub fn interrupt_handle(&self) -> Result<InterruptHandle> {
self.inner.interrupt_handle()
}

pub fn snapshot(&mut self) -> Result<Snapshot<<G::Sandbox as GuestSandbox>::SnapshotData>> {
self.inner.snapshot()
}
Expand Down Expand Up @@ -373,6 +406,12 @@ where
mod tests {
use super::*;

#[test]
fn interrupt_handle_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<InterruptHandle>();
}

struct TestGuest;

struct TestGuestSandbox;
Expand Down
18 changes: 16 additions & 2 deletions src/javascript_sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use hyperlight_js::{
};
use hyperlight_sandbox::runtime::BlockOn;
use hyperlight_sandbox::{
CapFs, ExecutionResult, Guest, GuestSandbox, NetworkPermissions, SandboxConfig, Snapshot,
ToolRegistry, http as sandbox_http,
CapFs, ExecutionResult, Guest, GuestSandbox, InterruptHandle, NetworkPermissions,
SandboxConfig, Snapshot, ToolRegistry, http as sandbox_http,
};
use serde::Deserialize;

Expand Down Expand Up @@ -393,6 +393,15 @@ impl JsGuestSandbox {
exit_code: response.exit_code,
})
}
Err(error)
if matches!(
error,
hyperlight_sandbox::HyperlightError::ExecutionCanceledByHost()
| hyperlight_sandbox::HyperlightError::PoisonedSandbox
) =>
{
Err(error.into())
}
Err(error) => {
let mut stderr = error.to_string();
let stdout = match self.stdout.lock() {
Expand All @@ -415,6 +424,11 @@ impl JsGuestSandbox {
impl GuestSandbox for JsGuestSandbox {
type SnapshotData = JsSnapshot;

fn interrupt_handle(&self) -> Result<InterruptHandle> {
let handle = self.sandbox.interrupt_handle();
Ok(InterruptHandle::new(handle))
}

fn run(&mut self, code: &str) -> Result<ExecutionResult> {
self.run_impl(code)
}
Expand Down
10 changes: 9 additions & 1 deletion src/sdk/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ just dotnet copilot-sdk-example # Run Copilot SDK example
| Method | Description |
|--------|-------------|
| `Run(string code)` | Execute guest code, returns `ExecutionResult` |
| `RunAsync(string, CancellationToken)` | Async version on thread pool |
| `Run(string, CancellationToken)` | Execute guest code with interruption on cancellation |
| `RunAsync(string, CancellationToken)` | Execute on the thread pool with interruption on cancellation |
| `RegisterTool<TArgs,TResult>(name, handler)` | Register typed tool |
| `RegisterTool(name, Func<string,string>)` | Register raw JSON tool |
| `AllowDomain(target, methods?)` | Add domain to network allowlist |
Expand All @@ -275,6 +276,13 @@ just dotnet copilot-sdk-example # Run Copilot SDK example
| `Snapshot()` | Capture sandbox state |
| `Restore(snapshot)` | Restore to captured state |

Cancellation stops running guest execution on both backends and throws
`OperationCanceledException` only after native execution and interruption callbacks
have finished. Runtime initialization and host tool callbacks cannot be interrupted.
Capture a snapshot before execution and restore it after cancellation before reusing
the sandbox. Use a `CancellationTokenSource` with a finite timeout to bound guest
execution; host tools must enforce their own cancellation policy.

### `ExecutionResult`

| Property | Type | Description |
Expand Down
76 changes: 68 additions & 8 deletions src/sdk/dotnet/core/Api/Sandbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,21 @@ public void RegisterToolAsync(string name, Func<string, Task<string>> handler)
/// permissions). Subsequent calls reuse the initialized runtime.
/// </remarks>
public ExecutionResult Run(string code)
=> Run(code, CancellationToken.None);

/// <summary>Executes guest code with cancellation of running guest execution.</summary>
/// <param name="code">The code to execute.</param>
/// <param name="cancellationToken">Token used to interrupt guest execution.</param>
/// <returns>The execution result.</returns>
/// <remarks>
/// Runtime initialization and host callbacks cannot be interrupted.
/// Cancellation waits for native execution to stop before returning.
/// Completed results and unrelated failures are preserved if cancellation arrives late.
/// Restore a snapshot before reusing an interrupted sandbox.
/// </remarks>
/// <exception cref="OperationCanceledException">Thrown before execution if cancellation is
/// requested, or when native execution reports cancellation for this token.</exception>
public ExecutionResult Run(string code, CancellationToken cancellationToken)
{
lock (_gate)
{
Expand All @@ -333,9 +348,43 @@ public ExecutionResult Run(string code)
nameof(code));
}

var result = SafeNativeMethods.hyperlight_sandbox_run(_handle, code);
GC.KeepAlive(this);
result.ThrowIfError();
cancellationToken.ThrowIfCancellationRequested();
using var completed = new ManualResetEventSlim();
using var interruptHandle = cancellationToken.CanBeCanceled ? GetInterruptHandle() : null;
Task? interruption = null;
using var registration = cancellationToken.Register(() =>
{
// kill() can miss an idle guest just before native execution starts.
// Retry off the callback thread, which may itself be a host tool.
interruption = Task.Factory.StartNew(() =>
{
while (!completed.IsSet)
{
if (SafeNativeMethods.hyperlight_sandbox_interrupt(interruptHandle!))
{
break;
}

completed.Wait(10);
}
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default);
});

FFIResult result;
try
{
cancellationToken.ThrowIfCancellationRequested();
result = SafeNativeMethods.hyperlight_sandbox_run(_handle, code);
}
finally
{
completed.Set();
registration.Dispose();
interruption?.GetAwaiter().GetResult();
GC.KeepAlive(this);
}

result.ThrowIfError(cancellationToken);

var json = FFIResult.StringFromPtr(result.value);
if (json == null)
Expand All @@ -359,14 +408,25 @@ public ExecutionResult Run(string code)
/// For ASP.NET Core, prefer calling <see cref="Run"/> directly.
/// </summary>
/// <remarks>
/// The cancellation token prevents scheduling of the task but cannot
/// cancel an in-progress FFI call. Once <see cref="Run"/> starts
/// executing in the native layer, it will run to completion.
/// Cancellation interrupts running guest execution, but not runtime
/// initialization or host callbacks. Restore a snapshot after interruption.
/// </remarks>
/// <param name="code">The code to execute.</param>
/// <param name="cancellationToken">Token to prevent scheduling (does not cancel in-progress execution).</param>
/// <param name="cancellationToken">Token to prevent scheduling or interrupt running guest execution.</param>
public Task<ExecutionResult> RunAsync(string code, CancellationToken cancellationToken = default)
=> Task.Run(() => Run(code), cancellationToken);
=> Task.Run(() => Run(code, cancellationToken), cancellationToken);

private InterruptSafeHandle GetInterruptHandle()
{
var result = SafeNativeMethods.hyperlight_sandbox_get_interrupt_handle(_handle);
result.ThrowIfError();
if (result.value == IntPtr.Zero)
{
throw new SandboxException("Native interruption handle was null.");
}

return new InterruptSafeHandle(result.value);
}

// -----------------------------------------------------------------------
// Network
Expand Down
3 changes: 3 additions & 0 deletions src/sdk/dotnet/core/PInvoke/FFIErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ internal enum FFIErrorCode : uint

/// <summary>Filesystem I/O error.</summary>
IoError = 7,

/// <summary>Guest execution was interrupted by the host.</summary>
Cancelled = 8,
}
4 changes: 3 additions & 1 deletion src/sdk/dotnet/core/PInvoke/FFIResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ internal struct FFIResult
/// Maps <see cref="FFIErrorCode"/> values to specific exception types
/// for structured error handling in the API layer.
/// </remarks>
public void ThrowIfError()
public void ThrowIfError(CancellationToken cancellationToken = default)
{
if (is_success)
{
Expand All @@ -71,6 +71,8 @@ public void ThrowIfError()

throw code switch
{
FFIErrorCode.Cancelled when cancellationToken.IsCancellationRequested =>
new OperationCanceledException(errorMessage, cancellationToken),
FFIErrorCode.Timeout =>
new SandboxTimeoutException(errorMessage),
FFIErrorCode.Poisoned =>
Expand Down
22 changes: 22 additions & 0 deletions src/sdk/dotnet/core/PInvoke/SafeHandles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,25 @@ protected override bool ReleaseHandle()
return true;
}
}

/// <summary>Owns a native interruption handle independently of sandbox state.</summary>
internal sealed class InterruptSafeHandle : SafeHandle
{
public InterruptSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true)
{
SetHandle(handle);
}

public override bool IsInvalid => handle == IntPtr.Zero;

protected override bool ReleaseHandle()
{
IntPtr oldHandle = Interlocked.Exchange(ref handle, IntPtr.Zero);
if (oldHandle != IntPtr.Zero)
{
SafeNativeMethods.hyperlight_sandbox_free_interrupt_handle(oldHandle);
}

return true;
}
}
13 changes: 13 additions & 0 deletions src/sdk/dotnet/core/PInvoke/SafeNativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,19 @@ internal static partial FFIResult hyperlight_sandbox_run(
SandboxSafeHandle handle,
[MarshalAs(UnmanagedType.LPUTF8Str)] string code);

[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial FFIResult hyperlight_sandbox_get_interrupt_handle(SandboxSafeHandle handle);

[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
[return: MarshalAs(UnmanagedType.I1)]
internal static partial bool hyperlight_sandbox_interrupt(InterruptSafeHandle handle);

[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void hyperlight_sandbox_free_interrupt_handle(IntPtr handle);

// -----------------------------------------------------------------------
// Filesystem
// -----------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ public IntegrationTests(ITestOutputHelper output)
// Basic execution
// -----------------------------------------------------------------------

[Fact]
public async Task Integration_RunAsync_CancellationStopsGuestAndAllowsRestoreAsync()
{
var guestPath = FindPythonGuest();
if (guestPath == null) return;
using var cancellation = new CancellationTokenSource();
using var sandbox = new SandboxBuilder().WithModulePath(guestPath).Build();
sandbox.RegisterTool("cancel_run", (string _) =>
{
cancellation.CancelAfter(TimeSpan.FromMilliseconds(100));
return "{}";
});
var warmup = await sandbox.RunAsync("print('ready')").ConfigureAwait(false);
Assert.True(warmup.Success, warmup.Stderr);
using var snapshot = await sandbox.SnapshotAsync().ConfigureAwait(false);
var elapsed = System.Diagnostics.Stopwatch.StartNew();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sandbox.RunAsync("""
import time
call_tool('cancel_run')
end = time.monotonic() + 5
while time.monotonic() < end:
pass
print('finished')
""", cancellation.Token)).ConfigureAwait(false);

Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(2), "Guest execution was not interrupted promptly.");
await sandbox.RestoreAsync(snapshot).ConfigureAwait(false);
using var recovery = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await sandbox.RunAsync("print('recovered')", recovery.Token)
.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.True(result.Success, result.Stderr);
Assert.Contains("recovered", result.Stdout, StringComparison.Ordinal);
}

[Fact]

public void Integration_BasicExecution_ReturnsStdout()
Expand Down
Loading
Loading