diff --git a/.github/workflows/dep_rust.yml b/.github/workflows/dep_rust.yml index b99506bc..f41c5194 100644 --- a/.github/workflows/dep_rust.yml +++ b/.github/workflows/dep_rust.yml @@ -170,6 +170,19 @@ jobs: run: just test ${{ matrix.config }} ${{ env.FEATURES }} working-directory: ./src/hyperlight_wasm + # Runs before Test Examples, which replaces the native modules with + # Pulley ones. Skipped on macOS, where the tests run inside the dotnet + # host, which lacks the hypervisor entitlement. + - name: Set up .NET + if: runner.os != 'macOS' + uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0 + with: + dotnet-version: 8.0.x + + - name: Test .NET packages + if: runner.os != 'macOS' + run: just dotnet-test-prebuilt ${{ matrix.config }} ${{ env.FEATURES }} + - name: Install github-cli (Windows) if: runner.os == 'Windows' run: | diff --git a/.gitignore b/.gitignore index d81ee86a..33a8408d 100644 --- a/.gitignore +++ b/.gitignore @@ -457,6 +457,7 @@ $RECYCLE.BIN/ ## Hyperlight **/include/hyperlight*.h +!src/hyperlight_wasm_capi/include/hyperlight_wasm.h **/include/printf.h **/native/HyperlightGuest.lib **/native/HyperlightGuest.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cbb2312..dd3464c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased +### Added + +* Add `SandboxBuilder::with_interrupt_vcpu_sigrtmin_offset` to change the signal that interrupts a guest on Linux. +* Experimental: add the `experimental_runtime_file` feature, with `SandboxBuilder::with_runtime_file` to load the guest runtime from a file. Snapshots of such a sandbox cannot be saved. +* Experimental: add the `hyperlight-wasm-capi` crate, a C API that calls component functions with arguments and results in Hyperlight's component wire format. It is not published. +* Experimental: add the `Hyperlight.Wasm` and `Hyperlight.Wasm.Build` .NET packages, which run components from .NET with typed C# bindings generated from WIT at build time. See `dotnet/README.md`. They are not published. + ### Changed * Update Hyperlight dependencies to 0.19.0. diff --git a/Cargo.lock b/Cargo.lock index e05d3bcf..3cea544e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1871,6 +1871,15 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acddba0f6ad911efacdd3a6d2d176693f8a898c2a80384e218d238e9be1a91da" +dependencies = [ + "hyperlight-component-util", +] + [[package]] name = "hyperlight-component-macro" version = "0.19.0" @@ -2089,6 +2098,21 @@ dependencies = [ "wasmtime 47.0.4", ] +[[package]] +name = "hyperlight-wasm-capi" +version = "0.16.0" +dependencies = [ + "examples_common", + "hyperlight-wasm", +] + +[[package]] +name = "hyperlight-wasm-csharp-bindgen" +version = "0.16.0" +dependencies = [ + "hyperlight-component-csharp", +] + [[package]] name = "hyperlight-wasm-macro" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index a87a339a..54f6e348 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "src/hyperlight_wasm", "src/examples_common", "src/hyperlight_wasm_aot", "src/hyperlight_wasm_runtime", "src/hyperlight_wasm_macro" ] +members = [ "src/hyperlight_wasm", "src/hyperlight_wasm_capi", "src/hyperlight_wasm_csharp_bindgen", "src/examples_common", "src/hyperlight_wasm_aot", "src/hyperlight_wasm_runtime", "src/hyperlight_wasm_macro" ] exclude = [ "src/tests/rust_guests/rust_wasm_samples", "src/tests/rust_guests/component_sample", "src/tests/rust_guests/greeter_sample" ] resolver = "2" @@ -15,6 +15,7 @@ readme = "README.md" [workspace.dependencies] hyperlight-common = { version = "0.19.0", default-features = false } hyperlight-component-macro = "0.19.0" +hyperlight-component-csharp = "0.19.0" hyperlight-component-util = "0.19.0" hyperlight-guest = "0.19.0" hyperlight-guest-bin = "0.19.0" diff --git a/Justfile b/Justfile index 7a04ccae..e5dfae2c 100644 --- a/Justfile +++ b/Justfile @@ -181,6 +181,25 @@ run-examples-components target=default-target features="": {{ wit-world }} cargo test -p hyperlight-wasm {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --lib snapshot::tests::component {{ wit-world-c }} cargo run {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example c-component {{ wit-world }} {{ wit-world-name-greeter }} cargo run {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example component_greeter_example + {{ wit-world }} cargo test -p hyperlight-wasm-capi {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored + +# Builds the native libraries and a guest runtime for the component_sample example world, for the .NET tests +dotnet-test-assets target=default-target features="": ensure-cargo-hyperlight + cargo build -p hyperlight-wasm-capi -p hyperlight-wasm-csharp-bindgen {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} + {{ wit-world }} cargo hyperlight build {{ if features =~ "wasmtime_latest" {"--no-default-features -F wasmtime_latest"} else {""} }} --profile={{ if target == "debug" {"dev"} else { target } }} --manifest-path src/hyperlight_wasm_runtime/Cargo.toml --target-dir target/component-sample-runtime --locked + +# Runs the .NET tests. Needs the .NET 8 SDK or later. +dotnet-test target=default-target features="": (build-rust-component-examples target features) (dotnet-test-prebuilt target features) + +# Runs the .NET tests on the component_sample component that build-ci-examples builds. +dotnet-test-prebuilt target=default-target features="": (dotnet-test-assets target features) + dotnet test dotnet/tests/Hyperlight.Wasm.Tests -c {{ if target == "debug" {"Debug"} else {"Release"} }} + +# Packs the .NET packages for this machine's runtime identifier into dotnet/artifacts +dotnet-pack target="release": + cargo build -p hyperlight-wasm-capi -p hyperlight-wasm-csharp-bindgen --profile={{ if target == "debug" {"dev"} else { target } }} + dotnet pack dotnet/src/Hyperlight.Wasm -c {{ if target == "debug" {"Debug"} else {"Release"} }} -o dotnet/artifacts + dotnet pack dotnet/src/Hyperlight.Wasm.Build -c {{ if target == "debug" {"Debug"} else {"Release"} }} -o dotnet/artifacts # Test a component and a module compiled with pulley examples-pulley target=default-target features="": (build-pulley-rust-component-examples target features) (build-pulley-rust-wasm-examples target features) (run-examples-pulley target features) diff --git a/README.md b/README.md index 4da18eaf..dc3e11a5 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,15 @@ WIT_WORLD= HYPERLIGHT_COMPONENT_MACRO_DEBUG=/tmp/guest.rs HYPERLIGHT_COMPONENT_MACRO_DEBUG=/tmp/host.rs cargo build ``` +### Other languages + +These are experimental. + +* [`src/hyperlight_wasm_capi`](src/hyperlight_wasm_capi) is a C API. Its header is `include/hyperlight_wasm.h`. +* [`dotnet`](dotnet) has .NET packages with C# bindings generated from WIT. + +Both load a guest runtime built for the component's world from a file. + ## Code of Conduct diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props new file mode 100644 index 00000000..ed247fb9 --- /dev/null +++ b/dotnet/Directory.Build.props @@ -0,0 +1,26 @@ + + + + enable + enable + latest + true + + <_HyperlightCargoToml>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)../Cargo.toml')) + $([System.Text.RegularExpressions.Regex]::Match('$(_HyperlightCargoToml)', '(?<=\[workspace\.package\][^\[]*\nversion\s*=\s*")[^"]+').Value) + preview + The Hyperlight Authors + Apache-2.0 + https://github.com/hyperlight-dev/hyperlight-wasm + https://github.com/hyperlight-dev/hyperlight-wasm + + release + debug + $(MSBuildThisFileDirectory)../target/$(HyperlightCargoProfile)/ + lib + .dll + .so + .dylib + + + diff --git a/dotnet/Directory.Build.targets b/dotnet/Directory.Build.targets new file mode 100644 index 00000000..cebce9d6 --- /dev/null +++ b/dotnet/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + $(NETCoreSdkPortableRuntimeIdentifier) + + + diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 00000000..36cfb46e --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,87 @@ +# Hyperlight.Wasm for .NET + +> **Warning:** These packages are experimental. Their API and the generated C# +> can change in any release. + +Run WebAssembly components in Hyperlight micro VMs from .NET, with typed bindings generated from WIT. + +* `Hyperlight.Wasm` is the runtime library. It wraps the native `hyperlight-wasm-capi` library. +* `Hyperlight.Wasm.Build` generates C# from WIT at build time. It needs no Rust toolchain. + +Both target .NET 8 or later. The packages hold native libraries for the runtime identifier they were packed on. + +## Usage + +```xml + + + + + +``` + +Each `HyperlightWit` item is a `.wit` file or directory. `World` selects a world when the package has several. Without it, the last world is used. `Namespace` sets the namespace of the generated types and defaults to the project's root namespace. + +For a world `example` that imports `host` and exports `adder`, the build generates: + +* `ExampleComponent`, with an `Adder` property for the export. +* `ExampleComponent.RegisterImports`, which registers an `IHost` implementation as host functions. +* C# types for the world's records, variants, enums and flags. + +```csharp +using var sandbox = new WasmSandbox(new SandboxOptions +{ + RuntimePath = "example-runtime", +}); +ExampleComponent.RegisterImports(sandbox, new Host()); +sandbox.Load("example.aot"); + +var component = new ExampleComponent(sandbox); +uint sum = component.Adder.Add(1, 2); +``` + +`docs/component-wire-format.md` in hyperlight lists how WIT types map to C#. + +## Guest runtime and components + +A component needs a guest runtime built for its WIT world, because the runtime's guest bindings are generated when it is compiled. Building a runtime needs Rust and a hyperlight-wasm checkout of the same version as the packages: + +```sh +wasm-tools component wit example.wit -w -o example-world.wasm +WIT_WORLD=example-world.wasm cargo hyperlight build --release \ + --manifest-path src/hyperlight_wasm_runtime/Cargo.toml --target-dir target/example-runtime +``` + +Precompile the component with `hyperlight-wasm-aot compile --component`. The runtime, the AOT tool and the packages must come from the same hyperlight-wasm version. + +## Sandboxes + +* Register host functions, then load a component, then call it. +* Calls are serialized. Host functions run on the calling thread and cannot call back into their own sandbox. +* An exception thrown by a host function fails the guest call. The `HyperlightException` holds it as `InnerException`. +* A trap or interrupted call poisons the sandbox. Restore a snapshot taken with `CreateSnapshot` to use it again. +* `Call` takes a `CancellationToken`. Cancelling interrupts the guest. `CreateInterruptHandle` returns a handle that does the same from any thread. + +## Hypervisor entitlement on macOS + +macOS lets a process use Hypervisor.framework only when its executable is signed with the `com.apple.security.hypervisor` entitlement. Publish the app as a single file and sign that executable with an entitlements file such as `dev/macos-entitlements.plist`: + +```sh +dotnet publish -c Release -r osx-arm64 -p:PublishSingleFile=true +codesign -f -s - --entitlements macos-entitlements.plist bin/Release/net8.0/osx-arm64/publish/MyApp +``` + +`dotnet MyApp.dll` and `dotnet test` run inside the `dotnet` host, so they cannot create sandboxes on macOS. + +## Surrogate process on Windows + +Hyperlight runs a helper process, `hyperlight_surrogate_.exe`, for each sandbox on Windows. The first sandbox writes it to the directory of the running executable, which must be writable. For `dotnet MyApp.dll` that is the .NET installation directory, which needs administrator rights under Program Files. Publish the app with its own executable in a writable directory, or run it once as administrator. + +## Development + +```sh +just dotnet-test # builds the native libraries, a test runtime and component, then runs the tests +just dotnet-pack # packs both packages into dotnet/artifacts +``` + +`HyperlightNativeDir` overrides where the projects find the native libraries. diff --git a/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs b/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs new file mode 100644 index 00000000..9977e358 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs @@ -0,0 +1,145 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Hyperlight.Wasm.Build; + +/// Generates C# bindings for each WIT item. +public sealed unsafe class GenerateHyperlightBindings : Microsoft.Build.Utilities.Task +{ + /// + /// WIT files or directories. Optional metadata: World selects a + /// world and Namespace overrides . + /// + [Required] + public ITaskItem[] Wit { get; set; } = []; + + /// The native hyperlight_wasm_csharp_bindgen library. + [Required] + public string BindgenLibrary { get; set; } = ""; + + /// The directory for generated files. + [Required] + public string OutputDirectory { get; set; } = ""; + + /// The namespace of items without Namespace metadata. + public string DefaultNamespace { get; set; } = ""; + + /// The generated C# files. + [Output] + public ITaskItem[] Generated { get; set; } = []; + + /// hlw_bindgen_csharp. Returns 0 and the C# source, or 1 and an error message. + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int GenerateBindings( + byte* wit, nuint witLength, + byte* world, nuint worldLength, + byte* ns, nuint nsLength, + byte** output, nuint* outputLength); + + /// hlw_bindgen_free. Frees the output of . + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void FreeBindings(byte* output, nuint outputLength); + + /// + public override bool Execute() + { + nint library; + try + { + library = NativeLibrary.Load(BindgenLibrary); + } + catch (Exception e) + { + Log.LogError("Cannot load the Hyperlight bindings generator '{0}': {1}", BindgenLibrary, e.Message); + return false; + } + try + { + if (!NativeLibrary.TryGetExport(library, "hlw_bindgen_csharp", out var generateExport) + || !NativeLibrary.TryGetExport(library, "hlw_bindgen_free", out var freeExport)) + { + Log.LogError("The Hyperlight bindings generator '{0}' does not match this package version.", BindgenLibrary); + return false; + } + var generate = Marshal.GetDelegateForFunctionPointer(generateExport); + var free = Marshal.GetDelegateForFunctionPointer(freeExport); + Directory.CreateDirectory(OutputDirectory); + var generated = new List(); + var outputs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in Wit) + { + var wit = item.GetMetadata("FullPath"); + var world = item.GetMetadata("World"); + var ns = item.GetMetadata("Namespace"); + if (ns.Length == 0) + { + ns = DefaultNamespace; + } + if (!TryGenerate(generate, free, wit, world, ns, out var text)) + { + Log.LogError(null, "HLWIT001", null, item.ItemSpec, 0, 0, 0, 0, "Cannot generate Hyperlight bindings: {0}", text); + continue; + } + var name = Path.GetFileNameWithoutExtension(wit.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (world.Length > 0) + { + name += "." + world; + } + var output = Path.Combine(OutputDirectory, name + ".g.cs"); + if (!outputs.Add(output)) + { + Log.LogError(null, "HLWIT002", null, item.ItemSpec, 0, 0, 0, 0, "Two HyperlightWit items generate '{0}'. Give them different World metadata or file names.", output); + continue; + } + // Rewriting unchanged output would force a recompile. + if (!File.Exists(output) || File.ReadAllText(output) != text) + { + File.WriteAllText(output, text); + } + generated.Add(new TaskItem(output)); + } + Generated = [.. generated]; + return !Log.HasLoggedErrors; + } + finally + { + NativeLibrary.Free(library); + } + } + + private static bool TryGenerate( + GenerateBindings generate, + FreeBindings free, + string wit, + string world, + string ns, + out string text) + { + var witBytes = Encoding.UTF8.GetBytes(wit); + var worldBytes = Encoding.UTF8.GetBytes(world); + var nsBytes = Encoding.UTF8.GetBytes(ns); + byte* output; + nuint length; + int status; + fixed (byte* w = witBytes) + fixed (byte* wo = worldBytes) + fixed (byte* n = nsBytes) + { + status = generate(w, (nuint)witBytes.Length, wo, (nuint)worldBytes.Length, n, (nuint)nsBytes.Length, &output, &length); + } + try + { + text = Encoding.UTF8.GetString(output, checked((int)length)); + } + finally + { + free(output, length); + } + return status == 0; + } +} diff --git a/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj b/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj new file mode 100644 index 00000000..75bb239b --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + true + Experimental. Generates C# bindings for Hyperlight.Wasm from WIT files listed as HyperlightWit items. + hyperlight;wasm;webassembly;component;wit;bindgen + true + + false + $(NoWarn);NU5100;NU5128 + true + + + + + + + + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_csharp_bindgen$(HyperlightNativeSuffix) + + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props new file mode 100644 index 00000000..8072c94d --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets new file mode 100644 index 00000000..a94a0caf --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets @@ -0,0 +1,35 @@ + + + + $(MSBuildThisFileDirectory)../tasks/net8.0/Hyperlight.Wasm.Build.dll + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('Windows'))">hyperlight_wasm_csharp_bindgen.dll + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('Linux'))">libhyperlight_wasm_csharp_bindgen.so + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('OSX'))">libhyperlight_wasm_csharp_bindgen.dylib + $(MSBuildThisFileDirectory)../tasks/net8.0/runtimes/$(NETCoreSdkPortableRuntimeIdentifier)/native/$(_HyperlightBindgenFile) + + + + + + + + + $(IntermediateOutputPath)HyperlightBindings/ + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs b/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs new file mode 100644 index 00000000..7ab7c96c --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Support types for C# bindings generated by hyperlight-component-csharp. +// The encoding is specified in docs/component-wire-format.md. + +#nullable enable +// Members named after WIT types and object overrides need no docs. +#pragma warning disable CS1591 + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; + +namespace Hyperlight.Component +{ + /// Calls guest functions by wire name. + public interface IGuestCaller + { + /// + /// Calls with one encoded buffer per + /// parameter and returns the encoded result. + /// + byte[] Call(string function, byte[][] args); + } + + /// Registers host functions by wire name. + public interface IHostFunctionRegistry + { + /// + /// Registers , which receives one + /// encoded buffer per parameter and returns the encoded result. + /// + void Register(string function, int parameterCount, Func implementation); + } + + /// Thrown when encoded data does not match the expected type. + public sealed class WireFormatException : Exception + { + public WireFormatException(string message) : base(message) { } + } + + /// The payload of a result case without a type. + public readonly struct Unit : IEquatable + { + public bool Equals(Unit other) => true; + public override bool Equals(object? obj) => obj is Unit; + public override int GetHashCode() => 0; + public override string ToString() => "()"; + + public static bool operator ==(Unit left, Unit right) => true; + + public static bool operator !=(Unit left, Unit right) => false; + } + + /// + /// A WIT option whose payload is itself an option. + /// Other options map to nullable types. + /// + public readonly struct Option : IEquatable> + { + private readonly T _value; + + private Option(T value) + { + HasValue = true; + _value = value; + } + + public static Option None => default; + + public static Option Some(T value) => new(value); + + public bool HasValue { get; } + + public T Value => HasValue ? _value : throw new InvalidOperationException("option has no value"); + + public bool Equals(Option other) => + HasValue == other.HasValue && (!HasValue || EqualityComparer.Default.Equals(_value, other._value)); + + public override bool Equals(object? obj) => obj is Option other && Equals(other); + + public override int GetHashCode() => HasValue ? HashCode.Combine(true, _value) : 0; + + public override string ToString() => HasValue ? $"Some({_value})" : "None"; + + public static bool operator ==(Option left, Option right) => left.Equals(right); + + public static bool operator !=(Option left, Option right) => !left.Equals(right); + } + + /// A WIT result. + public readonly struct Result : IEquatable> + { + private readonly TOk _ok; + private readonly TErr _err; + + private Result(bool isOk, TOk ok, TErr err) + { + IsOk = isOk; + _ok = ok; + _err = err; + } + + public static Result FromOk(TOk value) => new(true, value, default!); + + public static Result FromErr(TErr value) => new(false, default!, value); + + public bool IsOk { get; } + + public TOk Ok => IsOk ? _ok : throw new InvalidOperationException("result is an error"); + + public TErr Err => IsOk ? throw new InvalidOperationException("result is not an error") : _err; + + public bool Equals(Result other) => + IsOk == other.IsOk && (IsOk + ? EqualityComparer.Default.Equals(_ok, other._ok) + : EqualityComparer.Default.Equals(_err, other._err)); + + public override bool Equals(object? obj) => obj is Result other && Equals(other); + + public override int GetHashCode() => + IsOk ? HashCode.Combine(true, _ok) : HashCode.Combine(false, _err); + + public override string ToString() => IsOk ? $"Ok({_ok})" : $"Err({_err})"; + + public static bool operator ==(Result left, Result right) => left.Equals(right); + + public static bool operator !=(Result left, Result right) => !left.Equals(right); + } + + /// Encodes values in the Hyperlight component wire format. + public sealed class WireWriter + { + // Throws on lone surrogates, which a WIT string cannot hold. + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private byte[] _buffer; + private int _length; + + public WireWriter(int capacity = 64) + { + _buffer = new byte[Math.Max(capacity, 1)]; + } + + /// The generated world's resource tables, for writing handles. + public object? Resources { get; init; } + + /// The call that handles written here are given or lent in. + public CallScope? Scope { get; init; } + + public byte[] ToArray() => _buffer.AsSpan(0, _length).ToArray(); + + public void Bool(bool value) => U8(value ? (byte)1 : (byte)0); + + public void U8(byte value) => Grow(1)[0] = value; + + public void S8(sbyte value) => Grow(1)[0] = unchecked((byte)value); + + public void U16(ushort value) => BinaryPrimitives.WriteUInt16LittleEndian(Grow(2), value); + + public void S16(short value) => BinaryPrimitives.WriteInt16LittleEndian(Grow(2), value); + + public void U32(uint value) => BinaryPrimitives.WriteUInt32LittleEndian(Grow(4), value); + + public void S32(int value) => BinaryPrimitives.WriteInt32LittleEndian(Grow(4), value); + + public void U64(ulong value) => BinaryPrimitives.WriteUInt64LittleEndian(Grow(8), value); + + public void S64(long value) => BinaryPrimitives.WriteInt64LittleEndian(Grow(8), value); + + public void F32(float value) => BinaryPrimitives.WriteSingleLittleEndian(Grow(4), value); + + public void F64(double value) => BinaryPrimitives.WriteDoubleLittleEndian(Grow(8), value); + + public void Char(Rune value) => U32((uint)value.Value); + + public void String(string value) + { + ArgumentNullException.ThrowIfNull(value); + var length = StrictUtf8.GetByteCount(value); + Length(length); + StrictUtf8.GetBytes(value, Grow(length)); + } + + /// Writes the element count of a list. + public void Length(int count) => U32(checked((uint)count)); + + /// Writes a list<u8>. + public void Bytes(byte[] value) + { + ArgumentNullException.ThrowIfNull(value); + Length(value.Length); + value.CopyTo(Grow(value.Length)); + } + + /// Writes a list<u8, N>. + public void FixedBytes(byte[] value, int count) + { + CheckFixedLength(value, count); + value.CopyTo(Grow(count)); + } + + /// Writes the low bytes of a flags bitmask. + public void Flags(uint bits, int byteCount) + { + var bytes = Grow(byteCount); + for (var i = 0; i < byteCount; i++) + { + bytes[i] = (byte)(bits >> (8 * i)); + } + } + + /// Throws unless a fixed-length list has elements. + public static void CheckFixedLength(IReadOnlyCollection value, int count) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Count != count) + { + throw new ArgumentException($"fixed-length list needs {count} elements, got {value.Count}"); + } + } + + private Span Grow(int count) + { + if (_buffer.Length - _length < count) + { + Array.Resize(ref _buffer, Math.Max(checked(_length + count), _buffer.Length * 2)); + } + var span = _buffer.AsSpan(_length, count); + _length += count; + return span; + } + } + + /// Decodes values in the Hyperlight component wire format. + public ref struct WireReader + { + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private readonly ReadOnlySpan _data; + private int _position; + + public WireReader(ReadOnlySpan data) + { + _data = data; + _position = 0; + } + + /// The generated world's resource tables, for reading handles. + public object? Resources { get; init; } + + public bool Bool() => U8() != 0; + + public byte U8() => Take(1)[0]; + + public sbyte S8() => unchecked((sbyte)Take(1)[0]); + + public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2)); + + public short S16() => BinaryPrimitives.ReadInt16LittleEndian(Take(2)); + + public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4)); + + public int S32() => BinaryPrimitives.ReadInt32LittleEndian(Take(4)); + + public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8)); + + public long S64() => BinaryPrimitives.ReadInt64LittleEndian(Take(8)); + + public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4)); + + public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8)); + + public Rune Char() + { + var value = U32(); + if (!Rune.IsValid(value)) + { + throw new WireFormatException($"invalid char {value:x}"); + } + return new Rune(value); + } + + public string String() + { + var bytes = Take(Length()); + try + { + return StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException e) + { + throw new WireFormatException($"invalid UTF-8: {e.Message}"); + } + } + + /// Reads the element count of a list. + public int Length() + { + var count = U32(); + // Every element takes at least one byte. + if (count > (uint)(_data.Length - _position)) + { + throw new WireFormatException($"list length {count} exceeds remaining input"); + } + return (int)count; + } + + /// Reads a list<u8>. + public byte[] Bytes() => Take(Length()).ToArray(); + + /// Reads a list<u8, N>. + public byte[] FixedBytes(int count) => Take(count).ToArray(); + + /// Reads a four byte tag that must be below . + public uint Tag32(uint cases) + { + var tag = U32(); + if (tag >= cases) + { + throw new WireFormatException($"invalid tag {tag}"); + } + return tag; + } + + /// + /// Reads a flags bitmask of bytes + /// and keeps the bits in . + /// + public uint Flags(int byteCount, uint mask) + { + var bytes = Take(byteCount); + uint bits = 0; + for (var i = 0; i < byteCount; i++) + { + bits |= (uint)bytes[i] << (8 * i); + } + return bits & mask; + } + + private ReadOnlySpan Take(int count) + { + if ((uint)count > (uint)(_data.Length - _position)) + { + throw new WireFormatException("unexpected end of input"); + } + var span = _data.Slice(_position, count); + _position += count; + return span; + } + } + + /// + /// Host objects of one resource type that the guest refers to by + /// handle. A handle is an index into the table. + /// + public sealed class ResourceTable where T : class + { + private readonly List _entries = new(); + + private sealed class Entry + { + public T? Value; + public bool Lent; + } + + /// + /// Gives to the guest. With + /// , the gift is undone unless the call + /// starts. + /// + public uint Give(T value, CallScope? scope = null) + { + var handle = Add(value, lent: false); + scope?.AddGift(() => Clear(handle)); + return handle; + } + + /// Lends to the guest until ends. + public uint Lend(T value, CallScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + var handle = Add(value, lent: true); + scope.AddLoan(() => Clear(handle)); + return handle; + } + + private void Clear(uint handle) + { + lock (_entries) + { + _entries[(int)handle].Value = null; + } + } + + /// Takes back a value the guest owns. + public T Take(uint handle) + { + lock (_entries) + { + var entry = Get(handle); + if (entry.Lent || entry.Value is null) + { + throw new WireFormatException($"resource handle {handle} is not owned"); + } + var value = entry.Value; + entry.Value = null; + return value; + } + } + + /// Returns the value for a handle the guest lends back to the host. + public T Borrow(uint handle) + { + lock (_entries) + { + return Get(handle).Value ?? throw new WireFormatException($"resource handle {handle} is not valid"); + } + } + + private uint Add(T value, bool lent) + { + ArgumentNullException.ThrowIfNull(value); + lock (_entries) + { + _entries.Add(new Entry { Value = value, Lent = lent }); + return checked((uint)(_entries.Count - 1)); + } + } + + private Entry Get(uint handle) => + handle < (uint)_entries.Count + ? _entries[(int)handle] + : throw new WireFormatException($"invalid resource handle {handle}"); + } + + /// + /// Resources lent to the guest for one call, and resources given to it + /// before the call starts. + /// + public sealed class CallScope + { + private List? _loans; + private List? _gifts; + + internal void AddLoan(Action revoke) => (_loans ??= new()).Add(revoke); + + internal void AddGift(Action undo) => (_gifts ??= new()).Add(undo); + + /// Marks the call as started, which completes the gifts. + public void Commit() => _gifts?.Clear(); + + /// Revokes every loan, and undoes gifts made before . + public void End() + { + Run(_gifts); + Run(_loans); + } + + private static void Run(List? actions) + { + if (actions is null) + { + return; + } + foreach (var action in actions) + { + action(); + } + actions.Clear(); + } + } +} diff --git a/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj b/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj new file mode 100644 index 00000000..1bfe9890 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + true + true + Experimental. Runs WebAssembly components in Hyperlight micro VMs. Use with Hyperlight.Wasm.Build to generate typed bindings from WIT. + hyperlight;wasm;webassembly;component;sandbox + + + + + + + + + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_capi$(HyperlightNativeSuffix) + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm/HyperlightException.cs b/dotnet/src/Hyperlight.Wasm/HyperlightException.cs new file mode 100644 index 00000000..b22c4032 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/HyperlightException.cs @@ -0,0 +1,39 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +namespace Hyperlight.Wasm; + +/// The kind of a . +public enum HyperlightErrorKind +{ + /// Any error not covered by another kind. + Other = 0, + + /// + /// The sandbox is poisoned by an interrupted or failed guest call. + /// Restore a snapshot to use it again. + /// + Poisoned = 1, + + /// A guest call was interrupted. + Cancelled = 2, + + /// The operation is not valid in the sandbox's current state. + InvalidState = 3, + + /// An argument is invalid. + InvalidArgument = 4, +} + +/// An error reported by Hyperlight. +public sealed class HyperlightException : Exception +{ + internal HyperlightException(HyperlightErrorKind kind, string message, Exception? inner) + : base(message, inner) + { + Kind = kind; + } + + /// The kind of error. + public HyperlightErrorKind Kind { get; } +} diff --git a/dotnet/src/Hyperlight.Wasm/Native.cs b/dotnet/src/Hyperlight.Wasm/Native.cs new file mode 100644 index 00000000..4e35096a --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Native.cs @@ -0,0 +1,194 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Hyperlight.Wasm; + +/// Declarations from hyperlight_wasm.h. +internal static unsafe partial class Native +{ + private const string Library = "hyperlight_wasm_capi"; + + [StructLayout(LayoutKind.Sequential)] + internal struct Bytes + { + public byte* Data; + public nuint Len; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Buf + { + public byte* Data; + public nuint Len; + } + + [LibraryImport(Library)] + internal static partial nint hlw_error_new(byte* message, nuint len); + + [LibraryImport(Library)] + internal static partial uint hlw_error_kind(nint error); + + [LibraryImport(Library)] + internal static partial void hlw_error_message(nint error, out byte* message, out nuint len); + + [LibraryImport(Library)] + internal static partial void hlw_error_delete(nint error); + + [LibraryImport(Library)] + internal static partial void hlw_buf_delete(Buf* buf); + + [LibraryImport(Library)] + internal static partial nint hlw_config_new(); + + [LibraryImport(Library)] + internal static partial void hlw_config_delete(nint config); + + [LibraryImport(Library)] + internal static partial void hlw_config_scratch_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial void hlw_config_h2g_buffer_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial void hlw_config_h2g_pool_pages(nint config, nuint pages); + + [LibraryImport(Library)] + internal static partial void hlw_config_h2g_queue_size(nint config, nuint entries); + + [LibraryImport(Library)] + internal static partial void hlw_config_g2h_buffer_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial void hlw_config_g2h_pool_pages(nint config, nuint pages); + + [LibraryImport(Library)] + internal static partial void hlw_config_g2h_queue_size(nint config, nuint entries); + + [LibraryImport(Library)] + internal static partial nint hlw_config_runtime_file(nint config, byte* path, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_config_interrupt_signal_offset(nint config, byte offset); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_new(nint config, out SandboxHandle sandbox); + + [LibraryImport(Library)] + internal static partial void hlw_sandbox_delete(nint sandbox); + + [LibraryImport(Library)] + internal static partial void hlw_result_set(nint result, byte* data, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_register( + SandboxHandle sandbox, + byte* name, + nuint nameLen, + nuint nargs, + delegate* unmanaged func, + nint env, + delegate* unmanaged finalizer); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_load_file(SandboxHandle sandbox, byte* path, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_load(SandboxHandle sandbox, byte* data, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_call( + SandboxHandle sandbox, + byte* name, + nuint nameLen, + Bytes* args, + nuint nargs, + Buf* result); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_snapshot(SandboxHandle sandbox, out SnapshotHandle snapshot); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_restore(SandboxHandle sandbox, SnapshotHandle snapshot); + + [LibraryImport(Library)] + internal static partial void hlw_snapshot_delete(nint snapshot); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_interrupt_handle(SandboxHandle sandbox, out InterruptHandleHandle handle); + + [LibraryImport(Library)] + [return: MarshalAs(UnmanagedType.U1)] + internal static partial bool hlw_interrupt_handle_kill(InterruptHandleHandle handle); + + [LibraryImport(Library)] + internal static partial void hlw_interrupt_handle_delete(nint handle); + + /// Throws if is not null, and frees it. + internal static void Check(nint error, Exception? inner = null) + { + if (error != 0) + { + throw TakeError(error, inner); + } + } + + internal static HyperlightException TakeError(nint error, Exception? inner = null) + { + try + { + hlw_error_message(error, out var message, out var len); + var text = System.Text.Encoding.UTF8.GetString(message, checked((int)len)); + return new HyperlightException((HyperlightErrorKind)hlw_error_kind(error), text, inner); + } + finally + { + hlw_error_delete(error); + } + } + + /// Creates a native error that the library takes ownership of. + internal static nint NewError(string message) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(message); + fixed (byte* p = bytes) + { + return hlw_error_new(p, (nuint)bytes.Length); + } + } +} + +internal sealed class SandboxHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public SandboxHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_sandbox_delete(handle); + return true; + } +} + +internal sealed class SnapshotHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public SnapshotHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_snapshot_delete(handle); + return true; + } +} + +internal sealed class InterruptHandleHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public InterruptHandleHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_interrupt_handle_delete(handle); + return true; + } +} diff --git a/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs b/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs new file mode 100644 index 00000000..bb0b6a47 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs @@ -0,0 +1,61 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +namespace Hyperlight.Wasm; + +/// Settings for a . Unset values use defaults. +public sealed class SandboxOptions +{ + /// + /// The signal offset from SIGRTMIN used unless + /// is set. The .NET runtime uses + /// SIGRTMIN itself to suspend threads. + /// + public const byte DefaultInterruptSignalOffset = 1; + + /// + /// The guest scratch size in bytes. Scratch holds the guest heap, stack + /// and transport buffers. Values below the default are ignored. + /// + public ulong? ScratchSize { get; set; } + + /// + /// The size in bytes of each host-to-guest buffer. These buffers carry + /// guest calls and components loaded from memory. A message spans + /// several buffers, so the pool and queue must hold the largest message. + /// + public ulong? H2GBufferSize { get; set; } + + /// The guest pages for host-to-guest buffers. Values of 48 or less are ignored. + public ulong? H2GPoolPages { get; set; } + + /// The number of in-flight host-to-guest buffers. + public ulong? H2GQueueSize { get; set; } + + /// + /// The size in bytes of each guest-to-host buffer. These buffers carry + /// host calls and guest results. + /// + public ulong? G2HBufferSize { get; set; } + + /// The guest pages for guest-to-host buffers. + public ulong? G2HPoolPages { get; set; } + + /// The number of in-flight guest-to-host buffers. + public ulong? G2HQueueSize { get; set; } + + /// + /// A guest runtime file built for the component's WIT world by the same + /// hyperlight-wasm version as this package. Components need it unless + /// the native library was built for their world with WIT_WORLD. + /// The packaged library has no world. + /// + public string? RuntimePath { get; set; } + + /// + /// The offset from SIGRTMIN of the signal that interrupts a + /// running guest on Linux. Change it only if the process uses that + /// signal itself. Ignored on Windows and macOS. + /// + public byte InterruptSignalOffset { get; set; } = DefaultInterruptSignalOffset; +} diff --git a/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs b/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs new file mode 100644 index 00000000..8ce05a3d --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs @@ -0,0 +1,417 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Hyperlight.Component; + +namespace Hyperlight.Wasm; + +/// +/// A Hyperlight micro VM that runs one WebAssembly component. +/// +/// +/// Register host functions, then load a precompiled component, then call +/// its exports. Generated bindings do this through +/// and . +/// Calls are serialized. Host functions run on the calling thread. Calls +/// from a host function back into its own sandbox throw. +/// +public sealed unsafe class WasmSandbox : IGuestCaller, IHostFunctionRegistry, IDisposable +{ + private readonly SandboxHandle _handle; + private readonly CallState _state = new(); + private readonly object _lock = new(); + private bool _loaded; + private bool _inCall; + private InterruptHandle? _cancelHandle; + + /// Creates a sandbox with , or defaults if null. + /// No hypervisor is available, or the settings are invalid. + public WasmSandbox(SandboxOptions? options = null) + { + options ??= new SandboxOptions(); + var config = Native.hlw_config_new(); + try + { + if (options.ScratchSize is { } scratch) + { + Native.hlw_config_scratch_size(config, checked((nuint)scratch)); + } + Set(config, options.H2GBufferSize, &Native.hlw_config_h2g_buffer_size); + Set(config, options.H2GPoolPages, &Native.hlw_config_h2g_pool_pages); + Set(config, options.H2GQueueSize, &Native.hlw_config_h2g_queue_size); + Set(config, options.G2HBufferSize, &Native.hlw_config_g2h_buffer_size); + Set(config, options.G2HPoolPages, &Native.hlw_config_g2h_pool_pages); + Set(config, options.G2HQueueSize, &Native.hlw_config_g2h_queue_size); + if (options.RuntimePath is { } runtime) + { + var path = Encoding.UTF8.GetBytes(Path.GetFullPath(runtime)); + fixed (byte* p = path) + { + Native.Check(Native.hlw_config_runtime_file(config, p, (nuint)path.Length)); + } + } + Native.Check(Native.hlw_config_interrupt_signal_offset(config, options.InterruptSignalOffset)); + Native.Check(Native.hlw_sandbox_new(config, out _handle)); + } + finally + { + Native.hlw_config_delete(config); + } + } + + /// + /// Registers a host function. It receives one encoded buffer per + /// parameter and returns the encoded result. Exceptions it throws fail + /// the guest call, which then throws a + /// with the exception as . + /// + /// A component is already loaded, or the parameter count exceeds 32. + public void Register(string function, int parameterCount, Func implementation) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentNullException.ThrowIfNull(implementation); + ArgumentOutOfRangeException.ThrowIfNegative(parameterCount); + var name = Encoding.UTF8.GetBytes(function); + lock (_lock) + { + ThrowIfDisposed(); + // The native library frees this handle through Release, also when + // registration fails. + var env = GCHandle.ToIntPtr(GCHandle.Alloc(new HostFunction(implementation, _state))); + fixed (byte* p = name) + { + Native.Check(Native.hlw_sandbox_register( + _handle, + p, + (nuint)name.Length, + (nuint)parameterCount, + &Invoke, + env, + &Release)); + } + } + } + + /// + /// Loads a component precompiled with hyperlight-wasm-aot. Loading runs + /// the component's start functions, which cannot be interrupted. After a + /// failed load the sandbox can only be disposed. + /// + public void Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + var bytes = Encoding.UTF8.GetBytes(Path.GetFullPath(path)); + lock (_lock) + { + ThrowIfDisposed(); + _state.HostException = null; + fixed (byte* p = bytes) + { + Native.Check(Native.hlw_sandbox_load_file(_handle, p, (nuint)bytes.Length), _state.HostException); + } + _loaded = true; + } + } + + /// + /// Loads a component precompiled with hyperlight-wasm-aot from memory, + /// as does. + /// + public void Load(ReadOnlySpan component) + { + lock (_lock) + { + ThrowIfDisposed(); + _state.HostException = null; + fixed (byte* p = component) + { + Native.Check(Native.hlw_sandbox_load(_handle, p, (nuint)component.Length), _state.HostException); + } + _loaded = true; + } + } + + /// + public byte[] Call(string function, byte[][] args) => Call(function, args, CancellationToken.None); + + /// + /// Calls the guest function with one encoded + /// buffer per parameter and returns the encoded result. + /// + /// + /// Cancelling interrupts the guest + /// and poisons the sandbox. A guest in a host function stops when that + /// function returns. + /// + /// The call was cancelled. + /// The call failed. + public byte[] Call(string function, byte[][] args, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentNullException.ThrowIfNull(args); + var name = Encoding.UTF8.GetBytes(function); + var pins = new GCHandle[args.Length]; + var views = new Native.Bytes[args.Length]; + lock (_lock) + { + ThrowIfDisposed(); + if (_inCall) + { + throw new InvalidOperationException("A host function cannot call into its own sandbox."); + } + cancellationToken.ThrowIfCancellationRequested(); + var registration = default(CancellationTokenRegistration); + Cancellation? cancellation = null; + if (cancellationToken.CanBeCanceled && _loaded) + { + _cancelHandle ??= CreateInterruptHandle(); + cancellation = new Cancellation(_cancelHandle); + registration = cancellationToken.Register(static c => ((Cancellation)c!).Cancel(), cancellation); + } + _inCall = true; + _state.HostException = null; + try + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i] ?? throw new ArgumentNullException(nameof(args), $"Argument {i} is null."); + pins[i] = GCHandle.Alloc(arg, GCHandleType.Pinned); + views[i] = new Native.Bytes + { + Data = (byte*)pins[i].AddrOfPinnedObject(), + Len = (nuint)arg.Length, + }; + } + Native.Buf result = default; + nint error; + fixed (byte* p = name) + fixed (Native.Bytes* v = views) + { + error = Native.hlw_sandbox_call(_handle, p, (nuint)name.Length, v, (nuint)views.Length, &result); + } + registration.Dispose(); + cancellation?.Stop(); + if (error != 0) + { + var exception = Native.TakeError(error, _state.HostException); + if (exception.Kind == HyperlightErrorKind.Cancelled && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(exception.Message, exception, cancellationToken); + } + throw exception; + } + try + { + return new ReadOnlySpan(result.Data, checked((int)result.Len)).ToArray(); + } + finally + { + Native.hlw_buf_delete(&result); + } + } + finally + { + registration.Dispose(); + cancellation?.Stop(); + _inCall = false; + foreach (var pin in pins) + { + if (pin.IsAllocated) + { + pin.Free(); + } + } + } + } + } + + /// Saves the state of the loaded sandbox. + public Snapshot CreateSnapshot() + { + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_snapshot(_handle, out var snapshot)); + return new Snapshot(this, snapshot); + } + } + + /// + /// Restores , which must come from this + /// sandbox. Restoring also recovers a poisoned sandbox. + /// + public void Restore(Snapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (snapshot.Owner != this) + { + throw new ArgumentException("The snapshot comes from another sandbox.", nameof(snapshot)); + } + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_restore(_handle, snapshot.Handle)); + } + } + + /// + /// Returns a handle that interrupts guest calls from any thread. The + /// sandbox must be loaded. Creating a handle waits for a running call, + /// so create it before the call it interrupts. + /// + public InterruptHandle CreateInterruptHandle() + { + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_interrupt_handle(_handle, out var handle)); + return new InterruptHandle(handle); + } + } + + /// Frees the micro VM and the registered host functions. + public void Dispose() + { + lock (_lock) + { + _cancelHandle?.Dispose(); + _handle.Dispose(); + } + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_handle.IsClosed, this); + + private static void Set(nint config, ulong? value, delegate* setter) + { + if (value is { } v) + { + setter(config, checked((nuint)v)); + } + } + + [UnmanagedCallersOnly] + private static nint Invoke(nint env, Native.Bytes* args, nuint nargs, nint result) + { + var function = (HostFunction)GCHandle.FromIntPtr(env).Target!; + try + { + var managed = new byte[checked((int)nargs)][]; + for (var i = 0; i < managed.Length; i++) + { + managed[i] = new ReadOnlySpan(args[i].Data, checked((int)args[i].Len)).ToArray(); + } + var bytes = function.Implementation(managed) ?? []; + fixed (byte* p = bytes) + { + Native.hlw_result_set(result, p, (nuint)bytes.Length); + } + return 0; + } + catch (Exception e) + { + function.State.HostException ??= e; + // The message reaches the guest. The caller gets the exception + // as the InnerException. + return Native.NewError("The host function threw an exception."); + } + } + + [UnmanagedCallersOnly] + private static void Release(nint env) => GCHandle.FromIntPtr(env).Free(); + + /// State shared with host functions, which must not keep the sandbox alive. + private sealed class CallState + { + public Exception? HostException; + } + + /// + /// Interrupts one guest call when its token is cancelled. A kill before + /// the guest starts is lost, so a failed kill is retried until the call + /// ends. The retry runs on the thread pool because the token can be + /// cancelled from a host function on the calling thread. + /// + private sealed class Cancellation(InterruptHandle handle) + { + private volatile bool _done; + private Task? _retry; + + public void Cancel() + { + if (!TryKill()) + { + _retry = Task.Run(Retry); + } + } + + /// Stops retrying. Call after disposing the token registration. + public void Stop() + { + _done = true; + _retry?.Wait(); + } + + private void Retry() + { + var spin = new SpinWait(); + while (!_done && !TryKill()) + { + spin.SpinOnce(); + } + } + + // The sandbox can be disposed from a host function during the call. + private bool TryKill() + { + try + { + return handle.Kill(); + } + catch (ObjectDisposedException) + { + return true; + } + } + } + + private sealed record HostFunction(Func Implementation, CallState State); +} + +/// A saved sandbox state. +public sealed class Snapshot : IDisposable +{ + internal Snapshot(WasmSandbox owner, SnapshotHandle handle) + { + Owner = owner; + Handle = handle; + } + + internal WasmSandbox Owner { get; } + + internal SnapshotHandle Handle { get; } + + /// + public void Dispose() => Handle.Dispose(); +} + +/// Interrupts guest calls of a sandbox from any thread. +public sealed class InterruptHandle : IDisposable +{ + private readonly InterruptHandleHandle _handle; + + internal InterruptHandle(InterruptHandleHandle handle) => _handle = handle; + + /// + /// Interrupts the running guest call, which then fails with + /// and poisons the sandbox. + /// A guest in a host function stops when that function returns. + /// + /// Whether the guest was running. + public bool Kill() => Native.hlw_interrupt_handle_kill(_handle); + + /// + public void Dispose() => _handle.Dispose(); +} diff --git a/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs b/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs new file mode 100644 index 00000000..80ddf84e --- /dev/null +++ b/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs @@ -0,0 +1,363 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using Hyperlight.Wasm.Tests.Sample; +using Hyperlight.Wasm.Tests.Sample.ComponentSample.Example; +using Xunit; + +namespace Hyperlight.Wasm.Tests; + +public sealed class ComponentTests +{ + private static readonly string Assets = Path.Combine(AppContext.BaseDirectory, "assets"); + private static readonly string Component = Path.Combine(Assets, "component_sample.aot"); + + private sealed class Host : IHost + { + public List Printed { get; } = []; + + public Func Reply { get; set; } = input => $"{input} and the host!"; + + public void Print(string message) => Printed.Add(message); + + public string HostFunction(string input) => Reply(input); + } + + private static SandboxOptions Options() => new() + { + ScratchSize = 100 << 20, + // Room to load the component from memory. + H2GPoolPages = 128, + H2GQueueSize = 128, + RuntimePath = Path.Combine(Assets, "component-sample-runtime"), + }; + + private static (WasmSandbox Sandbox, ExampleComponent Component) Load(Host host, bool fromMemory = false) + { + var sandbox = new WasmSandbox(Options()); + ExampleComponent.RegisterImports(sandbox, host); + if (fromMemory) + { + sandbox.Load(File.ReadAllBytes(Component)); + } + else + { + sandbox.Load(Component); + } + return (sandbox, new ExampleComponent(sandbox)); + } + + [Fact] + public void CallsExportsAndImports() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + { + Assert.Equal(3u, component.Adder.Add(1, 2)); + Assert.Equal("Hello from component and the host!", component.Adder.CallHost("Hello")); + component.Adder.DoSomething(42); + Assert.Equal(["42"], host.Printed); + } + } + + [Fact] + public void LoadsFromMemory() + { + var (sandbox, component) = Load(new Host(), fromMemory: true); + using (sandbox) + { + Assert.Equal(42u, component.Adder.Add(20, 22)); + } + } + + [Fact] + public void HostExceptionsBecomeInnerExceptions() + { + var failure = new InvalidDataException("host failure"); + var host = new Host { Reply = _ => throw failure }; + var (sandbox, component) = Load(host); + using (sandbox) + { + var e = Assert.Throws(() => component.Adder.CallHost("Hello")); + Assert.Same(failure, e.InnerException); + // The message reaches the guest, so it holds no host details. + Assert.DoesNotContain("host failure", e.Message); + } + } + + [Fact] + public void RestoreRecoversFromTrap() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + { +#if DEBUG + // Only the debug build of the sample traps on overflow. + var trap = Assert.Throws(() => component.Adder.Add(uint.MaxValue, 1)); + Assert.Contains("add_overflow", trap.Message); + Assert.Throws(() => component.Adder.Add(1, 1)); +#endif + sandbox.Restore(snapshot); + Assert.Equal(7u, component.Adder.Add(3, 4)); + } + } + + [Fact] + public void RestoreResetsGuestState() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + { + Assert.Equal(1u, component.Adder.Increment()); + using var first = sandbox.CreateSnapshot(); + Assert.Equal(2u, component.Adder.Increment()); + using var second = sandbox.CreateSnapshot(); + Assert.Equal(3u, component.Adder.Increment()); + + sandbox.Restore(first); + Assert.Equal(2u, component.Adder.Increment()); + sandbox.Restore(second); + Assert.Equal(3u, component.Adder.Increment()); + sandbox.Restore(first); + Assert.Equal(2u, component.Adder.Increment()); + } + } + + [Fact] + public void RestoreAfterHostFailureResetsGuestState() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + { + Assert.Equal(1u, component.Adder.Increment()); + host.Reply = _ => throw new InvalidOperationException(); + Assert.Throws(() => component.Adder.CallHost("Hello")); + + sandbox.Restore(snapshot); + host.Reply = input => input; + Assert.Equal("Hello from component", component.Adder.CallHost("Hello")); + Assert.Equal(1u, component.Adder.Increment()); + } + } + + [Fact] + public void RepeatedSnapshotsAndRestores() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + { + for (var i = 0; i < 1000; i++) + { + Assert.Equal(1u, component.Adder.Increment()); + sandbox.CreateSnapshot().Dispose(); + sandbox.Restore(snapshot); + } + } + } + + [Fact] + public void RejectsReentrantUse() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + { + Action[] reentries = + [ + () => component.Adder.Add(1, 1), + () => sandbox.Load(Component), + () => sandbox.CreateSnapshot(), + () => sandbox.Restore(snapshot), + () => sandbox.CreateInterruptHandle(), + () => sandbox.Register("late", 0, _ => []), + ]; + foreach (var reenter in reentries) + { + host.Reply = _ => + { + reenter(); + return ""; + }; + var e = Assert.Throws(() => component.Adder.CallHost("Hello")); + Assert.True( + e.InnerException is InvalidOperationException or HyperlightException { Kind: HyperlightErrorKind.InvalidState }, + e.InnerException?.ToString()); + sandbox.Restore(snapshot); + } + Assert.Equal(3u, component.Adder.Add(1, 2)); + } + } + + [Fact] + public void CancellationAndInterrupts() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + using (var handle = sandbox.CreateInterruptHandle()) + { + Assert.False(handle.Kill()); + Assert.Equal(2u, component.Adder.Add(1, 1)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var args = new[] { new byte[4], new byte[4] }; + Assert.Throws( + () => sandbox.Call("component-sample:example/adder#add", args, cts.Token)); + using var live = new CancellationTokenSource(); + var result = sandbox.Call("component-sample:example/adder#add", args, live.Token); + Assert.Equal(new byte[4], result); + } + } + + [Fact] + public void CancellationInterruptsRunningGuest() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + using (var cts = new CancellationTokenSource()) + { + host.Reply = input => + { + cts.Cancel(); + return input; + }; + var hello = Encoding.UTF8.GetBytes("Hello"); + byte[] arg = [.. BitConverter.GetBytes((uint)hello.Length), .. hello]; + Assert.Throws( + () => sandbox.Call("component-sample:example/adder#call-host", [arg], cts.Token)); + + sandbox.Restore(snapshot); + Assert.Equal(3u, component.Adder.Add(1, 2)); + } + } + + [Fact] + public void ValidatesState() + { + var (sandbox, _) = Load(new Host()); + var (other, _) = Load(new Host()); + using (other) + using (var foreign = other.CreateSnapshot()) + { + Assert.Throws(() => sandbox.Restore(foreign)); + var late = Assert.Throws(() => sandbox.Register("late", 0, _ => [])); + Assert.Equal(HyperlightErrorKind.InvalidState, late.Kind); + var missing = Assert.Throws(() => sandbox.Call("missing", [])); + Assert.Contains("missing", missing.Message); + sandbox.Dispose(); + sandbox.Dispose(); + Assert.Throws(() => sandbox.Call("missing", [])); + } + + using var empty = new WasmSandbox(Options()); + var unloaded = Assert.Throws(() => empty.Call("x", [])); + Assert.Equal(HyperlightErrorKind.InvalidState, unloaded.Kind); + } + + [Fact] + public void ReportsMissingRuntime() + { + var options = Options(); + options.RuntimePath = Path.Combine(Assets, "no-such-runtime"); + Assert.Throws(() => new WasmSandbox(options)); + } + + [Fact] + public async Task SurvivesGarbageCollection() + { + // GC.Collect in a host function runs while the guest call is active. + var host = new Host(); + host.Reply = input => + { + GC.Collect(); + return input; + }; + var (sandbox, component) = Load(host); + using (sandbox) + { + using var stop = new CancellationTokenSource(); + var churn = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + _ = new byte[64 * 1024]; + GC.Collect(0); + } + }); + for (var i = 0; i < 500; i++) + { + Assert.Equal((uint)(2 * i), component.Adder.Add((uint)i, (uint)i)); + Assert.Equal($"{i} from component", component.Adder.CallHost(i.ToString())); + } + stop.Cancel(); + await churn; + } + } +} + +public sealed class SignalTests +{ + private static volatile bool _stop; + + /// + /// The GC suspends a thread spinning in managed code with SIGRTMIN. With + /// interrupt signal offset 0 the sandbox replaces that handler and this + /// test hangs. + /// + [Fact] + public async Task GcSuspendsSpinningThreadAfterSandboxLoads() + { + var assets = Path.Combine(AppContext.BaseDirectory, "assets"); + using var sandbox = new WasmSandbox(new SandboxOptions + { + ScratchSize = 100 << 20, + RuntimePath = Path.Combine(assets, "component-sample-runtime"), + }); + ExampleComponent.RegisterImports(sandbox, new NoHost()); + sandbox.Load(Path.Combine(assets, "component_sample.aot")); + + _stop = false; + var spinner = new Thread(Spin) { IsBackground = true }; + spinner.Start(); + try + { + await Task.Run(() => + { + for (var i = 0; i < 20; i++) + { + GC.Collect(); + } + }).WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + _stop = true; + } + spinner.Join(); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static void Spin() + { + // No calls or allocations, so the thread has no GC safe point. + while (!_stop) + { + } + } + + private sealed class NoHost : IHost + { + public void Print(string message) { } + + public string HostFunction(string input) => input; + } +} diff --git a/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj b/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj new file mode 100644 index 00000000..00dc0cc4 --- /dev/null +++ b/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj @@ -0,0 +1,44 @@ + + + + net8.0 + Major + false + true + Hyperlight.Wasm.Tests + $(MSBuildThisFileDirectory)../../../ + + $(MSBuildThisFileDirectory)../../src/Hyperlight.Wasm.Build/bin/$(Configuration)/net8.0/Hyperlight.Wasm.Build.dll + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_csharp_bindgen$(HyperlightNativeSuffix) + + + + + + + + + + + + + + + + + + + + + + aarch64 + x86_64 + + + + + + + + + diff --git a/src/hyperlight_wasm/Cargo.toml b/src/hyperlight_wasm/Cargo.toml index a646f366..889e5edf 100644 --- a/src/hyperlight_wasm/Cargo.toml +++ b/src/hyperlight_wasm/Cargo.toml @@ -125,6 +125,8 @@ pulley = [] trace_guest = ["hyperlight-host/trace_guest"] # Use latest wasmtime instead of the default LTS version in wasm_runtime wasmtime_latest = [] +# SandboxBuilder::with_runtime_file loads the guest runtime from a file +experimental_runtime_file = [] [[bench]] name = "benchmarks" diff --git a/src/hyperlight_wasm/src/sandbox/proto_wasm_sandbox.rs b/src/hyperlight_wasm/src/sandbox/proto_wasm_sandbox.rs index afe00c73..7743c87d 100644 --- a/src/hyperlight_wasm/src/sandbox/proto_wasm_sandbox.rs +++ b/src/hyperlight_wasm/src/sandbox/proto_wasm_sandbox.rs @@ -36,6 +36,7 @@ use crate::build_info::BuildInfo; pub struct ProtoWasmSandbox { pub(super) inner: Option, host_function_definitions: HashMap, + runtime_file: bool, } impl Registerable for ProtoWasmSandbox { @@ -74,9 +75,13 @@ impl ProtoWasmSandbox { /// `LoadedWasmSandbox` returned from the `load_module` method on the `WasmSandbox`, you can /// use the `max_execution_time` and `max_wait_for_cancellation` /// fields in the `SandboxConfiguration` struct. + /// + /// `runtime_file` is whether `guest_binary` is a runtime loaded from a + /// file, whose snapshots cannot be saved. pub(super) fn new( cfg: Option, guest_binary: GuestBinary, + runtime_file: bool, ) -> Result { BuildInfo::log(); let inner = UninitializedSandbox::new(guest_binary, cfg)?; @@ -87,6 +92,7 @@ impl ProtoWasmSandbox { Ok(Self { inner: Some(inner), host_function_definitions, + runtime_file, }) } @@ -124,7 +130,7 @@ impl ProtoWasmSandbox { )); } - WasmSandbox::new(sandbox) + WasmSandbox::new(sandbox, self.runtime_file) } /// Register the given host function `host_func` with `self` under diff --git a/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs b/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs index 630ef591..be784a16 100644 --- a/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs @@ -44,6 +44,8 @@ pub struct SandboxBuilder { source: S, config: SandboxConfiguration, host_print_fn: Option>, + #[cfg(feature = "experimental_runtime_file")] + runtime_file: Option, } impl SandboxBuilder { @@ -52,6 +54,8 @@ impl SandboxBuilder { source, config, host_print_fn: None, + #[cfg(feature = "experimental_runtime_file")] + runtime_file: None, } } @@ -94,6 +98,17 @@ impl SandboxBuilder { self.config.set_guest_core_dump(enabled); self } + + /// Set the offset from `SIGRTMIN` of the signal used to interrupt a + /// running guest. + /// + /// Change it when the host process already uses `SIGRTMIN`. The .NET + /// runtime does, for example. + #[cfg(target_os = "linux")] + pub fn with_interrupt_vcpu_sigrtmin_offset(mut self, offset: u8) -> Result { + self.config.set_interrupt_vcpu_sigrtmin_offset(offset)?; + Ok(self) + } } impl SandboxBuilder { @@ -181,15 +196,35 @@ impl SandboxBuilder { self } + /// Load the guest runtime from `path` instead of using the one built + /// into this crate. Experimental. + /// + /// The runtime must be built by the same version of hyperlight-wasm. + /// A runtime built with `WIT_WORLD` set can only load components of + /// that world. Snapshots of the sandbox cannot be saved. + #[cfg(feature = "experimental_runtime_file")] + pub fn with_runtime_file(mut self, path: impl Into) -> Self { + self.runtime_file = Some(path.into()); + self + } + /// Build the ProtoWasmSandbox pub fn build(self) -> Result { if !is_hypervisor_present() { return Err(HyperlightError::NoHypervisorFound()); } - let guest_binary = GuestBinary::Buffer(super::WASM_RUNTIME.to_vec()); - - let mut proto_wasm_sandbox = ProtoWasmSandbox::new(Some(self.config), guest_binary)?; + #[cfg(feature = "experimental_runtime_file")] + let (guest_binary, runtime_file) = match self.runtime_file { + Some(path) => (GuestBinary::FilePath(path), true), + None => (GuestBinary::Buffer(super::WASM_RUNTIME.to_vec()), false), + }; + #[cfg(not(feature = "experimental_runtime_file"))] + let (guest_binary, runtime_file) = + (GuestBinary::Buffer(super::WASM_RUNTIME.to_vec()), false); + + let mut proto_wasm_sandbox = + ProtoWasmSandbox::new(Some(self.config), guest_binary, runtime_file)?; if let Some(host_print_fn) = self.host_print_fn { proto_wasm_sandbox.register_print(host_print_fn)?; } diff --git a/src/hyperlight_wasm/src/sandbox/snapshot.rs b/src/hyperlight_wasm/src/sandbox/snapshot.rs index 669fdc67..5b672b33 100644 --- a/src/hyperlight_wasm/src/sandbox/snapshot.rs +++ b/src/hyperlight_wasm/src/sandbox/snapshot.rs @@ -101,6 +101,10 @@ struct SnapshotMetadata { hyperlight_wasm_version: String, component_interface: Option, base_snapshot: Option, + /// Whether the runtime came from a file. Its world is unknown, so the + /// snapshot cannot be saved. + #[serde(skip)] + runtime_file: bool, } impl SnapshotMetadata { @@ -111,6 +115,7 @@ impl SnapshotMetadata { hyperlight_wasm_version: env!("CARGO_PKG_VERSION").to_owned(), component_interface: super::WASM_RUNTIME_COMPONENT_INTERFACE.map(str::to_owned), base_snapshot: None, + runtime_file: false, } } @@ -234,6 +239,11 @@ impl Snapshot { /// snapshot previously saved under `tag` unable to unload its module. pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { let path = path.as_ref(); + if self.metadata.runtime_file { + return Err(new_error!( + "Snapshots of a sandbox with a runtime file cannot be saved" + )); + } if tag.as_str().ends_with(BASE_TAG_SUFFIX) { return Err(new_error!( "Snapshot tags ending in '{}' are reserved by hyperlight-wasm", @@ -320,8 +330,12 @@ impl Snapshot { } impl Snapshot { - pub(super) fn runtime(inner: Arc) -> Self { - Self::new(inner, (), SnapshotMetadata::runtime()) + pub(super) fn runtime(inner: Arc, runtime_file: bool) -> Self { + let metadata = SnapshotMetadata { + runtime_file, + ..SnapshotMetadata::runtime() + }; + Self::new(inner, (), metadata) } } diff --git a/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs b/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs index 638a2bb0..74021584 100644 --- a/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs +++ b/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs @@ -182,9 +182,10 @@ pub struct WasmSandbox { const MAPPED_BINARY_VA: u64 = 0x1_0000_0000u64; impl WasmSandbox { - /// Snapshot a freshly initialized runtime and wrap it. - pub(super) fn new(mut inner: Sandbox) -> Result { - let snapshot = Snapshot::runtime(inner.snapshot()?); + /// Snapshot a freshly initialized runtime and wrap it. `runtime_file` + /// is whether the runtime was loaded from a file. + pub(super) fn new(mut inner: Sandbox, runtime_file: bool) -> Result { + let snapshot = Snapshot::runtime(inner.snapshot()?, runtime_file); Ok(Self::clean(inner, snapshot)) } @@ -629,6 +630,43 @@ mod tests { } } + #[cfg(feature = "experimental_runtime_file")] + #[test] + fn test_runtime_file() { + let path = env::temp_dir().join(format!("hyperlight-wasm-runtime-{}", std::process::id())); + std::fs::write(&path, &super::super::WASM_RUNTIME[..]).unwrap(); + let sandbox = SandboxBuilder::new().with_runtime_file(&path).build(); + std::fs::remove_file(&path).unwrap(); + let mut sandbox = sandbox.unwrap(); + sandbox + .register( + "GetTimeSinceBootMicrosecond", + get_time_since_boot_microsecond, + ) + .unwrap(); + let helloworld_wasm = get_test_file_path("HelloWorld.aot").unwrap(); + let mut loaded = sandbox + .load_runtime() + .unwrap() + .load_module(helloworld_wasm) + .unwrap(); + let result: i32 = loaded + .call_guest_function("HelloWorld", "Message from Rust Test".to_string()) + .unwrap(); + assert_eq!(result, 0); + + let snapshot = loaded.snapshot().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let tag = hyperlight_host::sandbox::snapshot::OciTag::new("runtime-file").unwrap(); + let err = snapshot.save(directory.path(), &tag).unwrap_err(); + assert!(err.to_string().contains("runtime file"), "{err}"); + + let missing = SandboxBuilder::new() + .with_runtime_file(env::temp_dir().join("hyperlight-wasm-no-such-runtime")) + .build(); + assert!(missing.is_err()); + } + #[test] fn test_load_from_snapshot() { let mut sandbox = SandboxBuilder::new().build().unwrap(); diff --git a/src/hyperlight_wasm_capi/Cargo.toml b/src/hyperlight_wasm_capi/Cargo.toml new file mode 100644 index 00000000..c11d5cc1 --- /dev/null +++ b/src/hyperlight_wasm_capi/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "hyperlight-wasm-capi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +publish = false +description = """ +Experimental. C API for hyperlight-wasm. Arguments and results are byte buffers +encoded as described in hyperlight's component wire format. +""" + +[lib] +name = "hyperlight_wasm_capi" +crate-type = ["cdylib", "staticlib", "lib"] + +[dependencies] +hyperlight-wasm = { path = "../hyperlight_wasm", default-features = false, features = ["experimental_runtime_file"] } + +[features] +default = ["kvm", "mshv3", "hvf"] +kvm = ["hyperlight-wasm/kvm"] +mshv3 = ["hyperlight-wasm/mshv3"] +hvf = ["hyperlight-wasm/hvf"] +wasmtime_latest = ["hyperlight-wasm/wasmtime_latest"] + +[dev-dependencies] +examples_common = { path = "../examples_common" } diff --git a/src/hyperlight_wasm_capi/include/hyperlight_wasm.h b/src/hyperlight_wasm_capi/include/hyperlight_wasm.h new file mode 100644 index 00000000..c5a1f8f8 --- /dev/null +++ b/src/hyperlight_wasm_capi/include/hyperlight_wasm.h @@ -0,0 +1,261 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * \file hyperlight_wasm.h + * + * C API for running WebAssembly components in Hyperlight sandboxes. + * + * Experimental. The API can change in any release. + * + * Guest and host functions are identified by their wire names and take one + * encoded byte buffer per parameter. Their results are one encoded byte + * buffer. The encoding and naming are described in hyperlight's + * docs/component-wire-format.md. + * + * Functions that can fail return an `hlw_error *`, which is NULL on success. + * The caller owns a returned error and frees it with `hlw_error_delete`. + * They return an `HLW_ERROR_INVALID_ARGUMENT` error for a NULL sandbox, + * snapshot or host function. + * + * A sandbox must not be used from two threads at once. Interrupt handles may + * be used from any thread. Host functions must not delete their own sandbox. + * Other calls into it from its host functions fail with + * `HLW_ERROR_INVALID_STATE`. + */ + +#ifndef HYPERLIGHT_WASM_H +#define HYPERLIGHT_WASM_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** An error. */ +typedef struct hlw_error hlw_error; + +/** Sandbox settings. */ +typedef struct hlw_config hlw_config; + +/** A sandbox that runs one WebAssembly component. */ +typedef struct hlw_sandbox hlw_sandbox; + +/** A saved sandbox state. */ +typedef struct hlw_snapshot hlw_snapshot; + +/** Interrupts a running guest call. */ +typedef struct hlw_interrupt_handle hlw_interrupt_handle; + +/** The result slot of a host function call. */ +typedef struct hlw_result hlw_result; + +/** Bytes borrowed from the caller. */ +typedef struct hlw_bytes { + const uint8_t *data; + size_t len; +} hlw_bytes; + +/** Bytes owned by the library. Free with `hlw_buf_delete`. */ +typedef struct hlw_buf { + uint8_t *data; + size_t len; +} hlw_buf; + +/** Any error not covered by another kind. */ +#define HLW_ERROR_OTHER 0 +/** + * The sandbox is poisoned by an interrupted or failed guest call. Restore a + * snapshot to use it again. + */ +#define HLW_ERROR_POISONED 1 +/** A guest call was interrupted with `hlw_interrupt_handle_kill`. */ +#define HLW_ERROR_CANCELLED 2 +/** The function is not valid in the sandbox's current state. */ +#define HLW_ERROR_INVALID_STATE 3 +/** An argument is invalid. */ +#define HLW_ERROR_INVALID_ARGUMENT 4 + +/** The largest number of parameters a function can have. */ +#define HLW_MAX_PARAMS 32 + +/** Creates an error of kind `HLW_ERROR_OTHER` from a UTF-8 message. */ +hlw_error *hlw_error_new(const char *message, size_t len); + +/** Returns the kind of `error`, one of the `HLW_ERROR_*` values. */ +uint32_t hlw_error_kind(const hlw_error *error); + +/** + * Returns the UTF-8 message of `error`, which is not NUL-terminated. It is + * valid until `error` is deleted. + */ +void hlw_error_message(const hlw_error *error, const char **message, + size_t *len); + +/** Frees `error`. Accepts NULL. */ +void hlw_error_delete(hlw_error *error); + +/** Frees the bytes of `buf` and clears it. Accepts NULL. */ +void hlw_buf_delete(hlw_buf *buf); + +/** Creates settings with default values. */ +hlw_config *hlw_config_new(void); + +/** Frees `config`. Accepts NULL. */ +void hlw_config_delete(hlw_config *config); + +/** + * Sets the guest scratch size in bytes. Scratch holds the guest heap, stack + * and transport buffers. Smaller values than the default are ignored. + */ +void hlw_config_scratch_size(hlw_config *config, size_t size); + +/* + * Transport settings. Host-to-guest (h2g) buffers carry guest calls and + * components loaded with `hlw_sandbox_load`. A message spans several h2g + * buffers, so their pool and queue must hold the largest message. + * Guest-to-host (g2h) buffers carry host calls and guest results. + */ + +/** Sets the size in bytes of each host-to-guest buffer. */ +void hlw_config_h2g_buffer_size(hlw_config *config, size_t size); + +/** Sets the guest pages for host-to-guest buffers. Values of 48 or less are ignored. */ +void hlw_config_h2g_pool_pages(hlw_config *config, size_t pages); + +/** Sets the number of in-flight host-to-guest buffers. */ +void hlw_config_h2g_queue_size(hlw_config *config, size_t entries); + +/** Sets the size in bytes of each guest-to-host buffer. */ +void hlw_config_g2h_buffer_size(hlw_config *config, size_t size); + +/** Sets the guest pages for guest-to-host buffers. */ +void hlw_config_g2h_pool_pages(hlw_config *config, size_t pages); + +/** Sets the number of in-flight guest-to-host buffers. */ +void hlw_config_g2h_queue_size(hlw_config *config, size_t entries); + +/** + * Loads the guest runtime from the UTF-8 path `path` instead of using the + * built-in one. The runtime must be built by the same hyperlight-wasm + * version as this library. Components need a runtime built for their world + * unless this library was built for it with `WIT_WORLD`. + */ +hlw_error *hlw_config_runtime_file(hlw_config *config, const char *path, + size_t len); + +/** + * Sets the offset from SIGRTMIN of the signal that interrupts a running + * guest. Change it when the process already uses SIGRTMIN, as .NET does. + * Has no effect on Windows and macOS. + */ +hlw_error *hlw_config_interrupt_signal_offset(hlw_config *config, + uint8_t offset); + +/** + * Creates a sandbox. `config` may be NULL for default settings and is not + * consumed. + */ +hlw_error *hlw_sandbox_new(const hlw_config *config, hlw_sandbox **out); + +/** Frees `sandbox`. Accepts NULL. */ +void hlw_sandbox_delete(hlw_sandbox *sandbox); + +/** + * A host function. It receives `nargs` encoded arguments, which are valid + * until it returns. It stores its encoded result with `hlw_result_set`. + * Leaving the result unset returns an empty buffer. It returns NULL on + * success or an error from `hlw_error_new`, which the library frees. + * + * It runs on the thread that called `hlw_sandbox_call`. + */ +typedef hlw_error *(*hlw_host_func)(void *env, const hlw_bytes *args, + size_t nargs, hlw_result *result); + +/** Copies `len` bytes from `data` into `result`. */ +void hlw_result_set(hlw_result *result, const uint8_t *data, size_t len); + +/** + * Registers `func` as the host function `name` with `nargs` parameters. + * Must be called before the component is loaded. `finalizer`, which may be + * NULL, is called with `env` when the sandbox no longer needs it. It is + * also called if registration fails. `env` must be usable, and `finalizer` + * callable, from any thread. `finalizer` must not call into `sandbox`, + * because it can run while the sandbox is in use. + */ +hlw_error *hlw_sandbox_register(hlw_sandbox *sandbox, const char *name, + size_t name_len, size_t nargs, + hlw_host_func func, void *env, + void (*finalizer)(void *)); + +/** + * Loads the precompiled component at the UTF-8 path `path`. Loading runs + * the component's start functions, which cannot be interrupted. If loading + * fails, the sandbox can only be deleted. + */ +hlw_error *hlw_sandbox_load_file(hlw_sandbox *sandbox, const char *path, + size_t len); + +/** + * Loads a precompiled component from memory, as `hlw_sandbox_load_file` + * does. + */ +hlw_error *hlw_sandbox_load(hlw_sandbox *sandbox, const uint8_t *data, + size_t len); + +/** + * Calls the guest function `name` with `nargs` encoded arguments and stores + * its encoded result in `result`. + */ +hlw_error *hlw_sandbox_call(hlw_sandbox *sandbox, const char *name, + size_t name_len, const hlw_bytes *args, + size_t nargs, hlw_buf *result); + +/** Saves the state of a loaded sandbox. */ +hlw_error *hlw_sandbox_snapshot(hlw_sandbox *sandbox, hlw_snapshot **out); + +/** + * Restores `snapshot`, which must come from `sandbox`. Restoring also + * recovers a poisoned sandbox. + */ +hlw_error *hlw_sandbox_restore(hlw_sandbox *sandbox, + const hlw_snapshot *snapshot); + +/** Frees `snapshot`. Accepts NULL. */ +void hlw_snapshot_delete(hlw_snapshot *snapshot); + +/** Returns a handle that interrupts guest calls of a loaded sandbox. */ +hlw_error *hlw_sandbox_interrupt_handle(hlw_sandbox *sandbox, + hlw_interrupt_handle **out); + +/** + * Interrupts the running guest call, which then fails with + * `HLW_ERROR_CANCELLED`. A guest in a host function stops when that + * function returns. Returns whether the guest was running. + */ +bool hlw_interrupt_handle_kill(const hlw_interrupt_handle *handle); + +/** Frees `handle`. Accepts NULL. */ +void hlw_interrupt_handle_delete(hlw_interrupt_handle *handle); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/hyperlight_wasm_capi/src/lib.rs b/src/hyperlight_wasm_capi/src/lib.rs new file mode 100644 index 00000000..712e4ecb --- /dev/null +++ b/src/hyperlight_wasm_capi/src/lib.rs @@ -0,0 +1,1043 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! C API for hyperlight-wasm, declared in `include/hyperlight_wasm.h`. +//! +//! Guest and host functions take one byte buffer per parameter and return +//! one byte buffer, so the API needs no knowledge of a component's WIT. + +#![deny(missing_docs)] +// Type names match the C header. +#![allow(non_camel_case_types)] + +use std::ffi::c_void; +use std::path::PathBuf; +use std::sync::Arc; + +use hyperlight_wasm::{ + HyperlightError, InterruptHandle, LoadedWasmSandbox, ProtoWasmSandbox, SandboxBuilder, + Snapshot, new_error, +}; + +/// Any error not covered by another kind. +pub const HLW_ERROR_OTHER: u32 = 0; +/// The sandbox is poisoned by an interrupted or failed guest call. +pub const HLW_ERROR_POISONED: u32 = 1; +/// A guest call was interrupted. +pub const HLW_ERROR_CANCELLED: u32 = 2; +/// The function is not valid in the sandbox's current state. +pub const HLW_ERROR_INVALID_STATE: u32 = 3; +/// An argument is invalid. +pub const HLW_ERROR_INVALID_ARGUMENT: u32 = 4; +/// The largest number of parameters a function can have. +pub const HLW_MAX_PARAMS: usize = 32; + +/// An error returned to C. +pub struct hlw_error { + kind: u32, + message: String, +} + +impl hlw_error { + fn new(kind: u32, message: impl Into) -> *mut hlw_error { + Box::into_raw(Box::new(hlw_error { + kind, + message: message.into(), + })) + } +} + +fn from_hyperlight(error: HyperlightError) -> *mut hlw_error { + let kind = match error { + HyperlightError::PoisonedSandbox => HLW_ERROR_POISONED, + HyperlightError::ExecutionCanceledByHost() => HLW_ERROR_CANCELLED, + _ => HLW_ERROR_OTHER, + }; + hlw_error::new(kind, error.to_string()) +} + +fn ok() -> *mut hlw_error { + std::ptr::null_mut() +} + +/// Bytes borrowed from C. +#[repr(C)] +pub struct hlw_bytes { + /// The first byte. May be null when `len` is zero. + pub data: *const u8, + /// The number of bytes. + pub len: usize, +} + +/// Bytes owned by this library. +#[repr(C)] +pub struct hlw_buf { + /// The first byte. Null when `len` is zero. + pub data: *mut u8, + /// The number of bytes. + pub len: usize, +} + +/// The result slot of a host function call. +#[derive(Default)] +pub struct hlw_result { + data: Vec, +} + +/// Sandbox settings. +#[derive(Default)] +pub struct hlw_config { + builder: SandboxBuilder, +} + +impl hlw_config { + fn update(&mut self, f: impl FnOnce(SandboxBuilder) -> SandboxBuilder) { + self.builder = f(std::mem::take(&mut self.builder)); + } +} + +enum State { + Proto(ProtoWasmSandbox), + Loaded(LoadedWasmSandbox), + /// Guest code is running, so host functions cannot use the sandbox. + Running, + /// A load failed and left no usable sandbox. + Broken, +} + +fn invalid_state(state: &State) -> *mut hlw_error { + let message = match state { + State::Proto(_) => "no component is loaded", + State::Loaded(_) => "a component is already loaded", + State::Running => "a host function cannot use its own sandbox", + State::Broken => "the sandbox failed and can only be deleted", + }; + hlw_error::new(HLW_ERROR_INVALID_STATE, message) +} + +/// A sandbox that runs one WebAssembly component. +pub struct hlw_sandbox { + state: State, +} + +/// A saved sandbox state. +pub struct hlw_snapshot { + snapshot: Snapshot, +} + +/// Interrupts a running guest call. +pub struct hlw_interrupt_handle { + handle: Arc, +} + +/// A host function registered from C. +type HostFunc = unsafe extern "C" fn( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_result, +) -> *mut hlw_error; + +/// The `env` of a host function, finalized on drop. +struct Env { + env: *mut c_void, + finalizer: Option, +} + +// SAFETY: `hlw_sandbox_register` requires `env` and `finalizer` to be usable +// from any thread. The finalizer runs wherever the sandbox is dropped. +unsafe impl Send for Env {} +// SAFETY: as for `Send`. Calls are serialized by the sandbox. +unsafe impl Sync for Env {} + +impl Drop for Env { + fn drop(&mut self) { + if let Some(finalizer) = self.finalizer { + // SAFETY: `hlw_sandbox_register` requires `finalizer` to accept + // `env`. It runs once, when the last reference is dropped. + unsafe { finalizer(self.env) } + } + } +} + +struct HostCallback { + func: HostFunc, + env: Env, +} + +impl HostCallback { + fn invoke(&self, args: Vec>) -> Result, HyperlightError> { + let views = args + .iter() + .map(|a| hlw_bytes { + data: a.as_ptr(), + len: a.len(), + }) + .collect::>(); + let mut result = hlw_result::default(); + // SAFETY: `hlw_sandbox_register` requires `func` to be callable with + // `env`. `views` and `result` outlive the call. + let error = unsafe { (self.func)(self.env.env, views.as_ptr(), views.len(), &mut result) }; + if error.is_null() { + Ok(result.data) + } else { + // SAFETY: `hlw_sandbox_register` requires a non-null error to + // come from `hlw_error_new`. This library owns it from here on. + let error = unsafe { Box::from_raw(error) }; + Err(new_error!("{}", error.message)) + } + } +} + +/// A slice from a C pointer and length. The pointer may be null when the +/// length is zero. +/// +/// # Safety +/// If `len` is non-zero, `data` must point to `len` readable values that +/// outlive `'a`. +unsafe fn slice<'a, T>(data: *const T, len: usize) -> &'a [T] { + if len == 0 { + &[] + } else { + // SAFETY: guaranteed by the caller. + unsafe { std::slice::from_raw_parts(data, len) } + } +} + +/// A UTF-8 string from a C pointer and length. +/// +/// # Safety +/// As for [`slice`]. +unsafe fn str<'a>(data: *const u8, len: usize) -> Result<&'a str, *mut hlw_error> { + // SAFETY: guaranteed by the caller. + let bytes = unsafe { slice(data, len) }; + std::str::from_utf8(bytes) + .map_err(|e| hlw_error::new(HLW_ERROR_INVALID_ARGUMENT, format!("invalid UTF-8: {e}"))) +} + +/// Returns an invalid-argument error from the enclosing function when a +/// pointer argument is null. +macro_rules! non_null { + ($($ptr:ident),+) => { + $( + if $ptr.is_null() { + return hlw_error::new( + HLW_ERROR_INVALID_ARGUMENT, + concat!("`", stringify!($ptr), "` is null"), + ); + } + )+ + }; +} + +macro_rules! try_c { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => return e, + } + }; +} + +/// Expands `$m!(a0 a1 ...)` for every argument count from 0 to +/// [`HLW_MAX_PARAMS`]. +macro_rules! for_each_arity { + ($m:ident) => { + for_each_arity!(@ $m [] [ + a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 + a16 a17 a18 a19 a20 a21 a22 a23 a24 a25 a26 a27 a28 a29 a30 a31 + ]); + }; + (@ $m:ident [$($done:ident)*] []) => { + $m!($($done)*); + }; + (@ $m:ident [$($done:ident)*] [$next:ident $($rest:ident)*]) => { + $m!($($done)*); + for_each_arity!(@ $m [$($done)* $next] [$($rest)*]); + }; +} + +macro_rules! count { + ($($a:ident)*) => { 0usize $(+ { let _ = stringify!($a); 1 })* }; +} + +/// Creates an error of kind `HLW_ERROR_OTHER` from a UTF-8 message. +/// +/// # Safety +/// `message` must point to `len` readable bytes, or `len` must be zero. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_new(message: *const u8, len: usize) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let bytes = unsafe { slice(message, len) }; + hlw_error::new(HLW_ERROR_OTHER, String::from_utf8_lossy(bytes)) +} + +/// Returns the kind of `error`. +/// +/// # Safety +/// `error` must be a live error from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_kind(error: *const hlw_error) -> u32 { + // SAFETY: guaranteed by the caller. + unsafe { (*error).kind } +} + +/// Returns the message of `error`. +/// +/// # Safety +/// `error` must be a live error from this library. `message` and `len` must +/// be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_message( + error: *const hlw_error, + message: *mut *const u8, + len: *mut usize, +) { + // SAFETY: guaranteed by the caller. + unsafe { + let error = &*error; + *message = error.message.as_ptr(); + *len = error.message.len(); + } +} + +/// Frees `error`. +/// +/// # Safety +/// `error` must be null or an error from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_delete(error: *mut hlw_error) { + if !error.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(error) }); + } +} + +/// Frees the bytes of `buf` and clears it. +/// +/// # Safety +/// `buf` must be null or hold bytes returned by this library, or be empty. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_buf_delete(buf: *mut hlw_buf) { + // SAFETY: guaranteed by the caller. + let Some(buf) = (unsafe { buf.as_mut() }) else { + return; + }; + if !buf.data.is_null() { + // SAFETY: the bytes come from `into_buf`, which leaks a boxed slice + // of this length. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(buf.data, buf.len)) }); + } + buf.data = std::ptr::null_mut(); + buf.len = 0; +} + +fn into_buf(bytes: Vec) -> hlw_buf { + let len = bytes.len(); + if len == 0 { + return hlw_buf { + data: std::ptr::null_mut(), + len, + }; + } + let data = Box::into_raw(bytes.into_boxed_slice()).cast::(); + hlw_buf { data, len } +} + +/// Creates settings with default values. +#[unsafe(no_mangle)] +pub extern "C" fn hlw_config_new() -> *mut hlw_config { + Box::into_raw(Box::default()) +} + +/// Frees `config`. +/// +/// # Safety +/// `config` must be null or a config from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_delete(config: *mut hlw_config) { + if !config.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(config) }); + } +} + +/// Sets the guest scratch size in bytes. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_scratch_size(config: *mut hlw_config, size: usize) { + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.with_guest_scratch_size(size)); +} + +/// Defines a config setter for each builder method. Each takes one `usize`. +macro_rules! config_setters { + ($($(#[doc = $doc:literal])* $name:ident => $method:ident;)*) => {$( + $(#[doc = $doc])* + /// + /// # Safety + /// `config` must be a live config from this library. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn $name(config: *mut hlw_config, value: usize) { + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.$method(value)); + } + )*}; +} + +config_setters! { + /// Sets the size in bytes of each host-to-guest buffer. + hlw_config_h2g_buffer_size => with_h2g_buffer_size; + /// Sets the guest pages for host-to-guest buffers. Values of 48 or less + /// are ignored. + hlw_config_h2g_pool_pages => with_h2g_pool_pages; + /// Sets the number of in-flight host-to-guest buffers. + hlw_config_h2g_queue_size => with_h2g_queue_size; + /// Sets the size in bytes of each guest-to-host buffer. + hlw_config_g2h_buffer_size => with_g2h_buffer_size; + /// Sets the guest pages for guest-to-host buffers. + hlw_config_g2h_pool_pages => with_g2h_pool_pages; + /// Sets the number of in-flight guest-to-host buffers. + hlw_config_g2h_queue_size => with_g2h_queue_size; +} + +/// Loads the guest runtime from a file. +/// +/// # Safety +/// `config` must be a live config from this library. `path` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_runtime_file( + config: *mut hlw_config, + path: *const u8, + len: usize, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let path = PathBuf::from(try_c!(unsafe { str(path, len) })); + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.with_runtime_file(path)); + ok() +} + +/// Sets the offset from `SIGRTMIN` of the interrupt signal. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_interrupt_signal_offset( + config: *mut hlw_config, + offset: u8, +) -> *mut hlw_error { + #[cfg(target_os = "linux")] + { + // SAFETY: guaranteed by the caller. + let config = unsafe { &mut *config }; + match config + .builder + .clone() + .with_interrupt_vcpu_sigrtmin_offset(offset) + { + Ok(builder) => config.builder = builder, + Err(e) => return hlw_error::new(HLW_ERROR_INVALID_ARGUMENT, e.to_string()), + } + } + #[cfg(not(target_os = "linux"))] + let _ = (config, offset); + ok() +} + +/// Creates a sandbox. +/// +/// # Safety +/// `config` must be null or a live config from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_new( + config: *const hlw_config, + out: *mut *mut hlw_sandbox, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let builder = unsafe { config.as_ref() } + .map(|c| c.builder.clone()) + .unwrap_or_default(); + let proto = try_c!(builder.build().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { + *out = Box::into_raw(Box::new(hlw_sandbox { + state: State::Proto(proto), + })); + } + ok() +} + +/// Frees `sandbox`. +/// +/// # Safety +/// `sandbox` must be null or a sandbox from this library that is not used +/// afterwards. It must not be called from a host function of `sandbox`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_delete(sandbox: *mut hlw_sandbox) { + if !sandbox.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(sandbox) }); + } +} + +/// Copies `len` bytes from `data` into `result`. +/// +/// # Safety +/// `result` must be the result slot passed to the running host function. +/// `data` must point to `len` readable bytes, or `len` must be zero. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_result_set(result: *mut hlw_result, data: *const u8, len: usize) { + // SAFETY: guaranteed by the caller. + unsafe { (*result).data = slice(data, len).to_vec() }; +} + +/// Registers a host function. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `name` must point to +/// `name_len` readable bytes. `func` must be null or safe to call with `env` +/// from any thread until `finalizer` runs, and must return null or an error +/// from `hlw_error_new`. `finalizer` must be null or safe to call once with +/// `env` from any thread, and must not call into `sandbox`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_register( + sandbox: *mut hlw_sandbox, + name: *const u8, + name_len: usize, + nargs: usize, + func: Option, + env: *mut c_void, + finalizer: Option, +) -> *mut hlw_error { + // Owns `env` first, so every failure below runs the finalizer. + let env = Env { env, finalizer }; + non_null!(sandbox); + let Some(func) = func else { + return hlw_error::new(HLW_ERROR_INVALID_ARGUMENT, "`func` is null"); + }; + let callback = Arc::new(HostCallback { func, env }); + // SAFETY: guaranteed by the caller. + let name = try_c!(unsafe { str(name, name_len) }); + // SAFETY: guaranteed by the caller. + let proto = match &mut unsafe { &mut *sandbox }.state { + State::Proto(proto) => proto, + state => return invalid_state(state), + }; + + macro_rules! register { + ($($a:ident)*) => { + if nargs == count!($($a)*) { + let callback = callback.clone(); + return match proto.register(name, move |$($a: Vec),*| callback.invoke(vec![$($a),*])) { + Ok(()) => ok(), + Err(e) => from_hyperlight(e), + }; + } + }; + } + for_each_arity!(register); + hlw_error::new( + HLW_ERROR_INVALID_ARGUMENT, + format!("{nargs} parameters exceed the limit of {HLW_MAX_PARAMS}"), + ) +} + +/// Runs guest code on the sandbox state while marking the sandbox running. +/// No reference to the sandbox lives across `f`, so a host function that +/// calls back into the sandbox gets an invalid-state error. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library, and `f` must not +/// delete it. +unsafe fn run(sandbox: *mut hlw_sandbox, f: impl FnOnce(State) -> (State, T)) -> T { + // SAFETY: guaranteed by the caller. + let state = std::mem::replace(unsafe { &mut (*sandbox).state }, State::Running); + let (state, out) = f(state); + // SAFETY: guaranteed by the caller. + unsafe { (*sandbox).state = state }; + out +} + +/// # Safety +/// `sandbox` must be a live sandbox from this library. +unsafe fn load( + sandbox: *mut hlw_sandbox, + load: impl FnOnce(hyperlight_wasm::WasmSandbox) -> hyperlight_wasm::Result, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + unsafe { + run(sandbox, |state| match state { + State::Proto(proto) => match proto.load_runtime().and_then(load) { + Ok(loaded) => (State::Loaded(loaded), ok()), + Err(e) => (State::Broken, from_hyperlight(e)), + }, + state => { + let error = invalid_state(&state); + (state, error) + } + }) + } +} + +/// Loads a precompiled component from a file. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `path` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_load_file( + sandbox: *mut hlw_sandbox, + path: *const u8, + len: usize, +) -> *mut hlw_error { + non_null!(sandbox); + // SAFETY: guaranteed by the caller. + let path = try_c!(unsafe { str(path, len) }); + // SAFETY: guaranteed by the caller. + unsafe { load(sandbox, |s| s.load_module(path)) } +} + +/// Loads a precompiled component from memory. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `data` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_load( + sandbox: *mut hlw_sandbox, + data: *const u8, + len: usize, +) -> *mut hlw_error { + non_null!(sandbox); + // SAFETY: guaranteed by the caller. + let data = unsafe { slice(data, len) }; + // SAFETY: guaranteed by the caller. + unsafe { load(sandbox, |s| s.load_module_from_buffer(data)) } +} + +fn loaded(sandbox: &mut hlw_sandbox) -> Result<&mut LoadedWasmSandbox, *mut hlw_error> { + match &mut sandbox.state { + State::Loaded(loaded) => Ok(loaded), + state => Err(invalid_state(state)), + } +} + +fn call( + loaded: &mut LoadedWasmSandbox, + name: &str, + mut args: Vec>, +) -> Result, *mut hlw_error> { + macro_rules! call { + ($($a:ident)*) => {{ + const N: usize = count!($($a)*); + if args.len() == N { + return match <[Vec; N]>::try_from(std::mem::take(&mut args)) { + Ok([$($a),*]) => loaded + .call_guest_function::>(name, ($($a,)*)) + .map_err(from_hyperlight), + Err(_) => unreachable!("length checked"), + }; + } + }}; + } + for_each_arity!(call); + unreachable!("hlw_sandbox_call checks the argument count") +} + +/// Calls a guest function. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `name` must point to +/// `name_len` readable bytes. `args` must point to `nargs` values whose +/// bytes are readable. `result` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_call( + sandbox: *mut hlw_sandbox, + name: *const u8, + name_len: usize, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_buf, +) -> *mut hlw_error { + non_null!(sandbox); + if nargs > HLW_MAX_PARAMS { + return hlw_error::new( + HLW_ERROR_INVALID_ARGUMENT, + format!("{nargs} arguments exceed the limit of {HLW_MAX_PARAMS}"), + ); + } + // SAFETY: guaranteed by the caller. + let name = try_c!(unsafe { str(name, name_len) }); + // SAFETY: guaranteed by the caller. + let args = unsafe { slice(args, nargs) } + .iter() + // SAFETY: guaranteed by the caller. + .map(|a| unsafe { slice(a.data, a.len) }.to_vec()) + .collect(); + // SAFETY: guaranteed by the caller. + let bytes = try_c!(unsafe { + run(sandbox, |state| match state { + State::Loaded(mut loaded) => { + let result = call(&mut loaded, name, args); + (State::Loaded(loaded), result) + } + state => { + let error = invalid_state(&state); + (state, Err(error)) + } + }) + }); + // SAFETY: guaranteed by the caller. + unsafe { *result = into_buf(bytes) }; + ok() +} + +/// Saves the state of a loaded sandbox. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_snapshot( + sandbox: *mut hlw_sandbox, + out: *mut *mut hlw_snapshot, +) -> *mut hlw_error { + non_null!(sandbox); + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + let snapshot = try_c!(loaded.snapshot().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { *out = Box::into_raw(Box::new(hlw_snapshot { snapshot })) }; + ok() +} + +/// Restores a snapshot. +/// +/// # Safety +/// `sandbox` and `snapshot` must each be null or a live object from this +/// library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_restore( + sandbox: *mut hlw_sandbox, + snapshot: *const hlw_snapshot, +) -> *mut hlw_error { + non_null!(sandbox, snapshot); + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + // SAFETY: guaranteed by the caller. + let snapshot = unsafe { &*snapshot }.snapshot.clone(); + match loaded.restore(snapshot) { + Ok(()) => ok(), + Err(e) => from_hyperlight(e), + } +} + +/// Frees `snapshot`. +/// +/// # Safety +/// `snapshot` must be null or a snapshot from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_snapshot_delete(snapshot: *mut hlw_snapshot) { + if !snapshot.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(snapshot) }); + } +} + +/// Returns a handle that interrupts guest calls. +/// +/// # Safety +/// `sandbox` must be null or a live sandbox from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_interrupt_handle( + sandbox: *mut hlw_sandbox, + out: *mut *mut hlw_interrupt_handle, +) -> *mut hlw_error { + non_null!(sandbox); + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + let handle = try_c!(loaded.interrupt_handle().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { *out = Box::into_raw(Box::new(hlw_interrupt_handle { handle })) }; + ok() +} + +/// Interrupts the running guest call. +/// +/// # Safety +/// `handle` must be null or a live interrupt handle from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_interrupt_handle_kill(handle: *const hlw_interrupt_handle) -> bool { + if handle.is_null() { + return false; + } + // SAFETY: guaranteed by the caller. + unsafe { &*handle }.handle.kill() +} + +/// Frees `handle`. +/// +/// # Safety +/// `handle` must be null or a handle from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_interrupt_handle_delete(handle: *mut hlw_interrupt_handle) { + if !handle.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(handle) }); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + unsafe extern "C" fn unused( + _env: *mut c_void, + _args: *const hlw_bytes, + _nargs: usize, + _result: *mut hlw_result, + ) -> *mut hlw_error { + unreachable!() + } + + fn take(error: *mut hlw_error) -> Option<(u32, String)> { + if error.is_null() { + return None; + } + // SAFETY: errors returned by this library are owned by the caller. + let error = unsafe { Box::from_raw(error) }; + Some((error.kind, error.message)) + } + + fn sandbox() -> *mut hlw_sandbox { + let mut sandbox = std::ptr::null_mut(); + // SAFETY: a null config selects defaults and `sandbox` is writable. + assert_eq!( + take(unsafe { hlw_sandbox_new(std::ptr::null(), &mut sandbox) }), + None + ); + sandbox + } + + #[test] + fn errors() { + let message = "bad \u{1F388}"; + // SAFETY: `message` is readable for its length. + let error = unsafe { hlw_error_new(message.as_ptr(), message.len()) }; + let (mut ptr, mut len) = (std::ptr::null(), 0); + // SAFETY: `error` is live and the outputs are writable. + unsafe { + assert_eq!(hlw_error_kind(error), HLW_ERROR_OTHER); + hlw_error_message(error, &mut ptr, &mut len); + assert_eq!(slice(ptr, len), message.as_bytes()); + hlw_error_delete(error); + hlw_error_delete(std::ptr::null_mut()); + } + + let kind = |e| take(from_hyperlight(e)).unwrap().0; + assert_eq!(kind(HyperlightError::PoisonedSandbox), HLW_ERROR_POISONED); + assert_eq!( + kind(HyperlightError::ExecutionCanceledByHost()), + HLW_ERROR_CANCELLED + ); + assert_eq!(kind(new_error!("x")), HLW_ERROR_OTHER); + } + + #[test] + fn buffers() { + let mut result = hlw_result::default(); + // SAFETY: the data is readable and `result` is a live result slot. + unsafe { + hlw_result_set(&mut result, [1u8, 2, 3].as_ptr(), 3); + assert_eq!(result.data, [1, 2, 3]); + hlw_result_set(&mut result, std::ptr::null(), 0); + assert!(result.data.is_empty()); + } + + let mut buf = into_buf(vec![4, 5]); + // SAFETY: `buf` holds bytes from `into_buf`. + unsafe { + assert_eq!(slice(buf.data, buf.len), [4, 5]); + hlw_buf_delete(&mut buf); + } + assert!(buf.data.is_null() && buf.len == 0); + let mut empty = into_buf(Vec::new()); + assert!(empty.data.is_null()); + // SAFETY: an empty buffer and null are accepted. + unsafe { + hlw_buf_delete(&mut empty); + hlw_buf_delete(std::ptr::null_mut()); + } + } + + #[test] + fn config() { + let config = hlw_config_new(); + let path = "/no/such/runtime"; + let invalid = [0xffu8]; + // SAFETY: `config` is live and the strings are readable. + unsafe { + hlw_config_scratch_size(config, 8 << 20); + hlw_config_h2g_buffer_size(config, 8192); + hlw_config_h2g_pool_pages(config, 128); + hlw_config_h2g_queue_size(config, 128); + hlw_config_g2h_buffer_size(config, 8192); + hlw_config_g2h_pool_pages(config, 32); + hlw_config_g2h_queue_size(config, 128); + assert_eq!(take(hlw_config_interrupt_signal_offset(config, 1)), None); + #[cfg(target_os = "linux")] + assert_eq!( + take(hlw_config_interrupt_signal_offset(config, u8::MAX)).map(|e| e.0), + Some(HLW_ERROR_INVALID_ARGUMENT) + ); + assert_eq!( + take(hlw_config_runtime_file(config, invalid.as_ptr(), 1)).map(|e| e.0), + Some(HLW_ERROR_INVALID_ARGUMENT) + ); + assert_eq!( + take(hlw_config_runtime_file(config, path.as_ptr(), path.len())), + None + ); + let mut sandbox = std::ptr::null_mut(); + assert!(take(hlw_sandbox_new(config, &mut sandbox)).is_some()); + assert!(sandbox.is_null()); + hlw_config_delete(config); + hlw_config_delete(std::ptr::null_mut()); + } + } + + /// Counts calls in the `AtomicUsize` that `env` points to. + unsafe extern "C" fn finalize(env: *mut c_void) { + // SAFETY: the tests pass a live `AtomicUsize` as `env`. + unsafe { &*(env as *const AtomicUsize) }.fetch_add(1, Ordering::SeqCst); + } + + #[test] + fn invalid_calls() { + let sandbox = sandbox(); + let name = "f"; + let mut snapshot = std::ptr::null_mut(); + let mut handle = std::ptr::null_mut(); + let mut result = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + let finalized = AtomicUsize::new(0); + let env = &finalized as *const AtomicUsize as *mut c_void; + // SAFETY: `sandbox` is live, the strings are readable and the outputs + // are writable. + unsafe { + let state = |e| take(e).map(|e: (u32, String)| e.0); + let call = + hlw_sandbox_call(sandbox, name.as_ptr(), 1, std::ptr::null(), 0, &mut result); + assert_eq!(state(call), Some(HLW_ERROR_INVALID_STATE)); + assert_eq!( + state(hlw_sandbox_snapshot(sandbox, &mut snapshot)), + Some(HLW_ERROR_INVALID_STATE) + ); + assert_eq!( + state(hlw_sandbox_interrupt_handle(sandbox, &mut handle)), + Some(HLW_ERROR_INVALID_STATE) + ); + + let too_many = hlw_sandbox_register( + sandbox, + name.as_ptr(), + 1, + HLW_MAX_PARAMS + 1, + Some(unused), + env, + Some(finalize), + ); + assert_eq!(state(too_many), Some(HLW_ERROR_INVALID_ARGUMENT)); + let bad_name = hlw_sandbox_register( + sandbox, + [0xffu8].as_ptr(), + 1, + 0, + Some(unused), + env, + Some(finalize), + ); + assert_eq!(state(bad_name), Some(HLW_ERROR_INVALID_ARGUMENT)); + let no_func = + hlw_sandbox_register(sandbox, name.as_ptr(), 1, 0, None, env, Some(finalize)); + assert_eq!(state(no_func), Some(HLW_ERROR_INVALID_ARGUMENT)); + // The finalizer runs when registration fails. + assert_eq!(finalized.load(Ordering::SeqCst), 3); + + let garbage = [0u8; 16]; + let load = hlw_sandbox_load(sandbox, garbage.as_ptr(), garbage.len()); + assert!(take(load).is_some()); + // A failed load leaves only deletion. + let reload = hlw_sandbox_load(sandbox, garbage.as_ptr(), garbage.len()); + assert_eq!(state(reload), Some(HLW_ERROR_INVALID_STATE)); + let call = + hlw_sandbox_call(sandbox, name.as_ptr(), 1, std::ptr::null(), 0, &mut result); + assert_eq!(state(call), Some(HLW_ERROR_INVALID_STATE)); + hlw_sandbox_delete(sandbox); + hlw_sandbox_delete(std::ptr::null_mut()); + hlw_snapshot_delete(std::ptr::null_mut()); + hlw_interrupt_handle_delete(std::ptr::null_mut()); + } + } + + #[test] + fn null_handles() { + let null = std::ptr::null_mut(); + let mut snapshot = std::ptr::null_mut(); + let mut handle = std::ptr::null_mut(); + let mut result = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + let finalized = AtomicUsize::new(0); + let env = &finalized as *const AtomicUsize as *mut c_void; + // SAFETY: null handles are rejected before use, and the outputs are + // writable. + unsafe { + let kind = |e| take(e).map(|e: (u32, String)| e.0); + let invalid = Some(HLW_ERROR_INVALID_ARGUMENT); + let reg = + hlw_sandbox_register(null, "f".as_ptr(), 1, 0, Some(unused), env, Some(finalize)); + assert_eq!(kind(reg), invalid); + assert_eq!(finalized.load(Ordering::SeqCst), 1); + assert_eq!(kind(hlw_sandbox_load_file(null, "f".as_ptr(), 1)), invalid); + assert_eq!(kind(hlw_sandbox_load(null, std::ptr::null(), 0)), invalid); + let call = hlw_sandbox_call(null, "f".as_ptr(), 1, std::ptr::null(), 0, &mut result); + assert_eq!(kind(call), invalid); + assert_eq!(kind(hlw_sandbox_snapshot(null, &mut snapshot)), invalid); + assert_eq!(kind(hlw_sandbox_restore(null, snapshot)), invalid); + assert_eq!( + kind(hlw_sandbox_interrupt_handle(null, &mut handle)), + invalid + ); + assert!(!hlw_interrupt_handle_kill(std::ptr::null())); + } + } +} diff --git a/src/hyperlight_wasm_capi/tests/component.rs b/src/hyperlight_wasm_capi/tests/component.rs new file mode 100644 index 00000000..3492dfcb --- /dev/null +++ b/src/hyperlight_wasm_capi/tests/component.rs @@ -0,0 +1,413 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Runs the `example` world of `component_sample` through the C API. +//! +//! The guest runtime must be built for that world, so run with `WIT_WORLD` +//! set to `src/tests/rust_guests/component_sample/wit/component-world.wasm` +//! and `--include-ignored`, as `just examples-components` does. + +use std::ffi::c_void; +use std::sync::atomic::{AtomicPtr, Ordering}; +use std::sync::{Arc, Mutex}; + +use hyperlight_wasm_capi::*; + +type HostFunc = + unsafe extern "C" fn(*mut c_void, *const hlw_bytes, usize, *mut hlw_result) -> *mut hlw_error; + +const ADD: &str = "component-sample:example/adder#add"; +const CALL_HOST: &str = "component-sample:example/adder#call-host"; +const DO_SOMETHING: &str = "component-sample:example/adder#do-something"; +const INCREMENT: &str = "component-sample:example/adder#increment"; +const PRINT: &str = "component-sample:example/host#print"; +const HOST_FUNCTION: &str = "component-sample:example/host#host-function"; + +fn encode_string(s: &str) -> Vec { + [&(s.len() as u32).to_le_bytes()[..], s.as_bytes()].concat() +} + +fn decode_string(bytes: &[u8]) -> String { + let len = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; + assert_eq!(bytes.len(), 4 + len); + String::from_utf8(bytes[4..].to_vec()).unwrap() +} + +#[derive(Debug)] +struct Error { + kind: u32, + message: String, +} + +fn check(error: *mut hlw_error) -> Result<(), Error> { + if error.is_null() { + return Ok(()); + } + let (mut ptr, mut len) = (std::ptr::null(), 0); + // SAFETY: `error` is a live error owned by the caller. + unsafe { + hlw_error_message(error, &mut ptr, &mut len); + let message = String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).into_owned(); + let kind = hlw_error_kind(error); + hlw_error_delete(error); + Err(Error { kind, message }) + } +} + +#[track_caller] +fn ok(error: *mut hlw_error) { + if let Err(e) = check(error) { + panic!("error {}: {}", e.kind, e.message); + } +} + +/// Host state shared with the callbacks through `env`. +#[derive(Default)] +struct Host { + printed: Mutex>, + fail: bool, + /// A sandbox `host_function` calls back into, recording the error kinds. + reenter: AtomicPtr, + reentry_errors: Mutex>>, +} + +unsafe extern "C" fn print( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + _result: *mut hlw_result, +) -> *mut hlw_error { + // SAFETY: `env` is the `Host` passed at registration and the arguments + // are valid for the call. + unsafe { + let host = &*(env as *const Host); + assert_eq!(nargs, 1); + let arg = std::slice::from_raw_parts((*args).data, (*args).len); + host.printed.lock().unwrap().push(decode_string(arg)); + } + std::ptr::null_mut() +} + +unsafe extern "C" fn host_function( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_result, +) -> *mut hlw_error { + // SAFETY: as for `print`. + unsafe { + let host = &*(env as *const Host); + if host.fail { + let message = "host failure"; + return hlw_error_new(message.as_ptr(), message.len()); + } + let sandbox = host.reenter.load(Ordering::SeqCst); + if !sandbox.is_null() { + let mut buf = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + let (mut snapshot, mut handle) = (std::ptr::null_mut(), std::ptr::null_mut()); + let errors = [ + hlw_sandbox_call( + sandbox, + ADD.as_ptr(), + ADD.len(), + std::ptr::null(), + 0, + &mut buf, + ), + hlw_sandbox_load(sandbox, std::ptr::null(), 0), + hlw_sandbox_snapshot(sandbox, &mut snapshot), + hlw_sandbox_interrupt_handle(sandbox, &mut handle), + ]; + let kinds = errors.map(|e| check(e).err().map(|e| e.kind)); + host.reentry_errors.lock().unwrap().extend(kinds); + } + assert_eq!(nargs, 1); + let input = decode_string(std::slice::from_raw_parts((*args).data, (*args).len)); + let output = encode_string(&format!("{input} and the host!")); + hlw_result_set(result, output.as_ptr(), output.len()); + } + std::ptr::null_mut() +} + +unsafe extern "C" fn drop_host(env: *mut c_void) { + // SAFETY: `env` is a reference from `Arc::into_raw` given to one + // registration, and the sandbox calls this once after its last use. + drop(unsafe { Arc::from_raw(env as *const Host) }); +} + +struct Sandbox(*mut hlw_sandbox); + +impl Sandbox { + fn new(host: &Arc, from_memory: bool) -> Sandbox { + let path = examples_common::get_wasm_module_path("component_sample.aot").unwrap(); + // SAFETY: every pointer passed is live and every string readable. + unsafe { + let config = hlw_config_new(); + hlw_config_scratch_size(config, 100 << 20); + // Room to load the component from memory. + hlw_config_h2g_pool_pages(config, 128); + hlw_config_h2g_queue_size(config, 128); + let mut sandbox = std::ptr::null_mut(); + ok(hlw_sandbox_new(config, &mut sandbox)); + hlw_config_delete(config); + + for (name, func) in [(PRINT, print as HostFunc), (HOST_FUNCTION, host_function)] { + let env = Arc::into_raw(host.clone()) as *mut c_void; + ok(hlw_sandbox_register( + sandbox, + name.as_ptr(), + name.len(), + 1, + Some(func), + env, + Some(drop_host), + )); + } + if from_memory { + let bytes = std::fs::read(&path).unwrap(); + ok(hlw_sandbox_load(sandbox, bytes.as_ptr(), bytes.len())); + } else { + ok(hlw_sandbox_load_file(sandbox, path.as_ptr(), path.len())); + } + Sandbox(sandbox) + } + } + + fn call(&self, name: &str, args: &[Vec]) -> Result, Error> { + let views = args + .iter() + .map(|a| hlw_bytes { + data: a.as_ptr(), + len: a.len(), + }) + .collect::>(); + let mut buf = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + // SAFETY: the sandbox is live, `views` outlives the call and `buf` + // is writable. + unsafe { + check(hlw_sandbox_call( + self.0, + name.as_ptr(), + name.len(), + views.as_ptr(), + views.len(), + &mut buf, + ))?; + let bytes = if buf.len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(buf.data, buf.len).to_vec() + }; + hlw_buf_delete(&mut buf); + Ok(bytes) + } + } + + fn add(&self, left: u32, right: u32) -> u32 { + let bytes = self + .call( + ADD, + &[left.to_le_bytes().to_vec(), right.to_le_bytes().to_vec()], + ) + .unwrap(); + u32::from_le_bytes(bytes.try_into().unwrap()) + } + + fn increment(&self) -> u32 { + let bytes = self.call(INCREMENT, &[]).unwrap(); + u32::from_le_bytes(bytes.try_into().unwrap()) + } + + fn snapshot(&self) -> Snapshot { + let mut snapshot = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `snapshot` is writable. + ok(unsafe { hlw_sandbox_snapshot(self.0, &mut snapshot) }); + Snapshot(snapshot) + } + + fn restore(&self, snapshot: &Snapshot) { + // SAFETY: the sandbox and snapshot are live. + ok(unsafe { hlw_sandbox_restore(self.0, snapshot.0) }); + } +} + +struct Snapshot(*mut hlw_snapshot); + +impl Drop for Snapshot { + fn drop(&mut self) { + // SAFETY: the snapshot is live and not used afterwards. + unsafe { hlw_snapshot_delete(self.0) }; + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + // SAFETY: the sandbox is live and not used afterwards. + unsafe { hlw_sandbox_delete(self.0) }; + } +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn calls_exports_and_imports() { + let host = Arc::new(Host::default()); + let sandbox = Sandbox::new(&host, false); + + assert_eq!(sandbox.add(1, 2), 3); + let mut snapshot = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `snapshot` is writable. + ok(unsafe { hlw_sandbox_snapshot(sandbox.0, &mut snapshot) }); + + // Only the debug build of the sample traps on overflow. A trapped + // instance can only be used again after a restore. + if cfg!(debug_assertions) { + let trap = sandbox + .call( + ADD, + &[u32::MAX.to_le_bytes().to_vec(), 1u32.to_le_bytes().to_vec()], + ) + .unwrap_err(); + assert!(trap.message.contains("add_overflow"), "{}", trap.message); + assert!(sandbox.call(ADD, &[vec![0; 4], vec![0; 4]]).is_err()); + } + // SAFETY: the sandbox and snapshot are live. + unsafe { + ok(hlw_sandbox_restore(sandbox.0, snapshot)); + hlw_snapshot_delete(snapshot); + } + assert_eq!(sandbox.add(3, 4), 7); + + let reply = sandbox.call(CALL_HOST, &[encode_string("Hello")]).unwrap(); + assert_eq!(decode_string(&reply), "Hello from component and the host!"); + + let reply = sandbox + .call(DO_SOMETHING, &[42u32.to_le_bytes().to_vec()]) + .unwrap(); + assert!(reply.is_empty()); + assert_eq!(*host.printed.lock().unwrap(), ["42"]); + + let missing = sandbox + .call("component-sample:example/adder#missing", &[]) + .unwrap_err(); + assert!(missing.message.contains("missing"), "{}", missing.message); + + drop(sandbox); + // Both registrations released their reference. + assert_eq!(Arc::strong_count(&host), 1); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn loads_from_memory() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), true); + assert_eq!(sandbox.add(20, 22), 42); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn host_errors_fail_the_call() { + let host = Arc::new(Host { + fail: true, + ..Host::default() + }); + let sandbox = Sandbox::new(&host, false); + let mut snapshot = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `snapshot` is writable. + ok(unsafe { hlw_sandbox_snapshot(sandbox.0, &mut snapshot) }); + + let error = sandbox + .call(CALL_HOST, &[encode_string("Hello")]) + .unwrap_err(); + assert!(error.message.contains("host failure"), "{}", error.message); + + // SAFETY: the sandbox and snapshot are live. + unsafe { + ok(hlw_sandbox_restore(sandbox.0, snapshot)); + hlw_snapshot_delete(snapshot); + } + assert_eq!(sandbox.add(2, 3), 5); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn restore_resets_guest_state() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), false); + assert_eq!(sandbox.increment(), 1); + let first = sandbox.snapshot(); + assert_eq!(sandbox.increment(), 2); + let second = sandbox.snapshot(); + assert_eq!(sandbox.increment(), 3); + + sandbox.restore(&first); + assert_eq!(sandbox.increment(), 2); + sandbox.restore(&second); + assert_eq!(sandbox.increment(), 3); + sandbox.restore(&first); + assert_eq!(sandbox.increment(), 2); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn repeated_snapshots_and_restores() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), false); + let base = sandbox.snapshot(); + for _ in 0..1000 { + assert_eq!(sandbox.increment(), 1); + drop(sandbox.snapshot()); + sandbox.restore(&base); + } +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn host_functions_cannot_use_their_sandbox() { + let host = Arc::new(Host::default()); + let sandbox = Sandbox::new(&host, false); + host.reenter.store(sandbox.0, Ordering::SeqCst); + let reply = sandbox.call(CALL_HOST, &[encode_string("Hello")]).unwrap(); + assert_eq!(decode_string(&reply), "Hello from component and the host!"); + assert_eq!( + *host.reentry_errors.lock().unwrap(), + [Some(HLW_ERROR_INVALID_STATE); 4] + ); + + // SAFETY: the sandbox is live. + let reload = check(unsafe { hlw_sandbox_load(sandbox.0, std::ptr::null(), 0) }); + assert_eq!(reload.unwrap_err().kind, HLW_ERROR_INVALID_STATE); + // The failed load keeps the loaded component. + assert_eq!(sandbox.add(1, 2), 3); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn interrupt_handle_is_idle_between_calls() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), false); + let mut handle = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `handle` is writable. + unsafe { + ok(hlw_sandbox_interrupt_handle(sandbox.0, &mut handle)); + assert!(!hlw_interrupt_handle_kill(handle)); + } + assert_eq!(sandbox.add(1, 1), 2); + // SAFETY: the handle is live and not used afterwards. + unsafe { hlw_interrupt_handle_delete(handle) }; +} diff --git a/src/hyperlight_wasm_csharp_bindgen/Cargo.toml b/src/hyperlight_wasm_csharp_bindgen/Cargo.toml new file mode 100644 index 00000000..360993f9 --- /dev/null +++ b/src/hyperlight_wasm_csharp_bindgen/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hyperlight-wasm-csharp-bindgen" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +publish = false +description = """ +Experimental. Native library that generates C# host bindings for hyperlight-wasm +from WIT. Used by the Hyperlight.Wasm.Build NuGet package at build time. +""" + +[lib] +name = "hyperlight_wasm_csharp_bindgen" +crate-type = ["cdylib", "lib"] + +[dependencies] +hyperlight-component-csharp.workspace = true diff --git a/src/hyperlight_wasm_csharp_bindgen/src/lib.rs b/src/hyperlight_wasm_csharp_bindgen/src/lib.rs new file mode 100644 index 00000000..d7440361 --- /dev/null +++ b/src/hyperlight_wasm_csharp_bindgen/src/lib.rs @@ -0,0 +1,165 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Generates C# host bindings from WIT for the Hyperlight.Wasm.Build +//! MSBuild task, which loads this library at build time. + +use std::any::Any; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; + +/// Generates C# bindings for a WIT world. +/// +/// `wit` is the UTF-8 path of a `.wit` file or directory. An empty `world` +/// selects the package's last world. All generated types go under the +/// namespace `ns`. +/// +/// Returns 0 and the C# source in `out`, or 1 and a UTF-8 error message. +/// Free `out` with [`hlw_bindgen_free`]. +/// +/// # Safety +/// Each string pointer must point to its length in readable bytes, or the +/// length must be zero. `out` and `out_len` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_bindgen_csharp( + wit: *const u8, + wit_len: usize, + world: *const u8, + world_len: usize, + ns: *const u8, + ns_len: usize, + out: *mut *mut u8, + out_len: *mut usize, +) -> i32 { + // SAFETY: guaranteed by the caller. + let args = unsafe { (str(wit, wit_len), str(world, world_len), str(ns, ns_len)) }; + let result = match args { + // The generator panics on WIT it cannot read, as the bindgen + // macros do. The message goes back to the build as the error. + (Ok(wit), Ok(world), Ok(ns)) => catch_unwind(AssertUnwindSafe(|| { + let world = (!world.is_empty()).then_some(world); + hyperlight_component_csharp::generate_from_path(Path::new(wit), world, ns) + .map_err(|e| e.to_string()) + })) + .unwrap_or_else(|panic| Err(panic_message(panic.as_ref()))), + _ => Err("argument is not valid UTF-8".to_string()), + }; + let (status, text) = match result { + Ok(source) => (0, source), + Err(message) => (1, message), + }; + let bytes = Box::into_raw(text.into_bytes().into_boxed_slice()); + // SAFETY: guaranteed by the caller. + unsafe { + *out_len = bytes.len(); + *out = bytes.cast(); + } + status +} + +fn panic_message(panic: &(dyn Any + Send)) -> String { + match panic.downcast_ref::() { + Some(message) => message.clone(), + None => panic.downcast_ref::<&str>().map_or_else( + || "WIT binding generation failed".to_string(), + |m| m.to_string(), + ), + } +} + +/// Frees text returned by [`hlw_bindgen_csharp`]. +/// +/// # Safety +/// `data` and `len` must come from one call to [`hlw_bindgen_csharp`], and +/// `data` must not be used afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_bindgen_free(data: *mut u8, len: usize) { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len)) }); +} + +/// # Safety +/// `data` must point to `len` readable bytes, or `len` must be zero. +unsafe fn str<'a>(data: *const u8, len: usize) -> Result<&'a str, std::str::Utf8Error> { + if len == 0 { + return Ok(""); + } + // SAFETY: guaranteed by the caller. + std::str::from_utf8(unsafe { std::slice::from_raw_parts(data, len) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate(wit: &str, world: &str, ns: &str) -> (i32, String) { + let (mut out, mut len) = (std::ptr::null_mut(), 0); + // SAFETY: the strings are readable and the outputs writable. + unsafe { + let status = hlw_bindgen_csharp( + wit.as_ptr(), + wit.len(), + world.as_ptr(), + world.len(), + ns.as_ptr(), + ns.len(), + &mut out, + &mut len, + ); + let text = String::from_utf8(std::slice::from_raw_parts(out, len).to_vec()).unwrap(); + hlw_bindgen_free(out, len); + (status, text) + } + } + + const WIT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/rust_guests/component_sample/wit/example.wit" + ); + + #[test] + fn generates_selected_world() { + let (status, source) = generate(WIT, "example", "Sample"); + assert_eq!(status, 0, "{source}"); + assert!(source.contains("public sealed class ExampleComponent")); + assert!(source.contains("\"component-sample:example/adder#add\"")); + } + + #[test] + fn reports_errors() { + let (status, message) = generate(WIT, "no-such-world", "Sample"); + assert_eq!(status, 1); + assert!(message.contains("no-such-world"), "{message}"); + let (status, message) = generate("/no/such/file.wit", "", "Sample"); + assert_eq!(status, 1); + assert!(message.contains("/no/such/file.wit"), "{message}"); + let (status, _) = generate(WIT, "example", "Not A Namespace"); + assert_eq!(status, 1); + } + + #[test] + fn dotnet_runtime_source_is_up_to_date() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../dotnet/src/Hyperlight.Wasm/Component/Runtime.cs" + ); + let committed = std::fs::read_to_string(path).unwrap().replace("\r\n", "\n"); + assert!( + committed == hyperlight_component_csharp::RUNTIME_SOURCE, + "copy hyperlight_component_csharp::RUNTIME_SOURCE to {path}" + ); + } +} diff --git a/src/tests/rust_guests/component_sample/src/bindings.rs b/src/tests/rust_guests/component_sample/src/bindings.rs index de018616..d34b956f 100644 --- a/src/tests/rust_guests/component_sample/src/bindings.rs +++ b/src/tests/rust_guests/component_sample/src/bindings.rs @@ -124,10 +124,18 @@ pub mod exports { #[cfg(target_arch = "wasm32")] _rt::run_ctors_once(); T::do_something(arg0 as u32); } + #[doc(hidden)] + #[allow(non_snake_case)] + pub unsafe fn _export_increment_cabi() -> i32 { + #[cfg(target_arch = "wasm32")] _rt::run_ctors_once(); + let result0 = T::increment(); + _rt::as_i32(result0) + } pub trait Guest { fn add(left: u32, right: u32) -> u32; fn call_host(input: _rt::String) -> _rt::String; fn do_something(number: u32) -> (); + fn increment() -> u32; } #[doc(hidden)] macro_rules! __export_component_sample_example_adder_cabi { @@ -148,7 +156,10 @@ pub mod exports { "component-sample:example/adder#do-something")] unsafe extern "C" fn export_do_something(arg0 : i32,) { unsafe { $($path_to_types)*:: _export_do_something_cabi::<$ty > (arg0) } } - }; + #[unsafe (export_name = + "component-sample:example/adder#increment")] unsafe extern "C" fn + export_increment() -> i32 { unsafe { $($path_to_types)*:: + _export_increment_cabi::<$ty > () } } }; }; } #[doc(hidden)] @@ -290,15 +301,16 @@ pub(crate) use __export_example_impl as export; )] #[doc(hidden)] #[allow(clippy::octal_escapes)] -pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 380] = *b"\ -\0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07\xfe\x01\x01A\x02\x01\ +pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 399] = *b"\ +\0asm\x0d\0\x01\0\0\x19\x16wit-component-encoding\x04\0\x07\x91\x02\x01A\x02\x01\ A\x04\x01B\x04\x01@\x01\x07messages\x01\0\x04\0\x05print\x01\0\x01@\x01\x05input\ s\0s\x04\0\x0dhost-function\x01\x01\x03\0\x1dcomponent-sample:example/host\x05\0\ -\x01B\x06\x01@\x02\x04lefty\x05righty\0y\x04\0\x03add\x01\0\x01@\x01\x05inputs\0\ +\x01B\x08\x01@\x02\x04lefty\x05righty\0y\x04\0\x03add\x01\0\x01@\x01\x05inputs\0\ s\x04\0\x09call-host\x01\x01\x01@\x01\x06numbery\x01\0\x04\0\x0cdo-something\x01\ -\x02\x04\0\x1ecomponent-sample:example/adder\x05\x01\x04\0\x20component-sample:e\ -xample/example\x04\0\x0b\x0d\x01\0\x07example\x03\0\0\0G\x09producers\x01\x0cpro\ -cessed-by\x02\x0dwit-component\x070.227.1\x10wit-bindgen-rust\x060.41.0"; +\x02\x01@\0\0y\x04\0\x09increment\x01\x03\x04\0\x1ecomponent-sample:example/adde\ +r\x05\x01\x04\0\x20component-sample:example/example\x04\0\x0b\x0d\x01\0\x07examp\ +le\x03\0\0\0G\x09producers\x01\x0cprocessed-by\x02\x0dwit-component\x070.227.1\x10\ +wit-bindgen-rust\x060.41.0"; #[inline(never)] #[doc(hidden)] pub fn __link_custom_section_describing_imports() { diff --git a/src/tests/rust_guests/component_sample/src/lib.rs b/src/tests/rust_guests/component_sample/src/lib.rs index 6d2a5079..ae2bca58 100644 --- a/src/tests/rust_guests/component_sample/src/lib.rs +++ b/src/tests/rust_guests/component_sample/src/lib.rs @@ -2,11 +2,16 @@ #[rustfmt::skip] mod bindings; +use std::sync::atomic::{AtomicU32, Ordering}; + use bindings::component_sample::example::host::{host_function, print}; use bindings::exports::component_sample::example::adder::Guest; struct Component {} +/// State that a snapshot restore resets. +static COUNTER: AtomicU32 = AtomicU32::new(0); + impl Guest for Component { fn add(left: u32, right: u32) -> u32 { left + right @@ -20,6 +25,10 @@ impl Guest for Component { fn do_something(number: u32) { print(&format!("{number}")); } + + fn increment() -> u32 { + COUNTER.fetch_add(1, Ordering::Relaxed) + 1 + } } bindings::export!(Component with_types_in bindings); diff --git a/src/tests/rust_guests/component_sample/wit/example.wit b/src/tests/rust_guests/component_sample/wit/example.wit index 93ddd517..90c288a5 100644 --- a/src/tests/rust_guests/component_sample/wit/example.wit +++ b/src/tests/rust_guests/component_sample/wit/example.wit @@ -13,6 +13,7 @@ interface adder { add: func(left: u32, right: u32) -> u32; call-host: func(input: string) -> string; do-something: func(number: u32); + increment: func() -> u32; } interface host {