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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/CargoPublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
VERSION="${VERSION#refs/heads/release/v}"
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
./dev/verify-version.sh "$VERSION" hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing
./dev/verify-version.sh "$VERSION" hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing

- name: Determine which crates need publishing
run: |
Expand All @@ -62,6 +62,7 @@ jobs:
hyperlight-guest-bin \
hyperlight-component-util \
hyperlight-component-macro \
hyperlight-component-csharp \
hyperlight-host \
hyperlight-guest-tracing >> "$GITHUB_ENV"

Expand Down Expand Up @@ -132,6 +133,13 @@ jobs:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
if: env.PUBLISH_HYPERLIGHT_HOST != 'false'

- name: Publish hyperlight-component-csharp
continue-on-error: ${{ inputs.dry_run }}
run: cargo publish --manifest-path ./src/hyperlight_component_csharp/Cargo.toml ${{ inputs.dry_run && '--dry-run' || '' }}
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
if: env.PUBLISH_HYPERLIGHT_COMPONENT_CSHARP != 'false'


# TODO: Do we want to publish hyperlight-guest-capi to crates.io given that it's not for Rust consumption?
# - name: Publish hyperlight-guest-capi
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/dep_build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ jobs:
RUST_LOG: debug
run: just test-rust-tracing ${{ inputs.config }}

# The X64 runner images have the .NET SDK.
- name: Test generated C# bindings
if: inputs.arch == 'X64'
run: just test-csharp-bindings ${{ inputs.config }}

- name: Install oras
if: runner.os != 'Windows' || inputs.arch != 'arm64'
uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added
* `Sandbox::snapshot_with_options` takes a snapshot with `SnapshotOptions`. `SnapshotOptions::scratch_size` gives the sandbox, and later restores of the snapshot, a different scratch size. A guest can boot with the scratch its initialisation needs and run guest function calls from a snapshot with only the scratch they need, so each restore costs less. It can also give a guest more scratch than it booted with.
* `hyperlight-component-csharp` crate for generating C# host bindings from WIT worlds by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1946

### Changed
* Core dumps no longer record the guest binary path. The process name and command line are always `<unknown>`, as they already were for sandboxes created from snapshots by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1975
Expand All @@ -31,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

* **Breaking:** The transport arena sits one unmapped guard page below the reserved scratch-top pages, at an address independent of the scratch size, and the page tables at the scratch base. The guest's page allocator stops at the arena GPA the host publishes in the scratch-top metadata, so guests must be rebuilt. `hyperlight_common::layout::scratch_allocator_limit_gpa` is renamed `scratch_reserved_top_gpa`, the start of the reserved pages. An allocator that stops there runs into the arena. The minimum scratch size grows by that guard page. Snapshot ABI 7: existing snapshots must be regenerated.

* **Breaking:** `hyperlight-component-macro`: `host_bindgen!` and `guest_bindgen!` encode values little-endian and qualify function names with their interface, such as `wasi:http/types@0.2.0#[method]fields.get` by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1946
Comment thread
jsturtevant marked this conversation as resolved.
Resolved

### Removed

### Fixed
Expand Down
15 changes: 15 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ members = [
"src/hyperlight_libc",
"src/hyperlight_component_util",
"src/hyperlight_component_macro",
"src/hyperlight_component_csharp",
"src/tests/csharp_host/native",
"src/trace_dump",
]
# Guests have custom linker flags and live in their own nested workspace,
Expand Down Expand Up @@ -47,6 +49,7 @@ hyperlight-guest-tracing = { path = "src/hyperlight_guest_tracing", version = "0
hyperlight-libc = { path = "src/hyperlight_libc", version = "0.18.0", default-features = false }
hyperlight-component-util = { path = "src/hyperlight_component_util", version = "0.18.0", default-features = false }
hyperlight-component-macro = { path = "src/hyperlight_component_macro", version = "0.18.0", default-features = false }
hyperlight-component-csharp = { path = "src/hyperlight_component_csharp", version = "0.18.0", default-features = false }

[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
Expand Down
20 changes: 15 additions & 5 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ test-like-ci config=default-target hypervisor="kvm":
@# test the tracing related features
{{ if os() == "linux" { "RUST_LOG=debug just test-rust-tracing " + config + " " + if hypervisor == "mshv3" { "mshv3" } else { "kvm" } } else { "" } }}

@# test generated C# bindings against witguest
{{ if arch() == "x86_64" { "just test-csharp-bindings " + config } else { "" } }}

code-checks-like-ci config=default-target hypervisor="kvm":
@# Ensure up-to-date Cargo.lock
cargo fetch --locked
Expand All @@ -149,7 +152,7 @@ code-checks-like-ci config=default-target hypervisor="kvm":
{{ if os() == "linux" { "just clippy-exhaustive " + config } else { "" } }}

@# Verify MSRV
./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing
./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing

@# Check cargo features compile
just check
Expand Down Expand Up @@ -297,8 +300,9 @@ test-integration target=default-target features="":
@# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests)
{{ cargo-cmd }} test {{ if features =="" {"--features executable_heap"} else if features=="no-default-features" {"--no-default-features --features executable_heap"} else {"--no-default-features -F executable_heap," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test integration_test execute_on_heap

@# run component-util integration tests that exercise WIT input codegen
{{ cargo-cmd }} test -p hyperlight-component-util --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test wasmtime_guest_codegen
@# run component-util unit tests and the integration tests that exercise WIT input codegen
{{ cargo-cmd }} test -p hyperlight-component-util --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib --test wasmtime_guest_codegen
{{ cargo-cmd }} test -p hyperlight-component-csharp --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib

@# run the rest of the integration tests. `snapshot_goldens` is in
@# the glob but no-ops without a mode token, so it needs no golden
Expand Down Expand Up @@ -348,6 +352,10 @@ test-rust-tracing target=default-target features="":
test-doc target=default-target features="":
{{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} {{ if features =="" {''} else { "--features " + features } }} --doc

# runs the C# binding tests against witguest. Needs the .NET 8 SDK or newer and the guests from `just guests`.
test-csharp-bindings target=default-target:
dotnet run --project src/tests/csharp_host --property:HyperlightProfile={{ target }}

# Nextest runs one Miri process per job, each with its own interpreter, so
# memory grows with the job count at roughly 0.4GB per job. Lower MIRI_JOBS on
# a machine with many cores but little RAM.
Expand Down Expand Up @@ -426,6 +434,8 @@ clippy-exhaustive target=default-target:
./hack/clippy-package-features.sh hyperlight-testing {{ target }} {{ target-triple }}
./hack/clippy-package-features.sh hyperlight-component-macro {{ target }} {{ target-triple }}
./hack/clippy-package-features.sh hyperlight-component-util {{ target }} {{ target-triple }}
./hack/clippy-package-features.sh hyperlight-component-csharp {{ target }} {{ target-triple }}
./hack/clippy-package-features.sh hyperlight-csharp-test-native {{ target }} {{ target-triple }}
{{ if hyperlight-target-arch == "x86_64" { "./hack/clippy-package-features.sh hyperlight-guest-tracing " + target } else { "" } }}
just clippy-guests {{ target }}

Expand All @@ -435,7 +445,7 @@ clippy-package package target=default-target:

# Verify Minimum Supported Rust Version
verify-msrv:
./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing
./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing

#####################
### RUST EXAMPLES ###
Expand Down Expand Up @@ -564,7 +574,7 @@ ensure-cargo-llvm-cov:

# host-side packages to collect coverage for (guest/no_std crates are excluded because they
# define #[panic_handler] and cannot be compiled for the host target under coverage instrumentation)
coverage-packages := "-p hyperlight-common -p hyperlight-host -p hyperlight-testing -p hyperlight-component-util -p hyperlight-component-macro"
coverage-packages := "-p hyperlight-common -p hyperlight-host -p hyperlight-testing -p hyperlight-component-util -p hyperlight-component-macro -p hyperlight-component-csharp"

# run all tests and examples with coverage instrumentation, collecting profdata without
# generating a report. Mirrors test-like-ci + run-examples-like-ci to exercise all code paths
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This project is composed internally of several components, depicted in the below
* [Security guidance for developers](./security-guidance-for-developers.md)
* [Paging Development Notes](./paging-development-notes.md)
* [Virtqueue host and guest communication](./virtio-host-guest-communication.md)
* [Component wire format](./component-wire-format.md)
* [How to debug a Hyperlight guest](./how-to-debug-a-hyperlight-guest.md)
* [How to use Flatbuffers in Hyperlight](./how-to-use-flatbuffers.md)
* [How to make a Hyperlight release](./how-to-make-releases.md)
Expand Down
67 changes: 67 additions & 0 deletions docs/component-wire-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Component wire format

Bindings generated from WIT exchange values in this format. It is used by
`host_bindgen!`, `guest_bindgen!`, the hyperlight-wasm guest runtime, and the
C# generator in
[`hyperlight-component-csharp`](../src/hyperlight_component_csharp). The host
and guest must be built from the same Hyperlight version.

## Calls

A component function is a Hyperlight guest or host function. Each WIT
parameter is a separate `VecBytes` argument holding the encoded value. The
result is one `VecBytes` value. A function without a result returns an empty
buffer.

## Function names

| Function | Wire name | Example |
|---|---|---|
| Member of an imported or exported instance | `<instance>#<function>` | `wasi:http/types@0.2.0#[method]fields.get` |
| Imported or exported by the world itself | `<function>` | `run` |

`<instance>` and `<function>` are the import and export names in the
component type, including package versions and resource method prefixes.

## Values

Integers, floats and lengths are little-endian. Compound values concatenate
their parts with no alignment.

| WIT type | Encoding |
|---|---|
| `bool` | 1 byte, `0` or `1` |
| `s8`, `u8` | 1 byte |
| `s16`, `u16` | 2 bytes |
| `s32`, `u32`, `f32` | 4 bytes |
| `s64`, `u64`, `f64` | 8 bytes |
| `char` | Unicode scalar value as `u32` |
| `string` | `u32` byte length, then UTF-8 bytes |
| `list<T>` | `u32` element count, then each element |
| `list<T, N>` | `N` elements |
| `record`, `tuple` | Fields in declaration order |
| `option<T>` | `u8` tag. `0` is `none`. `1` is `some`, followed by the payload |
| `result<T, E>` | `u8` tag. `0` is `ok` and `1` is `err`, followed by the payload if the case has a type |
| `variant` | `u32` case index, followed by the payload if the case has a type |
| `enum` | `u32` case index |
| `flags` | Bitset of `ceil(n / 8)` bytes. Flag `i` is bit `i % 8` of byte `i / 8`. Other bits are zero |
| `own<R>`, `borrow<R>` | `u32` handle |

Receivers read a nonzero `bool`, `option` tag or `result` tag as `true`,
`some` or `err`. They ignore unused flag bits and trailing bytes.

## Resources

Resources are defined by the host. A handle indexes the host's table for the
resource type. Passing `own<R>` to the guest adds the value to the table, and
passing `borrow<R>` adds it until the call returns. The guest returns a handle
to give the value back or to lend it to a host method. Resources defined by the
guest are not supported.

## Tests

The Rust and C# host bindings are both tested against
[`witguest`](../src/tests/rust_guests/witguest), which implements
[`guest.wit`](../src/tests/rust_guests/witguest/guest.wit).
[`edge.wit`](../src/tests/csharp_host/edge.wit) covers C# naming cases. After
`just guests`, `just test-csharp-bindings` runs the C# tests.
1 change: 1 addition & 0 deletions docs/how-to-make-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ When this job is done, a new [GitHub release](https://github.com/hyperlight-dev/
- `hyperlight-guest-bin`
- `hyperlight-component-util`
- `hyperlight-component-macro`
- `hyperlight-component-csharp`
- `hyperlight-host`
- `hyperlight-guest-tracing`

Expand Down
21 changes: 21 additions & 0 deletions src/hyperlight_component_csharp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "hyperlight-component-csharp"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
readme = "README.md"
description = """
Experimental. Generates C# host bindings for Hyperlight components from WIT worlds
"""

[lib]
name = "hyperlight_component_csharp"

[dependencies]
hyperlight-component-util = { workspace = true }

[dev-dependencies]
proc-macro2 = { version = "1.0.106" }
79 changes: 79 additions & 0 deletions src/hyperlight_component_csharp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# hyperlight-component-csharp

> **Warning:** This crate is experimental. Its API and the generated C# can
> change in any release.

Generates C# host bindings for a WIT world. A .NET host uses them to call a
Hyperlight component guest and to serve its imports.

hyperlight-wasm's .NET packages use this crate to generate bindings at build
time. Hyperlight itself has no .NET host.

## Usage

```rust
let cs = hyperlight_component_csharp::generate_from_path(
Path::new("wit/world.wit"),
None, // world name, or None for the last world
"MyApp.Guest", // root C# namespace
)?;
std::fs::write("Bindings.g.cs", cs)?;
```

`generate` takes a `hyperlight_component_util::util::WitSource`, the input
type of the bindgen macros. It reads the world through the same component type
elaboration as the macros.

Values use the [component wire format](../../docs/component-wire-format.md).
The generated code targets .NET 8 or later.

## Generated code

For a world `w`, the generator emits:

* `WComponent`, constructed from an `IGuestCaller`. It has a property for each
exported interface and a method for each function the world exports.
* `WComponent.RegisterImports`, which registers host functions on an
`IHostFunctionRegistry` from an implementation of each imported interface.
* A C# interface `IName` for each imported interface `name`, and `IWImports`
for functions the world imports.
* Types for every record, variant, enum and flags in the world's interfaces.
* A C# interface `IR` for each resource `r`, with a method for each of its
methods. The constructor and static functions are `NewR` and `RFunction` on
the interface that defines `r`.
* `WResources` when the world uses resources. It holds the handle tables.
`RegisterImports` returns it and the `WComponent` constructor takes it.

| WIT type | C# type |
|---|---|
| `bool`, integers, floats | `bool`, `sbyte` to `ulong`, `float`, `double` |
| `char` | `System.Text.Rune` |
| `string` | `string` |
| `list<u8>`, `list<u8, N>` | `byte[]` |
| `list<T>`, `list<T, N>` | `IReadOnlyList<T>` |
| `tuple<...>` | Value tuple |
| `option<T>` | `T?`, or `Option<T>` when `T` is an `option` |
| `result<T, E>` | `Result<T, E>`, with `Unit` for a missing type |
| `record` | Sealed record with `required init` properties |
| `variant` | Abstract record with a nested sealed record per case |
| `enum` | `enum` with `uint` values |
| `flags` | `[Flags]` enum with `uint` values |
| `own<r>`, `borrow<r>` | `IR` |

Resources defined by the guest, async functions, futures, streams, maps and
flags with more than 32 members are rejected.

For a root namespace `Root`, types from `ns:pkg/iface` are in
`Root.Ns.Pkg.Iface`. The C# interface or exports class for `iface` is in
`Root.Ns.Pkg`. A world that uses two versions of a package gets the version
after `Pkg`, such as `Root.Ns.Pkg.V1_0_0`.

## Runtime.cs

The generated code needs the C# types in [`src/Runtime.cs`](src/Runtime.cs).
The Rust constant `hyperlight_component_csharp::RUNTIME_SOURCE` holds the text
of this file, so tools can write it out next to the generated code.

A .NET host library, such as hyperlight-wasm's, includes this file in its build.
It implements the file's `IGuestCaller` and `IHostFunctionRegistry` interfaces
with its sandbox.
37 changes: 37 additions & 0 deletions src/hyperlight_component_csharp/examples/csharp_bindgen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The Hyperlight Authors.

//! Generates C# host bindings for a WIT world.
//!
//! Usage: `csharp_bindgen <wit path> <namespace> <output file>`

use std::process::ExitCode;

use hyperlight_component_csharp::generate_from_path;

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let [wit, namespace, out] = args.as_slice() else {
eprintln!("usage: csharp_bindgen <wit path> <namespace> <output file>");
return ExitCode::FAILURE;
};
// `cargo run` sets CARGO_MANIFEST_DIR, which would resolve a relative
// path against this crate instead of the caller's directory.
let wit = match std::path::absolute(wit) {
Ok(wit) => wit,
Err(e) => {
eprintln!("{wit}: {e}");
return ExitCode::FAILURE;
}
};
let result = generate_from_path(&wit, None, namespace)
.map_err(|e| e.to_string())
.and_then(|cs| std::fs::write(out, cs).map_err(|e| format!("{out}: {e}")));
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
}
}
}
Loading
Loading