Skip to content
Draft
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 @@ -52,7 +52,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 @@ -64,6 +64,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 @@ -134,6 +135,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
8 changes: 8 additions & 0 deletions .github/workflows/dep_code_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ jobs:
- name: Run loom concurrency tests
run: just test-loom

# Uses the .NET SDK installed in the runner image.
- name: Test generated C# bindings
run: just test-csharp-bindings

# A job step, not a post-job step, so the save runs before rust-cache's
# post-job cleanup deletes the sysroot. Only main saves, as caches are not
# shared across branches.
Expand Down Expand Up @@ -185,6 +189,10 @@ jobs:
just test-compilation-no-default-features debug
just test-compilation-no-default-features release

# Uses the .NET SDK installed in the runner image.
- name: Test generated C# bindings
run: just test-csharp-bindings

# A job step, not a post-job step, so the save runs before rust-cache's
# post-job cleanup deletes the sysroot. Only main saves, as caches are not
# shared across branches.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Prerelease] - Unreleased

### Added
* `hyperlight-component-csharp` crate for generating C# host bindings from WIT worlds by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/XXXX

### Changed
* **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/XXXX

### Removed

Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ members = [
"src/hyperlight_libc",
"src/hyperlight_component_util",
"src/hyperlight_component_macro",
"src/hyperlight_component_csharp",
"src/trace_dump",
]
# Guests have custom linker flags and live in their own nested workspace,
Expand Down Expand Up @@ -47,6 +48,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
16 changes: 11 additions & 5 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,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 @@ -285,8 +285,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 @@ -336,6 +337,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

# generates and tests C# bindings for the component test fixtures. Needs the .NET 8 SDK or newer.
test-csharp-bindings:
dotnet run --project src/tests/component_wire/csharp

# 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 @@ -414,6 +419,7 @@ 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 }}
{{ if hyperlight-target-arch == "x86_64" { "./hack/clippy-package-features.sh hyperlight-guest-tracing " + target } else { "" } }}
just clippy-guests {{ target }}

Expand All @@ -423,7 +429,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 @@ -548,7 +554,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
99 changes: 99 additions & 0 deletions docs/component-wire-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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`. 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` |
| `own<R>`, `borrow<R>` | `u32` handle |

[`src/tests/component_wire/vectors.txt`](../src/tests/component_wire/vectors.txt)
lists encodings of the types in
[`wire.wit`](../src/tests/component_wire/wire.wit). The Rust host bindings and
the C# bindings are both tested against it.
[`csharp/edge.wit`](../src/tests/component_wire/csharp/edge.wit) covers C#
naming cases. `just test-csharp-bindings` generates C# for both and runs the C#
tests.

## C# host bindings

`hyperlight_component_csharp::generate_from_path` generates C# for the host
side of a world. The generated code depends on the `Hyperlight.Component`
namespace in `hyperlight_component_csharp::RUNTIME_SOURCE`. It targets .NET 8
or later.

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.

| 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 |

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

Records, variant cases, `Option` and `Result` compare lists by element.
Variants and `Result` have `Match` and `Switch` methods that take one function
per case. These give a compile error when a case is missing.

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`.
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
22 changes: 22 additions & 0 deletions src/hyperlight_component_csharp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[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 = """
Generates C# host bindings for Hyperlight components from WIT worlds
"""

[lib]
name = "hyperlight_component_csharp"

[dependencies]
wit-parser = { version = "0.259.0" }

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

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, needed if the package has several
"MyApp.Guest", // root C# namespace
)?;
std::fs::write("Bindings.g.cs", cs)?;
```

`generate` takes a parsed `wit_parser::Resolve` and `WorldId`.

For a world `w`, the output has:

* `WComponent`, which calls the guest's exports.
* `WComponent.RegisterImports`, which registers host implementations of the
guest's imports.
* A C# type for each record, variant, enum and flags type.

See [docs/component-wire-format.md](../../docs/component-wire-format.md) for
the type mapping and the wire format.

## 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.
29 changes: 29 additions & 0 deletions src/hyperlight_component_csharp/examples/csharp_bindgen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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::path::Path;
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;
};
let result = generate_from_path(Path::new(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