From c0840053f6b55635d56a4a8445a60f011d6a60ad Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:44:56 -0700 Subject: [PATCH 1/7] Run hyperlight-component-util unit tests in just test Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- Justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index fbb2b83ce..2b1e79ad2 100644 --- a/Justfile +++ b/Justfile @@ -297,8 +297,8 @@ 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 @# 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 From c50e353acaaac0d818d7a59a94df598ded65c184 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:45:08 -0700 Subject: [PATCH 2/7] Name component functions by instance and encode little-endian Wire names were the module path plus the function, such as r#wasi::r#clocks::now. They dropped the interface and version, so functions in different interfaces collided. They are now #, such as wasi:clocks/monotonic-clock@0.2.0#now. Values are encoded little-endian. Every supported target is little-endian, so the bytes are unchanged. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- CHANGELOG.md | 2 + src/hyperlight_component_util/src/emit.rs | 16 +++++ src/hyperlight_component_util/src/guest.rs | 4 +- src/hyperlight_component_util/src/hl.rs | 77 +++++++++++----------- src/hyperlight_component_util/src/host.rs | 4 +- 5 files changed, 60 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73ce1620..c3a58d2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,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 + ### Removed ### Fixed diff --git a/src/hyperlight_component_util/src/emit.rs b/src/hyperlight_component_util/src/emit.rs index 96f5f45de..a49fe209c 100644 --- a/src/hyperlight_component_util/src/emit.rs +++ b/src/hyperlight_component_util/src/emit.rs @@ -414,6 +414,10 @@ pub struct State<'a, 'b> { /// When a name is in this set, the parent namespace is prepended to /// disambiguate the trait member name. pub colliding_import_names: HashSet, + /// Full kebab name of the instance whose functions are being + /// emitted, e.g. `wasi:http/types@0.2.0`. Wire function names are + /// derived from it. + pub cur_instance: Option, } /// Create a State with all of its &mut references pointing to @@ -467,6 +471,7 @@ impl<'a, 'b> State<'a, 'b> { is_guest, is_wasmtime_guest, colliding_import_names: HashSet::new(), + cur_instance: None, } } pub fn clone<'c>(&'c mut self) -> State<'c, 'b> { @@ -489,6 +494,7 @@ impl<'a, 'b> State<'a, 'b> { is_guest: self.is_guest, is_wasmtime_guest: self.is_wasmtime_guest, colliding_import_names: self.colliding_import_names.clone(), + cur_instance: self.cur_instance.clone(), } } /// Obtain a reference to the [`Mod`] that we are currently @@ -524,6 +530,13 @@ impl<'a, 'b> State<'a, 'b> { s.mod_cursor = cursor; s } + /// Copy the state to emit the functions of the instance `wn`, + /// moving the module cursor to its namespace. + pub fn with_instance<'c>(&'c mut self, wn: &WitName) -> State<'c, 'b> { + let mut s = self.with_cursor(wn.namespace_idents()); + s.cur_instance = Some(wn.kebab.to_string()); + s + } /// Copy the state, replacing its [`State::cur_needs_vars`] reference, /// allowing a caller to capture the vars referenced by any emit /// run with the resultant state @@ -813,6 +826,8 @@ impl<'a, 'b> State<'a, 'b> { /// namespaces, an actual name, and possibly a SemVer version #[derive(Debug, Clone)] pub struct WitName<'a> { + /// The unparsed name + pub kebab: &'a str, pub namespaces: Vec<&'a str>, pub name: &'a str, pub _version: Vec<&'a str>, @@ -844,6 +859,7 @@ pub fn split_wit_name(n: &str) -> WitName<'_> { let name = versioned_name.next().unwrap(); namespaces.extend(slash_components.rev()); WitName { + kebab: n, namespaces, name, _version: versioned_name.collect(), diff --git a/src/hyperlight_component_util/src/guest.rs b/src/hyperlight_component_util/src/guest.rs index ffbcb596f..339571aa8 100644 --- a/src/hyperlight_component_util/src/guest.rs +++ b/src/hyperlight_component_util/src/guest.rs @@ -127,7 +127,7 @@ fn emit_import_extern_decl<'a, 'b, 'c>( /// declaration that implements this imported instance in terms of /// hyperlight host calls fn emit_import_instance<'a, 'b, 'c>(s: &'c mut State<'a, 'b>, wn: WitName, it: &'c Instance<'b>) { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); let imports = it @@ -241,7 +241,7 @@ fn emit_export_instance<'a, 'b, 'c>( path: Vec, it: &'c Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); let exports = it diff --git a/src/hyperlight_component_util/src/hl.rs b/src/hyperlight_component_util/src/hl.rs index 6029468fe..a91c57e7d 100644 --- a/src/hyperlight_component_util/src/hl.rs +++ b/src/hyperlight_component_util/src/hl.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use itertools::Itertools; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; @@ -9,16 +8,16 @@ use crate::emit::{ResolvedBoundVar, State, kebab_to_cons, kebab_to_flags_const, use crate::etypes::{self, Defined, Handleable, Tyvar, Value}; use crate::rtypes; -/// Construct a string that can be used "on the wire" to identify a -/// given function between the guest/host. This should be replaced -/// with an integer index so that we can dispatch less dynamically in -/// the future. +/// Construct the string that identifies a function on the wire +/// between the guest and host: `#` for instance +/// members, e.g. `wasi:http/types@0.2.0#[method]fields.get`, and the +/// plain function name for functions imported or exported directly +/// by the world. pub fn emit_fn_hl_name(s: &State, kebab: &str) -> String { - s.mod_cursor - .iter() - .map(|x| x.to_string()) - .chain(std::iter::once(kebab.to_string())) - .join("::") + match &s.cur_instance { + Some(instance) => format!("{instance}#{kebab}"), + None => kebab.to_string(), + } } /// Emit code to unmarshal a value into a toplevel type (i.e. types @@ -133,7 +132,7 @@ pub fn emit_hl_unmarshal_toplevel_value( } }); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let #inid = &#id[4..]; match n { #(#vcs,)* @@ -148,7 +147,7 @@ pub fn emit_hl_unmarshal_toplevel_value( quote! { #i => ( #tname::#case_name, 4) } }); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()); match n { #(#vcs,)* _ => panic!("invalid value for enum"), @@ -193,15 +192,15 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let (tid, width) = rtypes::numeric_rtype(vt); let blen = width as usize / 8; quote! { - (#tid::from_ne_bytes(#id[0..#blen].try_into().unwrap()), #blen) + (#tid::from_le_bytes(#id[0..#blen].try_into().unwrap()), #blen) } } Value::Char => quote! { - (unsafe { char::from_u32_unchecked(u32::from_ne_bytes( + (unsafe { char::from_u32_unchecked(u32::from_le_bytes( #id[0..4].try_into().unwrap())) }, 4) }, Value::String => quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()) as usize; + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()) as usize; let s = ::alloc::string::ToString::to_string(::core::str::from_utf8(&#id[4..4 + n]).unwrap()); // todo: better error handling (s, n + 4) }, @@ -210,7 +209,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let inid = format_ident!("{}_elem", id); let vtun = emit_hl_unmarshal_value(s, inid.clone(), vt); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()) as usize; + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()) as usize; let mut #retid = alloc::vec::Vec::new(); let mut cursor = 4; for i in 0..n { @@ -270,7 +269,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let inid = format_ident!("{}_body", id); let vtun = emit_hl_unmarshal_value(s, inid.clone(), vt); quote! { - let n = u8::from_ne_bytes(#id[0..1].try_into().unwrap()); + let n = u8::from_le_bytes(#id[0..1].try_into().unwrap()); if n != 0 { let #inid = &#id[1..]; let (x, b) = { #vtun }; @@ -293,7 +292,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr quote! { ((), 0) } }; quote! { - let i = u8::from_ne_bytes(#id[0..1].try_into().unwrap()); + let i = u8::from_le_bytes(#id[0..1].try_into().unwrap()); let #inid = &#id[1..]; if i == 0 { let (x, b) = { #vtun1 }; @@ -311,19 +310,19 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let rid = format_ident!("HostResource{}", vi); if s.is_wasmtime_guest { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (::wasmtime::component::Resource::<#rid>::new_own(i), 4) } } else { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (#rid { rep: i }, 4) } } } else { let rid = format_ident!("resource{}", vi); quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let Some(v) = rts.#rid[i as usize].take() else { // todo: better error handling panic!(""); @@ -339,7 +338,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let rid = format_ident!("HostResource{}", vi); if s.is_wasmtime_guest { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (::wasmtime::component::Resource::<#rid>::new_borrow(i), 4) } } else { @@ -356,7 +355,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr // AsRef or something in the // future... quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (#rid { rep: i }, 4) } @@ -364,7 +363,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr } else { let rid = format_ident!("resource{}", vi); quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let Some(v) = rts.#rid[i as usize].borrow() else { // todo: better error handling panic!(""); @@ -474,7 +473,7 @@ pub fn emit_hl_marshal_toplevel_value( let vtun = emit_hl_marshal_value(s, bodyid.clone(), ty); quote! { #tname::#case_name(#bodyid) => { - #retid.extend(u32::to_ne_bytes(#i)); + #retid.extend(u32::to_le_bytes(#i)); #retid.extend({ #vtun }) } } @@ -482,7 +481,7 @@ pub fn emit_hl_marshal_toplevel_value( None => { quote! { #tname::#case_name => { - #retid.extend(u32::to_ne_bytes(#i)); + #retid.extend(u32::to_le_bytes(#i)); } } } @@ -504,7 +503,7 @@ pub fn emit_hl_marshal_toplevel_value( quote! { #tname::#case_name => #i } }); quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(match #id { + alloc::vec::Vec::from(u32::to_le_bytes(match #id { #(#vcs,)* })) } @@ -528,10 +527,10 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea }, Value::S(_) | Value::U(_) | Value::F(_) => { let (tid, _) = rtypes::numeric_rtype(vt); - quote! { alloc::vec::Vec::from(#tid::to_ne_bytes(#id)) } + quote! { alloc::vec::Vec::from(#tid::to_le_bytes(#id)) } } Value::Char => quote! { - alloc::vec::Vec::from((#id as u32).to_ne_bytes()) + alloc::vec::Vec::from((#id as u32).to_le_bytes()) }, Value::String => { let retid = format_ident!("{}_string", id); @@ -539,7 +538,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { let mut #retid = alloc::vec::Vec::new(); let #bytesid = #id.into_bytes(); - #retid.extend(alloc::vec::Vec::from(u32::to_ne_bytes(#bytesid.len() as u32))); + #retid.extend(alloc::vec::Vec::from(u32::to_le_bytes(#bytesid.len() as u32))); #retid.extend(#bytesid); #retid } @@ -551,7 +550,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { let mut #retid = alloc::vec::Vec::new(); let n = #id.len(); - #retid.extend(alloc::vec::Vec::from(u32::to_ne_bytes(n as u32))); + #retid.extend(alloc::vec::Vec::from(u32::to_le_bytes(n as u32))); for #inid in #id { #retid.extend({ #vtun }) } @@ -598,11 +597,11 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { match #id { ::core::option::Option::Some(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(1)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(1)); #retid.extend({ #vtun }); #retid }, - ::core::option::Option::None => alloc::vec::Vec::from(u8::to_ne_bytes(0)) + ::core::option::Option::None => alloc::vec::Vec::from(u8::to_le_bytes(0)) } } } @@ -624,12 +623,12 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { match #id { ::core::result::Result::Ok(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(0)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(0)); #vtun1 #retid }, ::core::result::Result::Err(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(1)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(1)); #vtun2 #retid }, @@ -646,14 +645,14 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! {} }; quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(#id.rep #call)) + alloc::vec::Vec::from(u32::to_le_bytes(#id.rep #call)) } } else { let rid = format_ident!("resource{}", vi); quote! { let i = rts.#rid.len(); rts.#rid.push_back(::hyperlight_common::resource::ResourceEntry::give(#id)); - alloc::vec::Vec::from(u32::to_ne_bytes(i as u32)) + alloc::vec::Vec::from(u32::to_le_bytes(i as u32)) } } } @@ -667,7 +666,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! {} }; quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(#id.rep #call)) + alloc::vec::Vec::from(u32::to_le_bytes(#id.rep #call)) } } else { let rid = format_ident!("resource{}", vi); @@ -676,7 +675,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea let (lrg, re) = ::hyperlight_common::resource::ResourceEntry::lend(#id); to_cleanup.push(Box::new(lrg)); rts.#rid.push_back(re); - alloc::vec::Vec::from(u32::to_ne_bytes(i as u32)) + alloc::vec::Vec::from(u32::to_le_bytes(i as u32)) } } } diff --git a/src/hyperlight_component_util/src/host.rs b/src/hyperlight_component_util/src/host.rs index 69bd1d13a..9c3ba0cb4 100644 --- a/src/hyperlight_component_util/src/host.rs +++ b/src/hyperlight_component_util/src/host.rs @@ -101,7 +101,7 @@ fn emit_export_extern_decl<'a, 'b, 'c>( /// declaration that implements this exported instance in terms of /// hyperlight guest calls fn emit_export_instance<'a, 'b, 'c>(s: &'c mut State<'a, 'b>, wn: WitName, it: &'c Instance<'b>) { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); let exports = it @@ -311,7 +311,7 @@ fn emit_import_instance<'a, 'b, 'c>( wn: WitName, it: &'c Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); From b6c999cbac70cec1f065c600ae12f9a0e926de43 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:08:34 -0700 Subject: [PATCH 3/7] Document the component wire format Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- docs/README.md | 1 + docs/component-wire-format.md | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/component-wire-format.md diff --git a/docs/README.md b/docs/README.md index 9682b9269..918c5e244 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/component-wire-format.md b/docs/component-wire-format.md new file mode 100644 index 000000000..f979320af --- /dev/null +++ b/docs/component-wire-format.md @@ -0,0 +1,63 @@ +# Component wire format + +Bindings generated from WIT exchange values in this format. It is used by +`host_bindgen!`, `guest_bindgen!` and the hyperlight-wasm guest runtime. 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 | `#` | `wasi:http/types@0.2.0#[method]fields.get` | +| Imported or exported by the world itself | `` | `run` | + +`` and `` 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` | `u32` element count, then each element | +| `list` | `N` elements | +| `record`, `tuple` | Fields in declaration order | +| `option` | `u8` tag. `0` is `none`. `1` is `some`, followed by the payload | +| `result` | `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`, `borrow` | `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` to the guest adds the value to the table, and +passing `borrow` 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 host bindings are tested against +[`witguest`](../src/tests/rust_guests/witguest), which implements +[`guest.wit`](../src/tests/rust_guests/witguest/guest.wit). From 4706b4e3e6cecf31280a64a694fe52a085641d3d Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:08:39 -0700 Subject: [PATCH 4/7] Cover more WIT features in witguest Add world-level functions, an inline export interface, and nested and composite types to guest.wit. Other host bindings can then be tested against witguest. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/tests/wit_test.rs | 139 ++++++++++++++++++++- src/tests/rust_guests/witguest/guest.wit | 34 ++++- src/tests/rust_guests/witguest/src/main.rs | 61 +++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/hyperlight_host/tests/wit_test.rs b/src/hyperlight_host/tests/wit_test.rs index edf354e59..957c51ff5 100644 --- a/src/hyperlight_host/tests/wit_test.rs +++ b/src/hyperlight_host/tests/wit_test.rs @@ -16,7 +16,7 @@ mod bindings { hyperlight_component_macro::host_bindgen!(wit: "../tests/rust_guests/witguest/guest.wit"); } -use bindings::test::wit::roundtrip::{Testrecord, Testvariant}; +use bindings::test::wit::roundtrip::{Testcomposite, Testrecord, Testvariant}; use bindings::*; impl PartialEq for Testrecord { @@ -31,11 +31,27 @@ impl PartialEq for Testvariant { (Testvariant::VariantA, Testvariant::VariantA) => true, (Testvariant::VariantB(s1), Testvariant::VariantB(s2)) => s1 == s2, (Testvariant::VariantC(c1), Testvariant::VariantC(c2)) => c1 == c2, + (Testvariant::VariantD(r1), Testvariant::VariantD(r2)) => r1 == r2, _ => false, } } } +impl PartialEq for Testcomposite { + fn eq(&self, other: &Self) -> bool { + self.nested == other.nested + && self.records == other.records + && self.fixed == other.fixed + && self.res == other.res + && self.tup == other.tup + && self.choice == other.choice + && self.kind == other.kind + && self.mask == other.mask + && self.c == other.c + && self.b == other.b + } +} + impl Clone for Testrecord { fn clone(&self) -> Self { Self { @@ -51,6 +67,24 @@ impl Clone for Testvariant { Self::VariantA => Self::VariantA, Self::VariantB(s) => Self::VariantB(s.clone()), Self::VariantC(c) => Self::VariantC(*c), + Self::VariantD(r) => Self::VariantD(r.clone()), + } + } +} + +impl Clone for Testcomposite { + fn clone(&self) -> Self { + Self { + nested: self.nested, + records: self.records.clone(), + fixed: self.fixed, + res: self.res.clone(), + tup: self.tup.clone(), + choice: self.choice.clone(), + kind: self.kind, + mask: self.mask.clone(), + c: self.c, + b: self.b, } } } @@ -189,6 +223,37 @@ impl test::wit::Roundtrip for Host { } fn roundtrip_no_result(&mut self, _x: u32) {} + + fn roundtrip_flags_medium( + &mut self, + x: test::wit::roundtrip::Mediumflags, + ) -> test::wit::roundtrip::Mediumflags { + x + } + fn roundtrip_nested_option(&mut self, x: Option>) -> Option> { + x + } + fn roundtrip_nested_list(&mut self, x: Vec>) -> Vec> { + x + } + fn roundtrip_record_list(&mut self, x: Vec) -> Vec { + x + } + fn roundtrip_result_unit(&mut self, x: Result<(), String>) -> Result<(), String> { + x + } + fn roundtrip_result_empty(&mut self, x: Result<(), ()>) -> Result<(), ()> { + x + } + fn roundtrip_two_args(&mut self, first: u32, second: String) -> (u32, String) { + (first, second) + } + fn roundtrip_no_args(&mut self) -> u32 { + 42 + } + fn roundtrip_composite(&mut self, x: Testcomposite) -> Testcomposite { + x + } } struct TestResource { @@ -273,6 +338,9 @@ impl test::wit::TestImports for Host { fn host_resource(&mut self) -> &mut Self { self } + fn now(&mut self) -> u64 { + 7 + } } fn sb() -> TestSandbox { @@ -483,6 +551,7 @@ mod wit_test { Just(VariantA), any::().prop_map(VariantB), any::().prop_map(VariantC), + arb_testrecord().prop_map(VariantD), ] } @@ -491,6 +560,43 @@ mod wit_test { prop_oneof![Just(EnumA), Just(EnumB), Just(EnumC),] } + prop_compose! { + fn arb_mediumflags()( + m0: bool, m1: bool, m2: bool, m3: bool, m4: bool, + m5: bool, m6: bool, m7: bool, m8: bool, m9: bool, + ) -> roundtrip::Mediumflags { + roundtrip::Mediumflags { m0, m1, m2, m3, m4, m5, m6, m7, m8, m9 } + } + } + + fn arb_testrecords() -> impl Strategy> { + proptest::collection::vec(arb_testrecord(), 0..4) + } + + fn arb_nested_list() -> impl Strategy>> { + proptest::collection::vec(proptest::collection::vec(".*", 0..4), 0..4) + } + + prop_compose! { + fn arb_testcomposite()( + nested: Option>, + records in arb_testrecords(), + fixed: [u8; 3], + res in prop_oneof![ + arb_testrecord().prop_map(Ok), + any::().prop_map(Err), + ], + tup in (any::(), any::(), arb_testrecord()), + choice in arb_testvariant(), + kind in arb_testenum(), + mask in arb_mediumflags(), + c: char, + b: bool, + ) -> roundtrip::Testcomposite { + roundtrip::Testcomposite { nested, records, fixed, res, tup, choice, kind, mask, c, b } + } + } + /// 32 instead of default 256 to speed up ci const ROUNDTRIP_CASES: u32 = 32; @@ -544,6 +650,37 @@ mod wit_test { make_test! { roundtrip_fix_list_string, : [String; 4] } make_test! { roundtrip_fix_array_of_lists, : [Vec; 3] } make_test! { roundtrip_fix_array_of_string_lists, : [Vec; 2] } + make_test! { roundtrip_flags_medium, in arb_mediumflags() } + make_test! { roundtrip_nested_option, : Option> } + make_test! { roundtrip_nested_list, in arb_nested_list() } + make_test! { roundtrip_record_list, in arb_testrecords() } + make_test! { roundtrip_result_unit, : Result<(), String> } + make_test! { roundtrip_result_empty, : Result<(), ()> } + make_test! { roundtrip_composite, in arb_testcomposite() } + + #[test] + fn test_roundtrip_two_args() { + let result = sb().roundtrip().roundtrip_two_args(5, "five".to_string()); + assert_eq!(result.unwrap(), (5, "five".to_string())); + } + + #[test] + fn test_roundtrip_no_args() { + assert_eq!(sb().roundtrip().roundtrip_no_args().unwrap(), 42); + } + + #[test] + fn test_world_functions() { + use crate::bindings::Extra; + + let mut sb = sb(); + assert_eq!(sb.run("go".to_string()).unwrap(), Ok("go 7".to_string())); + assert_eq!( + sb.run(String::new()).unwrap(), + Err("empty input".to_string()) + ); + assert_eq!(sb.extra().ping(0xa5).unwrap(), 0xa5); + } #[test] fn test_roundtrip_no_result() { diff --git a/src/tests/rust_guests/witguest/guest.wit b/src/tests/rust_guests/witguest/guest.wit index 3000e0105..332960ed1 100644 --- a/src/tests/rust_guests/witguest/guest.wit +++ b/src/tests/rust_guests/witguest/guest.wit @@ -3,9 +3,14 @@ package test:wit; world test { import roundtrip; import host-resource; + import now: func() -> u64; export roundtrip; export test-host-resource; export failable; + export run: func(input: string) -> result; + export extra: interface { + ping: func(x: u8) -> u8; + } } interface roundtrip { @@ -62,13 +67,40 @@ interface roundtrip { } roundtrip-flags-large: func(x: largeflags) -> largeflags; variant testvariant { - variant-a, variant-b(string), variant-c(char), + variant-a, variant-b(string), variant-c(char), variant-d(testrecord), } roundtrip-variant: func(x: testvariant) -> testvariant; enum testenum { enum-a, enum-b, enum-c } roundtrip-enum: func(x: testenum) -> testenum; + + flags mediumflags { + m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, + } + roundtrip-flags-medium: func(x: mediumflags) -> mediumflags; + roundtrip-nested-option: func(x: option>) -> option>; + roundtrip-nested-list: func(x: list>) -> list>; + roundtrip-record-list: func(x: list) -> list; + roundtrip-result-unit: func(x: result<_, string>) -> result<_, string>; + roundtrip-result-empty: func(x: result) -> result; + roundtrip-two-args: func(first: u32, second: string) -> tuple; + roundtrip-no-args: func() -> u32; + + /// Composite field types, which are encoded by the record's codec. + record testcomposite { + nested: option>, + records: list, + fixed: list, + res: result, + tup: tuple, + choice: testvariant, + kind: testenum, + mask: mediumflags, + c: char, + b: bool, + } + roundtrip-composite: func(x: testcomposite) -> testcomposite; } interface host-resource { diff --git a/src/tests/rust_guests/witguest/src/main.rs b/src/tests/rust_guests/witguest/src/main.rs index a4329e177..b62cf8c80 100644 --- a/src/tests/rust_guests/witguest/src/main.rs +++ b/src/tests/rust_guests/witguest/src/main.rs @@ -153,6 +153,48 @@ impl test::wit::Roundtrip for Guest { fn roundtrip_no_result(&mut self, x: u32) { (Host {}).roundtrip_no_result(x) } + fn roundtrip_flags_medium( + &mut self, + x: test::wit::roundtrip::Mediumflags, + ) -> test::wit::roundtrip::Mediumflags { + (Host {}).roundtrip_flags_medium(x) + } + fn roundtrip_nested_option(&mut self, x: Option>) -> Option> { + (Host {}).roundtrip_nested_option(x) + } + fn roundtrip_nested_list(&mut self, x: Vec>) -> Vec> { + (Host {}).roundtrip_nested_list(x) + } + fn roundtrip_record_list( + &mut self, + x: Vec, + ) -> Vec { + (Host {}).roundtrip_record_list(x) + } + fn roundtrip_result_unit( + &mut self, + x: ::core::result::Result<(), String>, + ) -> ::core::result::Result<(), String> { + (Host {}).roundtrip_result_unit(x) + } + fn roundtrip_result_empty( + &mut self, + x: ::core::result::Result<(), ()>, + ) -> ::core::result::Result<(), ()> { + (Host {}).roundtrip_result_empty(x) + } + fn roundtrip_two_args(&mut self, first: u32, second: String) -> (u32, String) { + (Host {}).roundtrip_two_args(first, second) + } + fn roundtrip_no_args(&mut self) -> u32 { + (Host {}).roundtrip_no_args() + } + fn roundtrip_composite( + &mut self, + x: test::wit::roundtrip::Testcomposite, + ) -> test::wit::roundtrip::Testcomposite { + (Host {}).roundtrip_composite(x) + } } use alloc::string::ToString; @@ -213,6 +255,25 @@ impl test::wit::TestExports for Guest { fn failable(&mut self) -> &mut Self { self } + type Extra = Self; + fn extra(&mut self) -> &mut Self { + self + } + fn run(&mut self, input: String) -> ::core::result::Result { + if input.is_empty() { + return Err("empty input".to_string()); + } + Ok(alloc::format!( + "{input} {}", + >::now(&mut Host {}) + )) + } +} + +impl Extra for Guest { + fn ping(&mut self, x: u8) -> u8 { + x + } } static GUEST_STATE: Mutex = Mutex::new(Guest { From 73fb2d5f8e80acb24f2d83c35ae27fb43d054083 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:35:19 -0700 Subject: [PATCH 5/7] Let other bindings generators reuse emit::State State takes its output type as a parameter, which defaults to Mod. new, clone and resolve_bound_var work for any output, and so does emit_fn_hl_name. read_wit_type also works outside a cargo build. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_component_util/src/emit.rs | 60 ++++++++++++----------- src/hyperlight_component_util/src/hl.rs | 2 +- src/hyperlight_component_util/src/util.rs | 8 ++- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/hyperlight_component_util/src/emit.rs b/src/hyperlight_component_util/src/emit.rs index a49fe209c..cdb1539ad 100644 --- a/src/hyperlight_component_util/src/emit.rs +++ b/src/hyperlight_component_util/src/emit.rs @@ -314,11 +314,12 @@ pub enum ResolvedBoundVar<'a> { }, } -/// A whole grab-bag of useful state to have while emitting Rust +/// A whole grab-bag of useful state to have while emitting bindings. +/// `M` holds the output. [`Mod`] holds Rust output. #[derive(Debug)] -pub struct State<'a, 'b> { - /// A pointer to a [`Mod`] that everything we emit will end up in - pub root_mod: &'a mut Mod, +pub struct State<'a, 'b, M = Mod> { + /// A pointer to the output that everything we emit will end up in + pub root_mod: &'a mut M, /// A cursor to the current submodule (under [`State::root_mod`]), /// where decls that we are looking at right now should end up pub mod_cursor: Vec, @@ -444,9 +445,9 @@ pub fn run_state<'b, F: for<'a> FnMut(&mut State<'a, 'b>)>( root_mod.into_tokens() } -impl<'a, 'b> State<'a, 'b> { +impl<'a, 'b, M> State<'a, 'b, M> { pub fn new( - root_mod: &'a mut Mod, + root_mod: &'a mut M, bound_vars: &'a mut VecDeque>, vars_needs_vars: &'a mut VecDeque>, is_guest: bool, @@ -474,7 +475,7 @@ impl<'a, 'b> State<'a, 'b> { cur_instance: None, } } - pub fn clone<'c>(&'c mut self) -> State<'c, 'b> { + pub fn clone<'c>(&'c mut self) -> State<'c, 'b, M> { State { root_mod: self.root_mod, mod_cursor: self.mod_cursor.clone(), @@ -497,6 +498,30 @@ impl<'a, 'b> State<'a, 'b> { cur_instance: self.cur_instance.clone(), } } + /// Resolve a type variable as far as possible: either this ends + /// up with a definition, in which case, let's get that, or it + /// ends up with a resource type, in which case we return the + /// resource index + /// + /// Distinct from [`Ctx::resolve_tv`], which is mostly concerned + /// with free variables, because this is concerned entirely with + /// bound variables. + pub fn resolve_bound_var(&self, n: u32) -> ResolvedBoundVar<'b> { + let noff = self.var_offset as u32 + n; + match &self.bound_vars[noff as usize].bound { + TypeBound::Eq(Defined::Handleable(Handleable::Var(Tyvar::Bound(nn)))) => { + self.resolve_bound_var(n + 1 + nn) + } + TypeBound::Eq(t) => ResolvedBoundVar::Definite { + final_bound_var: n, + ty: t.clone(), + }, + TypeBound::SubResource => ResolvedBoundVar::Resource { rtidx: noff }, + } + } +} + +impl<'a, 'b> State<'a, 'b> { /// Obtain a reference to the [`Mod`] that we are currently /// generating code in, creating it if necessary pub fn cur_mod<'c>(&'c mut self) -> &'c mut Mod { @@ -785,27 +810,6 @@ impl<'a, 'b> State<'a, 'b> { } self.root_mod.adjust_vars(n); } - /// Resolve a type variable as far as possible: either this ends - /// up with a definition, in which case, let's get that, or it - /// ends up with a resource type, in which case we return the - /// resource index - /// - /// Distinct from [`Ctx::resolve_tv`], which is mostly concerned - /// with free variables, because this is concerned entirely with - /// bound variables. - pub fn resolve_bound_var(&self, n: u32) -> ResolvedBoundVar<'b> { - let noff = self.var_offset as u32 + n; - match &self.bound_vars[noff as usize].bound { - TypeBound::Eq(Defined::Handleable(Handleable::Var(Tyvar::Bound(nn)))) => { - self.resolve_bound_var(n + 1 + nn) - } - TypeBound::Eq(t) => ResolvedBoundVar::Definite { - final_bound_var: n, - ty: t.clone(), - }, - TypeBound::SubResource => ResolvedBoundVar::Resource { rtidx: noff }, - } - } /// Construct a namespace path referring to the resource trait for /// a resource with the given name diff --git a/src/hyperlight_component_util/src/hl.rs b/src/hyperlight_component_util/src/hl.rs index a91c57e7d..77719a7a1 100644 --- a/src/hyperlight_component_util/src/hl.rs +++ b/src/hyperlight_component_util/src/hl.rs @@ -13,7 +13,7 @@ use crate::rtypes; /// members, e.g. `wasi:http/types@0.2.0#[method]fields.get`, and the /// plain function name for functions imported or exported directly /// by the world. -pub fn emit_fn_hl_name(s: &State, kebab: &str) -> String { +pub fn emit_fn_hl_name(s: &State<'_, '_, M>, kebab: &str) -> String { match &s.cur_instance { Some(instance) => format!("{instance}#{kebab}"), None => kebab.to_string(), diff --git a/src/hyperlight_component_util/src/util.rs b/src/hyperlight_component_util/src/util.rs index c3caddc99..3e1a87c1e 100644 --- a/src/hyperlight_component_util/src/util.rs +++ b/src/hyperlight_component_util/src/util.rs @@ -89,9 +89,13 @@ impl WitSource { } } +/// Resolve `path` against the crate being built, or the working +/// directory outside a cargo build. fn manifest_path(path: &std::path::Path) -> std::path::PathBuf { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - std::path::Path::new(&manifest_dir).join(path) + match std::env::var_os("CARGO_MANIFEST_DIR") { + Some(dir) => std::path::Path::new(&dir).join(path), + None => path.to_path_buf(), + } } /// Read and parse a WIT type from a supported bindgen input. From 2a91ae66bc58ef8606bd1b49b850358ed2774cc8 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:08:50 -0700 Subject: [PATCH 6/7] Add hyperlight-component-csharp Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/workflows/CargoPublish.yml | 10 +- CHANGELOG.md | 1 + Cargo.lock | 8 + Cargo.toml | 2 + Justfile | 8 +- docs/component-wire-format.md | 6 +- docs/how-to-make-releases.md | 1 + src/hyperlight_component_csharp/Cargo.toml | 21 + src/hyperlight_component_csharp/README.md | 79 +++ .../src/Runtime.cs | 475 +++++++++++++ src/hyperlight_component_csharp/src/codec.rs | 582 +++++++++++++++ src/hyperlight_component_csharp/src/host.rs | 661 ++++++++++++++++++ src/hyperlight_component_csharp/src/lib.rs | 346 +++++++++ src/hyperlight_component_csharp/src/names.rs | 250 +++++++ src/hyperlight_component_csharp/src/tests.rs | 218 ++++++ src/hyperlight_component_csharp/src/types.rs | 293 ++++++++ 16 files changed, 2955 insertions(+), 6 deletions(-) create mode 100644 src/hyperlight_component_csharp/Cargo.toml create mode 100644 src/hyperlight_component_csharp/README.md create mode 100644 src/hyperlight_component_csharp/src/Runtime.cs create mode 100644 src/hyperlight_component_csharp/src/codec.rs create mode 100644 src/hyperlight_component_csharp/src/host.rs create mode 100644 src/hyperlight_component_csharp/src/lib.rs create mode 100644 src/hyperlight_component_csharp/src/names.rs create mode 100644 src/hyperlight_component_csharp/src/tests.rs create mode 100644 src/hyperlight_component_csharp/src/types.rs diff --git a/.github/workflows/CargoPublish.yml b/.github/workflows/CargoPublish.yml index 6d19ca70f..f0ee76ee2 100644 --- a/.github/workflows/CargoPublish.yml +++ b/.github/workflows/CargoPublish.yml @@ -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: | @@ -62,6 +62,7 @@ jobs: hyperlight-guest-bin \ hyperlight-component-util \ hyperlight-component-macro \ + hyperlight-component-csharp \ hyperlight-host \ hyperlight-guest-tracing >> "$GITHUB_ENV" @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a58d2e2..2fda03ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, as they already were for sandboxes created from snapshots by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1975 diff --git a/Cargo.lock b/Cargo.lock index b20394a51..bed87277b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1714,6 +1714,14 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.18.0" +dependencies = [ + "hyperlight-component-util", + "proc-macro2", +] + [[package]] name = "hyperlight-component-macro" version = "0.18.0" diff --git a/Cargo.toml b/Cargo.toml index 60ee2443d..9670e0f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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, @@ -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" diff --git a/Justfile b/Justfile index 2b1e79ad2..c68ba5522 100644 --- a/Justfile +++ b/Justfile @@ -149,7 +149,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 @@ -299,6 +299,7 @@ test-integration target=default-target features="": @# 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 @@ -426,6 +427,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 }} @@ -435,7 +437,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 ### @@ -564,7 +566,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 diff --git a/docs/component-wire-format.md b/docs/component-wire-format.md index f979320af..349e2ae1f 100644 --- a/docs/component-wire-format.md +++ b/docs/component-wire-format.md @@ -1,8 +1,10 @@ # Component wire format Bindings generated from WIT exchange values in this format. It is used by -`host_bindgen!`, `guest_bindgen!` and the hyperlight-wasm guest runtime. The -host and guest must be built from the same Hyperlight version. +`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 diff --git a/docs/how-to-make-releases.md b/docs/how-to-make-releases.md index 14df208a7..4d377c7e4 100644 --- a/docs/how-to-make-releases.md +++ b/docs/how-to-make-releases.md @@ -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` diff --git a/src/hyperlight_component_csharp/Cargo.toml b/src/hyperlight_component_csharp/Cargo.toml new file mode 100644 index 000000000..7a3802b88 --- /dev/null +++ b/src/hyperlight_component_csharp/Cargo.toml @@ -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" } diff --git a/src/hyperlight_component_csharp/README.md b/src/hyperlight_component_csharp/README.md new file mode 100644 index 000000000..9c035d574 --- /dev/null +++ b/src/hyperlight_component_csharp/README.md @@ -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`, `list` | `byte[]` | +| `list`, `list` | `IReadOnlyList` | +| `tuple<...>` | Value tuple | +| `option` | `T?`, or `Option` when `T` is an `option` | +| `result` | `Result`, 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`, `borrow` | `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. diff --git a/src/hyperlight_component_csharp/src/Runtime.cs b/src/hyperlight_component_csharp/src/Runtime.cs new file mode 100644 index 000000000..7ab7c96c7 --- /dev/null +++ b/src/hyperlight_component_csharp/src/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/src/hyperlight_component_csharp/src/codec.rs b/src/hyperlight_component_csharp/src/codec.rs new file mode 100644 index 000000000..667515fd4 --- /dev/null +++ b/src/hyperlight_component_csharp/src/codec.rs @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# types, declarations and encoding for WIT value types. + +use hyperlight_component_util::emit::ResolvedBoundVar; +use hyperlight_component_util::etypes::{Defined, FloatWidth, Handleable, IntWidth, Tyvar, Value}; + +use crate::names::{Src, case, member, pascal}; +use crate::{RT, S}; + +/// What a type variable reference stands for. +enum Target<'b> { + /// A declared C# type, by index into `Root::decls` + Named(usize), + /// A value type alias, whose definition is relative to the given + /// `var_offset` + Alias(Value<'b>, usize), +} + +fn target<'b>(s: &S<'_, 'b>, v: &Value<'b>) -> Option> { + let Value::Var(Some(Tyvar::Bound(n)), _) = v else { + return None; + }; + let ResolvedBoundVar::Definite { + final_bound_var, + ty: Defined::Value(def), + } = s.resolve_bound_var(*n) + else { + unreachable!("rejected while collecting"); + }; + let var = s.var_offset + final_bound_var as usize; + Some(match s.root_mod.decl_index(var) { + Some(i) => Target::Named(i), + None => Target::Alias(def, var + 1), + }) +} + +/// Run `f` on an alias definition with its own `var_offset`. +fn alias<'b, R>(s: &mut S<'_, 'b>, offset: usize, f: impl FnOnce(&mut S<'_, 'b>) -> R) -> R { + let mut s = s.clone(); + s.var_offset = offset; + f(&mut s) +} + +/// Index in `Root::resources` of the resource that a handle refers to. +fn resource(s: &S, h: &Handleable) -> usize { + let Handleable::Var(Tyvar::Bound(n)) = h else { + unreachable!("rejected while collecting"); + }; + let ResolvedBoundVar::Resource { rtidx } = s.resolve_bound_var(*n) else { + unreachable!("rejected while collecting"); + }; + s.root_mod.resource_index(rtidx as usize).unwrap() +} + +/// The C# type and `WireWriter`/`WireReader` method for a primitive type. +fn primitive(v: &Value) -> Option<(&'static str, &'static str)> { + Some(match v { + Value::Bool => ("bool", "Bool"), + Value::S(IntWidth::I8) => ("sbyte", "S8"), + Value::U(IntWidth::I8) => ("byte", "U8"), + Value::S(IntWidth::I16) => ("short", "S16"), + Value::U(IntWidth::I16) => ("ushort", "U16"), + Value::S(IntWidth::I32) => ("int", "S32"), + Value::U(IntWidth::I32) => ("uint", "U32"), + Value::S(IntWidth::I64) => ("long", "S64"), + Value::U(IntWidth::I64) => ("ulong", "U64"), + Value::F(FloatWidth::F32) => ("float", "F32"), + Value::F(FloatWidth::F64) => ("double", "F64"), + Value::Char => ("global::System.Text.Rune", "Char"), + Value::String => ("string", "String"), + _ => return None, + }) +} + +fn is_u8(v: &Value) -> bool { + matches!(v, Value::U(IntWidth::I8)) +} + +fn is_option<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> bool { + match (v, target(s, v)) { + (Value::Option(_), _) => true, + (_, Some(Target::Alias(def, off))) => alias(s, off, |s| is_option(s, &def)), + _ => false, + } +} + +/// Whether the C# type of `v` is a value type, which decides how +/// `option` maps to a nullable type. +fn is_value_type<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> bool { + match v { + Value::String | Value::List(_) | Value::FixList(..) | Value::Own(_) | Value::Borrow(_) => { + false + } + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => { + matches!(s.root_mod.decls[i].value, Value::Enum(_) | Value::Flags(_)) + } + Some(Target::Alias(def, off)) => alias(s, off, |s| is_value_type(s, &def)), + None => false, + }, + _ => true, + } +} + +/// The C# type of `v`. +pub(crate) fn ty<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> String { + if let Some((ty, _)) = primitive(v) { + return ty.to_string(); + } + match v { + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => s.root_mod.type_name(&s.root_mod.decls[i]), + Some(Target::Alias(def, off)) => alias(s, off, |s| ty(s, &def)), + None => unreachable!(), + }, + Value::Tuple(ts) if ts.len() == 1 => { + format!("global::System.ValueTuple<{}>", ty(s, &ts[0])) + } + Value::Tuple(ts) => { + let types = ts.iter().map(|t| ty(s, t)).collect::>(); + format!("({})", types.join(", ")) + } + Value::Option(inner) if is_option(s, inner) => format!("{RT}.Option<{}>", ty(s, inner)), + Value::Option(inner) => format!("{}?", ty(s, inner)), + Value::Result(ok, err) => format!( + "{RT}.Result<{}, {}>", + ty_or_unit(s, ok.as_ref().as_ref()), + ty_or_unit(s, err.as_ref().as_ref()) + ), + Value::List(inner) | Value::FixList(inner, _) if is_u8(inner) => "byte[]".to_string(), + Value::List(inner) | Value::FixList(inner, _) => format!( + "global::System.Collections.Generic.IReadOnlyList<{}>", + ty(s, inner) + ), + Value::Own(h) | Value::Borrow(h) => { + let r = &s.root_mod.resources[resource(s, h)]; + format!("global::{}.{}", r.namespace, r.name) + } + _ => unreachable!("rejected while collecting"), + } +} + +fn ty_or_unit<'b>(s: &mut S<'_, 'b>, v: Option<&Value<'b>>) -> String { + v.map_or_else(|| format!("{RT}.Unit"), |v| ty(s, v)) +} + +/// Emit statements that encode `value` of type `v` into the +/// `WireWriter` named `w`. +pub(crate) fn write<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: &Value<'b>, value: &str, w: &str) { + if let Some((_, method)) = primitive(v) { + src.line(format!("{w}.{method}({value});")); + return; + } + match v { + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => { + let root = &*s.root_mod; + src.line(format!( + "{}.Write_{}({w}, {value});", + root.codec, root.decls[i].mangled + )); + } + Some(Target::Alias(def, off)) => alias(s, off, |s| write(s, src, &def, value, w)), + None => unreachable!(), + }, + Value::Tuple(ts) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + for (i, ty) in ts.iter().enumerate() { + write(s, src, ty, &tuple_item(&t, i), w); + } + } + Value::Option(inner) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + let (has_value, payload) = if is_option(s, inner) || is_value_type(s, inner) { + (format!("{t}.HasValue"), format!("{t}.Value")) + } else { + (format!("{t} is not null"), t.clone()) + }; + src.line(format!("if ({has_value})")); + src.open(); + src.line(format!("{w}.U8(1);")); + write(s, src, inner, &payload, w); + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(0);")); + src.close(); + } + Value::Result(ok, err) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + src.line(format!("if ({t}.IsOk)")); + src.open(); + src.line(format!("{w}.U8(0);")); + if let Some(ok) = ok.as_ref() { + write(s, src, ok, &format!("{t}.Ok"), w); + } + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(1);")); + if let Some(err) = err.as_ref() { + write(s, src, err, &format!("{t}.Err"), w); + } + src.close(); + } + Value::List(inner) if is_u8(inner) => src.line(format!("{w}.Bytes({value});")), + Value::FixList(inner, n) if is_u8(inner) => { + src.line(format!("{w}.FixedBytes({value}, {n});")); + } + Value::List(inner) | Value::FixList(inner, _) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + match v { + Value::FixList(_, n) => { + src.line(format!("{RT}.WireWriter.CheckFixedLength({t}, {n});")); + } + _ => { + src.line(format!( + "global::System.ArgumentNullException.ThrowIfNull({t});" + )); + src.line(format!("{w}.Length({t}.Count);")); + } + } + let e = s.root_mod.temp(); + src.line(format!("foreach (var {e} in {t})")); + src.open(); + write(s, src, inner, &e, w); + src.close(); + } + Value::Own(h) => { + let table = s + .root_mod + .resource_table(resource(s, h), &format!("{w}.Resources")); + src.line(format!("{w}.U32({table}.Give({value}, {w}.Scope));")); + } + Value::Borrow(h) => { + let table = s + .root_mod + .resource_table(resource(s, h), &format!("{w}.Resources")); + src.line(format!("{w}.U32({table}.Lend({value}, {w}.Scope!));")); + } + _ => unreachable!("rejected while collecting"), + } +} + +/// Emit statements that decode a value of type `v` from the +/// `WireReader` named `r`, and return the local holding it. +pub(crate) fn read<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: &Value<'b>, r: &str) -> String { + if let Value::Var(..) = v + && let Some(Target::Alias(def, off)) = target(s, v) + { + return alias(s, off, |s| read(s, src, &def, r)); + } + let t = s.root_mod.temp(); + if let Some((_, method)) = primitive(v) { + src.line(format!("var {t} = {r}.{method}();")); + return t; + } + match v { + Value::Var(..) => { + let Some(Target::Named(i)) = target(s, v) else { + unreachable!() + }; + let root = &*s.root_mod; + src.line(format!( + "var {t} = {}.Read_{}(ref {r});", + root.codec, root.decls[i].mangled + )); + } + Value::Tuple(ts) => { + let items = ts.iter().map(|ty| read(s, src, ty, r)).collect::>(); + if items.len() == 1 { + src.line(format!("var {t} = new {}({});", ty(s, v), items[0])); + } else { + src.line(format!("var {t} = ({});", items.join(", "))); + } + } + Value::Option(inner) => { + let option_ty = ty(s, v); + let nested = is_option(s, inner); + src.line(format!("{option_ty} {t};")); + src.line(format!("if ({r}.U8() != 0)")); + src.open(); + let x = read(s, src, inner, r); + if nested { + src.line(format!("{t} = {option_ty}.Some({x});")); + } else { + src.line(format!("{t} = {x};")); + } + src.close(); + src.line("else"); + src.open(); + if nested { + src.line(format!("{t} = {option_ty}.None;")); + } else { + src.line(format!("{t} = null;")); + } + src.close(); + } + Value::Result(ok, err) => { + let result_ty = ty(s, v); + src.line(format!("{result_ty} {t};")); + src.line(format!("if ({r}.U8() == 0)")); + src.open(); + let x = read_or_unit(s, src, ok.as_ref().as_ref(), r); + src.line(format!("{t} = {result_ty}.FromOk({x});")); + src.close(); + src.line("else"); + src.open(); + let x = read_or_unit(s, src, err.as_ref().as_ref(), r); + src.line(format!("{t} = {result_ty}.FromErr({x});")); + src.close(); + } + Value::List(inner) if is_u8(inner) => src.line(format!("var {t} = {r}.Bytes();")), + Value::FixList(inner, n) if is_u8(inner) => { + src.line(format!("var {t} = {r}.FixedBytes({n});")); + } + Value::List(inner) | Value::FixList(inner, _) => { + let n = s.root_mod.temp(); + // A guest-supplied length is not trusted as a capacity. + let capacity = match v { + Value::FixList(_, len) => { + src.line(format!("var {n} = {len};")); + n.clone() + } + _ => { + src.line(format!("var {n} = {r}.Length();")); + String::new() + } + }; + let elem = ty(s, inner); + src.line(format!( + "var {t} = new global::System.Collections.Generic.List<{elem}>({capacity});" + )); + let i = s.root_mod.temp(); + src.line(format!("for (var {i} = 0; {i} < {n}; {i}++)")); + src.open(); + let x = read(s, src, inner, r); + src.line(format!("{t}.Add({x});")); + src.close(); + } + Value::Own(h) => { + let table = s + .root_mod + .resource_table(resource(s, h), &format!("{r}.Resources")); + src.line(format!("var {t} = {table}.Take({r}.U32());")); + } + Value::Borrow(h) => { + let table = s + .root_mod + .resource_table(resource(s, h), &format!("{r}.Resources")); + src.line(format!("var {t} = {table}.Borrow({r}.U32());")); + } + _ => unreachable!("rejected while collecting"), + } + t +} + +fn read_or_unit<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: Option<&Value<'b>>, r: &str) -> String { + match v { + Some(v) => read(s, src, v, r), + None => format!("default({RT}.Unit)"), + } +} + +/// A clone of `s` positioned at the definition of the declared type `i`. +fn at_decl<'c, 'b>(s: &'c mut S<'_, 'b>, i: usize) -> (S<'c, 'b>, Value<'b>, String) { + let d = &s.root_mod.decls[i]; + let (var, value, name) = (d.var, d.value.clone(), d.name.clone()); + let mut s = s.clone(); + s.var_offset = var + 1; + (s, value, name) +} + +/// Declaration of the record, variant, enum or flags type `i`. +pub(crate) fn emit_decl(s: &mut S<'_, '_>, i: usize) -> Src { + let (mut s, value, name) = at_decl(s, i); + let s = &mut s; + let mut src = Src::default(); + match &value { + Value::Record(fields) => { + src.line(format!("public sealed record {name}")); + src.open(); + for (i, f) in fields.iter().enumerate() { + if i > 0 { + src.line(""); + } + src.line(format!( + "public required {} {} {{ get; init; }}", + ty(s, &f.ty), + member(f.name.name, &name) + )); + } + src.close(); + } + Value::Variant(cases) => { + src.line(format!("public abstract record {name}")); + src.open(); + src.line(format!("private {name}() {{ }}")); + for c in cases { + let case_name = case(c.name.name, &name); + src.line(""); + match &c.ty { + Some(t) => src.line(format!( + "public sealed record {case_name}({} Value) : {name};", + ty(s, t) + )), + None => src.line(format!("public sealed record {case_name} : {name};")), + } + } + src.close(); + } + Value::Enum(names) => { + src.line(format!("public enum {name} : uint")); + src.open(); + for (i, c) in names.iter().enumerate() { + src.line(format!("{} = {i},", pascal(c.name))); + } + src.close(); + } + Value::Flags(flags) => { + src.line("[global::System.Flags]"); + src.line(format!("public enum {name} : uint")); + src.open(); + if flags.iter().all(|f| pascal(f.name) != "None") { + src.line("None = 0,"); + } + for (i, f) in flags.iter().enumerate() { + src.line(format!("{} = 1u << {i},", pascal(f.name))); + } + src.close(); + } + _ => unreachable!("only named types are declared"), + } + src +} + +/// Encode and decode methods for the declared type `i`. +pub(crate) fn emit_type_codec(s: &mut S<'_, '_>, src: &mut Src, i: usize) { + let full = s.root_mod.type_name(&s.root_mod.decls[i]); + let mangled = s.root_mod.decls[i].mangled.clone(); + let (mut s, value, name) = at_decl(s, i); + let s = &mut s; + + src.line(format!( + "public static void Write_{mangled}({RT}.WireWriter w, {full} v)" + )); + src.open(); + match &value { + Value::Record(fields) => { + src.line("global::System.ArgumentNullException.ThrowIfNull(v);"); + for f in fields { + write( + s, + src, + &f.ty, + &format!("v.{}", member(f.name.name, &name)), + "w", + ); + } + } + Value::Variant(cases) => { + src.line("switch (v)"); + src.open(); + for (i, c) in cases.iter().enumerate() { + let case_ty = format!("{full}.{}", case(c.name.name, &name)); + match &c.ty { + Some(payload) => { + let x = s.root_mod.temp(); + src.line(format!("case {case_ty} {x}:")); + src.indent_lines(|src| { + src.line(format!("w.U32({i});")); + write(s, src, payload, &format!("{x}.Value"), "w"); + src.line("break;"); + }); + } + None => { + src.line(format!("case {case_ty}:")); + src.indent_lines(|src| { + src.line(format!("w.U32({i});")); + src.line("break;"); + }); + } + } + } + src.line("default:"); + src.indent_lines(|src| { + src.line( + "throw new global::System.ArgumentException($\"unknown case {v}\", nameof(v));", + ); + }); + src.close(); + } + Value::Enum(names) => { + src.line(format!("if ((uint)v >= {})", names.len())); + src.open(); + src.line("throw new global::System.ArgumentOutOfRangeException(nameof(v));"); + src.close(); + src.line("w.U32((uint)v);"); + } + Value::Flags(flags) => { + let mask = flags_mask(flags.len()); + src.line(format!("if (((uint)v & ~{mask}u) != 0)")); + src.open(); + src.line("throw new global::System.ArgumentOutOfRangeException(nameof(v));"); + src.close(); + src.line(format!("w.Flags((uint)v, {});", flags.len().div_ceil(8))); + } + _ => unreachable!("only named types are declared"), + } + src.close(); + + src.line(""); + src.line(format!( + "public static {full} Read_{mangled}(ref {RT}.WireReader r)" + )); + src.open(); + match &value { + Value::Record(fields) => { + let fields = fields + .iter() + .map(|f| (member(f.name.name, &name), read(s, src, &f.ty, "r"))) + .collect::>(); + src.line(format!("return new {full}")); + src.open(); + for (field, value) in fields { + src.line(format!("{field} = {value},")); + } + src.close_with("};"); + } + Value::Variant(cases) => { + src.line(format!("switch (r.Tag32({}))", cases.len())); + src.open(); + for (i, c) in cases.iter().enumerate() { + let case_ty = format!("{full}.{}", case(c.name.name, &name)); + src.line(format!("case {i}:")); + src.open(); + match &c.ty { + Some(payload) => { + let x = read(s, src, payload, "r"); + src.line(format!("return new {case_ty}({x});")); + } + None => src.line(format!("return new {case_ty}();")), + } + src.close(); + } + src.line("default:"); + src.indent_lines(|src| { + src.line("throw new global::System.InvalidOperationException();"); + }); + src.close(); + } + Value::Enum(names) => { + src.line(format!("return ({full})r.Tag32({});", names.len())); + } + Value::Flags(flags) => { + let mask = flags_mask(flags.len()); + src.line(format!( + "return ({full})r.Flags({}, {mask}u);", + flags.len().div_ceil(8) + )); + } + _ => unreachable!("only named types are declared"), + } + src.close(); +} + +/// Accessor for element `i` of a C# value tuple. +fn tuple_item(tuple: &str, i: usize) -> String { + format!("{tuple}.Item{}", i + 1) +} + +/// Hex literal with the low `count` bits set. WIT flags have at most 32 +/// members. +fn flags_mask(count: usize) -> String { + format!("0x{:X}", u32::MAX >> (32 - count)) +} diff --git a/src/hyperlight_component_csharp/src/host.rs b/src/hyperlight_component_csharp/src/host.rs new file mode 100644 index 000000000..ac1b35a75 --- /dev/null +++ b/src/hyperlight_component_csharp/src/host.rs @@ -0,0 +1,661 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! The host side of a world: interfaces for imports, classes that call +//! exports, and registration of host functions. + +use hyperlight_component_util::emit::split_wit_name; +use hyperlight_component_util::etypes::{Component, ExternDecl, ExternDesc, Func, Param}; +use hyperlight_component_util::hl::emit_fn_hl_name; + +use crate::codec::{read, ty, write}; +use crate::names::{Src, camel, member, pascal, string_literal}; +use crate::{Error, RT, S, error}; + +/// Functions of one imported or exported instance, or of the world +/// itself. +struct Group<'c, 'b> { + /// Instance name, or `None` for functions of the world itself + instance: Option<&'b str>, + /// `var_offset` for the function types + offset: usize, + functions: Vec<(&'b str, &'c Func<'b>)>, +} + +/// A function's role, from its WIT name. +enum Kind<'b> { + Plain, + Constructor(&'b str), + /// Resource and method names + Method(&'b str, &'b str), + /// Resource and function names + Static(&'b str, &'b str), +} + +fn kind(name: &str) -> Kind<'_> { + fn split(rest: &str) -> (&str, &str) { + rest.split_once('.').unwrap_or((rest, "")) + } + if let Some(r) = name.strip_prefix("[constructor]") { + return Kind::Constructor(r); + } + if let Some(rest) = name.strip_prefix("[method]") { + let (r, f) = split(rest); + return Kind::Method(r, f); + } + if let Some(rest) = name.strip_prefix("[static]") { + let (r, f) = split(rest); + return Kind::Static(r, f); + } + Kind::Plain +} + +fn groups<'c, 'b>(decls: &'c [ExternDecl<'b>], offset: usize) -> Vec> { + let mut groups = Vec::new(); + let mut world = Vec::new(); + for ed in decls { + match &ed.desc { + ExternDesc::Instance(it) => { + let functions = it + .exports + .iter() + .filter_map(|e| match &e.desc { + ExternDesc::Func(f) => Some((e.kebab_name, f)), + _ => None, + }) + .collect::>(); + if !functions.is_empty() { + groups.push(Group { + instance: Some(ed.kebab_name), + offset, + functions, + }); + } + } + ExternDesc::Func(f) => world.push((ed.kebab_name, f)), + _ => {} + } + } + if !world.is_empty() { + groups.push(Group { + instance: None, + offset, + functions: world, + }); + } + groups +} + +/// Write the interfaces for the imports of the world `ct`, the classes +/// that call its exports, and the component class. +pub(crate) fn emit_toplevel<'b>(s: &mut S<'_, 'b>, ct: &Component<'b>) -> Result<(), Error> { + let imports = groups(&ct.imports, ct.instance.evars.len()); + let exports = groups(&ct.instance.unqualified.exports, 0); + check_class_names(s, &imports, &exports)?; + for i in 0..s.root_mod.resources.len() { + let src = emit_resource_interface(s, &imports, i); + let ns = s.root_mod.resources[i].namespace.clone(); + s.root_mod.add(ns, src); + } + for group in &imports { + if let Some(instance) = group.instance { + let ns = s.root_mod.qualify(s.root_mod.package_namespace(instance)); + let name = import_interface_name(s, group); + let src = emit_import_interface(s, &name, group); + s.root_mod.add(ns, src); + } + } + for group in &exports { + if let Some(instance) = group.instance { + let ns = s.root_mod.qualify(s.root_mod.package_namespace(instance)); + let name = export_class_name(group); + let src = emit_export_class(s, &name, group); + s.root_mod.add(ns, src); + } + } + let src = emit_component(s, &imports, &exports); + s.root_mod.add(s.root_mod.ns.clone(), src); + Ok(()) +} + +/// Reject a generated interface or class whose name a namespace of +/// interface types also takes, which C# cannot compile. +fn check_class_names(s: &S, imports: &[Group], exports: &[Group]) -> Result<(), Error> { + let root = &*s.root_mod; + let classes = imports + .iter() + .filter(|g| g.instance.is_some()) + .map(|g| qualified_group_type(s, g, import_interface_name(s, g))) + .chain( + exports + .iter() + .filter(|g| g.instance.is_some()) + .map(|g| qualified_group_type(s, g, export_class_name(g))), + ); + let namespaces = root + .decls + .iter() + .map(|d| &d.namespace) + .chain(root.resources.iter().map(|r| &r.namespace)) + .collect::>(); + for class in classes { + let class = class.trim_start_matches("global::"); + if namespaces + .iter() + .any(|ns| *ns == class || ns.starts_with(&format!("{class}."))) + { + return Err(error(format!( + "generated type {class} clashes with a namespace of the same name" + ))); + } + } + Ok(()) +} + +fn import_interface_name(s: &S, group: &Group) -> String { + match group.instance { + Some(instance) => format!("I{}", pascal(split_wit_name(instance).name)), + None => format!("I{}Imports", s.root_mod.world_type), + } +} + +fn export_class_name(group: &Group) -> String { + format!( + "{}Exports", + pascal(split_wit_name(group.instance.unwrap()).name) + ) +} + +fn qualified_group_type(s: &S, group: &Group, name: String) -> String { + let ns = match group.instance { + Some(instance) => s.root_mod.qualify(s.root_mod.package_namespace(instance)), + None => s.root_mod.ns.clone(), + }; + format!("global::{ns}.{name}") +} + +/// Member names for the instances in `groups`, or `None` for functions of +/// the world itself. A name is qualified with its package when two +/// instances share it or it is in `taken`. Package segments are kept apart +/// with `_` only when the qualified names still clash, as for `a:b-c` and +/// `a-b:c`. +fn group_member_names(s: &S, groups: &[Group], taken: &[String]) -> Vec> { + let bases = groups + .iter() + .map(|g| g.instance.map(|i| pascal(split_wit_name(i).name))) + .collect::>(); + let qualified = |instance: &str, base: &str, separator: &str| { + let package = s.root_mod.package_namespace(instance).unwrap_or_default(); + format!("{}{base}", package.replace('.', separator)) + }; + let names = groups + .iter() + .zip(&bases) + .map(|(g, base)| { + let (instance, base) = (g.instance?, base.as_deref()?); + let collides = bases.iter().flatten().filter(|b| *b == base).count() > 1 + || taken.iter().any(|t| t == base); + Some(if collides { + qualified(instance, base, "") + } else { + base.to_string() + }) + }) + .collect::>(); + groups + .iter() + .zip(&bases) + .zip(&names) + .map(|((g, base), name)| { + let name = name.as_ref()?; + if names.iter().flatten().filter(|n| *n == name).count() > 1 { + Some(qualified(g.instance?, base.as_deref()?, "_")) + } else { + Some(name.clone()) + } + }) + .collect() +} + +fn signature<'b>( + s: &mut S<'_, 'b>, + group: &Group<'_, 'b>, + params: &[Param<'b>], + f: &Func<'b>, + name: &str, +) -> String { + let mut s = s.clone(); + s.var_offset = group.offset; + let params = params + .iter() + .map(|p| format!("{} {}", ty(&mut s, &p.ty), camel(p.name.name))) + .collect::>() + .join(", "); + let result = f.result.as_ref().map_or("void".into(), |t| ty(&mut s, t)); + format!("{result} {name}({params})") +} + +fn wire_name(s: &mut S, group: &Group, function: &str) -> String { + let mut s = s.clone(); + s.cur_instance = group.instance.map(str::to_string); + emit_fn_hl_name(&s, function) +} + +/// A member name of the generated class `class`, escaped if it clashes +/// with the class name, an inherited member, or `RegisterImports` on the +/// component class. +fn class_member(name: &str, class: &str) -> String { + let name = member(name, class); + if name == "RegisterImports" { + format!("{name}_") + } else { + name + } +} + +fn emit_import_interface<'b>(s: &mut S<'_, 'b>, name: &str, group: &Group<'_, 'b>) -> Src { + let mut src = Src::default(); + src.line(format!("public interface {name}")); + src.open(); + let methods = interface_methods(group, name); + let mut first = true; + for ((_, f), method) in group.functions.iter().zip(&methods) { + let Some(method) = method else { + continue; + }; + if !first { + src.line(""); + } + first = false; + src.line(format!("{};", signature(s, group, &f.params, f, method))); + } + src.close(); + src +} + +/// Method names on the import interface `iface` for the functions of +/// `group`, or `None` for resource methods, which belong to the +/// resource's interface. Constructors are named `New{Resource}` and +/// static functions `{Resource}{Function}`. +fn interface_methods(group: &Group, iface: &str) -> Vec> { + let mut names = Vec::>::new(); + for (function, _) in &group.functions { + let mut name = match kind(function) { + Kind::Plain => member(function, iface), + Kind::Constructor(r) => member(&format!("new-{r}"), iface), + Kind::Static(r, f) => member(&format!("{r}-{f}"), iface), + Kind::Method(..) => { + names.push(None); + continue; + } + }; + while names.iter().flatten().any(|n| *n == name) { + name.push('_'); + } + names.push(Some(name)); + } + names +} + +/// The interface the host implements for resource `i`, with a method for +/// each of the resource's methods. +fn emit_resource_interface<'b>(s: &mut S<'_, 'b>, imports: &[Group<'_, 'b>], i: usize) -> Src { + let r = &s.root_mod.resources[i]; + let (name, wit_name, instance) = (r.name.clone(), r.wit_name.clone(), r.instance.clone()); + let mut src = Src::default(); + src.line(format!("public interface {name}")); + src.open(); + if let Some(group) = imports.iter().find(|g| g.instance == instance.as_deref()) { + let mut first = true; + for (function, f) in &group.functions { + if let Kind::Method(resource, method) = kind(function) + && resource == wit_name + { + if !first { + src.line(""); + } + first = false; + let params = f.params.get(1..).unwrap_or_default(); + let method = member(method, &name); + src.line(format!("{};", signature(s, group, params, f, &method))); + } + } + } + src.close(); + src +} + +/// Constructor parameters of the classes that call exports. +fn caller_params(s: &S) -> String { + if s.root_mod.resources.is_empty() { + format!("{RT}.IGuestCaller caller") + } else { + format!( + "{RT}.IGuestCaller caller, {} resources", + s.root_mod.resources_class + ) + } +} + +/// An object initializer that gives a reader or writer the resource +/// tables in `resources`, and the current call's scope when `scoped`. +fn context(s: &S, resources: &str, scoped: bool) -> String { + match (s.root_mod.resources.is_empty(), scoped) { + (true, _) => String::new(), + (false, false) => format!(" {{ Resources = {resources} }}"), + (false, true) => format!(" {{ Resources = {resources}, Scope = __scope }}"), + } +} + +/// Fields and constructor body of a class that calls exports. +fn emit_caller_fields(s: &S, src: &mut Src, class: &str, visibility: &str) { + src.line(format!("private readonly {RT}.IGuestCaller _caller;")); + if !s.root_mod.resources.is_empty() { + src.line(format!( + "private readonly {} _resources;", + s.root_mod.resources_class + )); + } + src.line(""); + src.line(format!("{visibility} {class}({})", caller_params(s))); + src.open(); + src.line("_caller = caller;"); + if !s.root_mod.resources.is_empty() { + src.line("_resources = resources;"); + } +} + +fn emit_export_class<'b>(s: &mut S<'_, 'b>, name: &str, group: &Group<'_, 'b>) -> Src { + let mut src = Src::default(); + src.line(format!("public sealed class {name}")); + src.open(); + emit_caller_fields(s, &mut src, name, "internal"); + src.close(); + emit_export_methods(s, &mut src, name, group); + src.close(); + src +} + +fn emit_export_methods<'b>(s: &mut S<'_, 'b>, src: &mut Src, class: &str, group: &Group<'_, 'b>) { + let resources = !s.root_mod.resources.is_empty(); + for (function, f) in &group.functions { + src.line(""); + src.line(format!( + "public {}", + signature(s, group, &f.params, f, &class_member(function, class)) + )); + let call = format!( + "_caller.Call({}, __args)", + string_literal(&wire_name(s, group, function)) + ); + let mut s = s.clone(); + s.var_offset = group.offset; + let s = &mut s; + src.open(); + if resources { + src.line(format!("var __scope = new {RT}.CallScope();")); + src.line("try"); + src.open(); + } + if f.params.is_empty() { + src.line("var __args = global::System.Array.Empty();"); + } else { + src.line(format!("var __args = new byte[{}][];", f.params.len())); + } + for (i, p) in f.params.iter().enumerate() { + src.open(); + let init = context(s, "_resources", true); + src.line(format!("var __w = new {RT}.WireWriter(){init};")); + write(s, src, &p.ty, &camel(p.name.name), "__w"); + src.line(format!("__args[{i}] = __w.ToArray();")); + src.close(); + } + if resources { + src.line("__scope.Commit();"); + } + match &f.result { + Some(t) => { + let init = context(s, "_resources", false); + src.line(format!("var __r = new {RT}.WireReader({call}){init};")); + let value = read(s, src, t, "__r"); + src.line(format!("return {value};")); + } + None => src.line(format!("{call};")), + } + if resources { + src.close(); + src.line("finally"); + src.open(); + src.line("__scope.End();"); + src.close(); + } + src.close(); + } +} + +fn emit_component<'b>( + s: &mut S<'_, 'b>, + imports: &[Group<'_, 'b>], + exports: &[Group<'_, 'b>], +) -> Src { + let world_type = s.root_mod.world_type.clone(); + let name = format!("{world_type}Component"); + let resources = !s.root_mod.resources.is_empty(); + let mut src = Src::default(); + + if let Some(group) = imports.iter().find(|g| g.instance.is_none()) { + let iface = import_interface_name(s, group); + src.append(emit_import_interface(s, &iface, group)); + src.line(""); + } + if resources { + src.append(emit_resources_class(s)); + src.line(""); + } + + src.line(format!("public sealed class {name}")); + src.open(); + emit_caller_fields(s, &mut src, &name, "public"); + // Functions of the world itself are methods on this class, so the + // properties for exported interfaces must not take their names. + let methods = exports + .iter() + .filter(|g| g.instance.is_none()) + .flat_map(|g| g.functions.iter().map(|(f, _)| class_member(f, &name))) + .collect::>(); + let export_names = group_member_names(s, exports, &methods) + .into_iter() + .map(|n| n.map(|n| class_member(&n, &name))) + .collect::>(); + let args = if resources { + "caller, resources" + } else { + "caller" + }; + for (group, prop) in exports.iter().zip(&export_names) { + if let Some(prop) = prop { + let class = qualified_group_type(s, group, export_class_name(group)); + src.line(format!("{prop} = new {class}({args});")); + } + } + src.close(); + for (group, prop) in exports.iter().zip(&export_names) { + if let Some(prop) = prop { + let class = qualified_group_type(s, group, export_class_name(group)); + src.line(""); + src.line(format!("public {class} {prop} {{ get; }}")); + } + } + if let Some(group) = exports.iter().find(|g| g.instance.is_none()) { + emit_export_methods(s, &mut src, &name, group); + } + + // The parameter for functions of the world itself is `world`. + let taken = imports + .iter() + .filter(|g| g.instance.is_none()) + .map(|_| "World".to_string()) + .collect::>(); + let import_names = group_member_names(s, imports, &taken); + let params = imports + .iter() + .zip(&import_names) + .map(|(group, base)| { + let ty = qualified_group_type(s, group, import_interface_name(s, group)); + let mut param = camel(base.as_deref().unwrap_or("world")); + if param == "registry" { + param.push('_'); + } + (ty, param, group) + }) + .collect::>(); + src.line(""); + let decls = std::iter::once(format!("{RT}.IHostFunctionRegistry registry")) + .chain(params.iter().map(|(ty, param, _)| format!("{ty} {param}"))) + .collect::>() + .join(", "); + if resources { + src.line("/// "); + src.line("/// Registers the world's imports as host functions. Pass the returned"); + src.line("/// resource tables to the constructor."); + src.line("/// "); + src.line(format!( + "public static {} RegisterImports({decls})", + s.root_mod.resources_class + )); + src.open(); + src.line(format!( + "var __resources = new {}();", + s.root_mod.resources_class + )); + } else { + src.line("/// Registers the world's imports as host functions."); + src.line(format!("public static void RegisterImports({decls})")); + src.open(); + } + for (_, param, group) in ¶ms { + let iface = import_interface_name(s, group); + let methods = interface_methods(group, &iface); + for ((function, f), method) in group.functions.iter().zip(&methods) { + let callee = match (kind(function), method) { + (Kind::Method(resource, m), _) => { + Callee::SelfMethod(member(m, &resource_interface(s, group, resource))) + } + (_, Some(method)) => Callee::On(param, method.clone()), + (_, None) => unreachable!("only resource methods have no interface method"), + }; + emit_registration(s, &mut src, callee, group, function, f); + } + } + if resources { + src.line("return __resources;"); + } + src.close(); + src.close(); + src +} + +/// The class holding a handle table for each resource. +fn emit_resources_class(s: &S) -> Src { + let root = &*s.root_mod; + let name = root.resources_class.rsplit('.').next().unwrap(); + let mut src = Src::default(); + src.line("/// Handles to host resources that the guest holds."); + src.line(format!("public sealed class {name}")); + src.open(); + src.line(format!("internal {name}()")); + src.open(); + src.close(); + for r in &root.resources { + src.line(""); + src.line(format!( + "internal {RT}.ResourceTable {} {{ get; }} = new();", + r.namespace, r.name, r.table + )); + } + src.close(); + src +} + +/// Name of the interface for the resource `resource` of `group`. +fn resource_interface(s: &S, group: &Group, resource: &str) -> String { + s.root_mod + .resources + .iter() + .find(|r| r.instance.as_deref() == group.instance && r.wit_name == resource) + .map(|r| r.name.clone()) + .unwrap() +} + +/// What a registered host function calls. +enum Callee<'a> { + /// A method of an import interface parameter + On(&'a str, String), + /// A method of the resource passed as the first argument + SelfMethod(String), +} + +fn emit_registration<'b>( + s: &mut S<'_, 'b>, + src: &mut Src, + callee: Callee, + group: &Group<'_, 'b>, + function: &str, + f: &Func<'b>, +) { + src.line(format!( + "registry.Register({}, {}, __args =>", + string_literal(&wire_name(s, group, function)), + f.params.len() + )); + let mut s = s.clone(); + s.var_offset = group.offset; + let s = &mut s; + src.open(); + let mut args = Vec::new(); + for (i, p) in f.params.iter().enumerate() { + let reader = format!("__r{i}"); + let init = context(s, "__resources", false); + src.line(format!( + "var {reader} = new {RT}.WireReader(__args[{i}]){init};" + )); + args.push(read(s, src, &p.ty, &reader)); + } + let call = match callee { + Callee::On(target, method) => format!("{target}.{method}({})", args.join(", ")), + Callee::SelfMethod(method) => { + format!("{}.{method}({})", args[0], args[1..].join(", ")) + } + }; + match &f.result { + Some(t) if !s.root_mod.resources.is_empty() => { + // Undo gifts in the result if encoding it fails. + src.line(format!("var __ret = {call};")); + src.line(format!("var __scope = new {RT}.CallScope();")); + src.line("try"); + src.open(); + let init = context(s, "__resources", true); + src.line(format!("var __w = new {RT}.WireWriter(){init};")); + write(s, src, t, "__ret", "__w"); + src.line("__scope.Commit();"); + src.line("return __w.ToArray();"); + src.close(); + src.line("finally"); + src.open(); + src.line("__scope.End();"); + src.close(); + } + Some(t) => { + src.line(format!("var __ret = {call};")); + let init = context(s, "__resources", false); + src.line(format!("var __w = new {RT}.WireWriter(){init};")); + write(s, src, t, "__ret", "__w"); + src.line("return __w.ToArray();"); + } + None => { + src.line(format!("{call};")); + src.line("return global::System.Array.Empty();"); + } + } + src.close_with("});"); +} diff --git a/src/hyperlight_component_csharp/src/lib.rs b/src/hyperlight_component_csharp/src/lib.rs new file mode 100644 index 000000000..247a5007a --- /dev/null +++ b/src/hyperlight_component_csharp/src/lib.rs @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# bindings for the host side of a WIT world. +//! +//! The generator walks the elaborated component type from +//! [`hyperlight_component_util`], which the Rust bindings also use. The +//! generated code encodes values in the format described in +//! `docs/component-wire-format.md` and calls guest functions through the +//! interfaces in [`RUNTIME_SOURCE`]. It covers every WIT value type and +//! resources the host defines. Resources the guest defines, async +//! functions, futures, streams, maps and flags with more than 32 members +//! are rejected. + +mod codec; +mod host; +mod names; +mod types; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::path::Path; + +use hyperlight_component_util::emit::{State, split_wit_name}; +use hyperlight_component_util::etypes::{Component, ExternDesc, Value}; +use hyperlight_component_util::util::{WitSource, read_wit_type}; +use names::{Src, pascal}; + +/// C# source for the `Hyperlight.Component` namespace that generated +/// bindings depend on. +pub const RUNTIME_SOURCE: &str = include_str!("Runtime.cs"); + +/// Namespace of [`RUNTIME_SOURCE`]. +const RT: &str = "global::Hyperlight.Component"; + +/// A WIT input the C# bindings cannot be generated for. +#[derive(Debug)] +pub struct Error(String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +fn error(msg: impl Into) -> Error { + Error(msg.into()) +} + +/// Generate C# bindings for a world in the `.wit` file or directory at +/// `path`. A relative `path` is resolved against `CARGO_MANIFEST_DIR` when +/// set, and the working directory otherwise. See [`generate`] for `world` +/// and `namespace`. +pub fn generate_from_path( + path: &Path, + world: Option<&str>, + namespace: &str, +) -> Result { + generate(WitSource::Wit(path.to_path_buf()), world, namespace) +} + +/// Generate C# bindings for the world named `world` in `source`, or its +/// last world. All generated types are placed under `namespace`. +/// +/// # Panics +/// +/// If `source` cannot be read or elaborated, as the bindgen macros do. +pub fn generate(source: WitSource, world: Option<&str>, namespace: &str) -> Result { + if !namespace.split('.').all(names::is_identifier) { + return Err(error(format!("invalid C# namespace '{namespace}'"))); + } + let world = world.map(str::to_string); + read_wit_type(source, world, |name, ct| emit(&name, ct, namespace)) +} + +type S<'a, 'b> = State<'a, 'b, Root<'b>>; + +fn emit(name: &str, ct: &Component<'_>, namespace: &str) -> Result { + let mut root = Root::new(namespace, name, ct); + // Export types refer to the existential variables, then the + // universal ones. Import types refer to the universal ones. + let mut bound_vars = ct + .instance + .evars + .iter() + .rev() + .chain(ct.uvars.iter().rev()) + .cloned() + .collect::>(); + let mut vars_needs_vars = VecDeque::new(); + let mut s = State::new( + &mut root, + &mut bound_vars, + &mut vars_needs_vars, + false, + false, + ); + types::emit_toplevel(&mut s, ct)?; + host::emit_toplevel(&mut s, ct)?; + Ok(root.render()) +} + +/// A record, variant, enum or flags type declared in C#. +struct Decl<'b> { + /// Absolute index of the type variable that defines it + var: usize, + /// Other type variables for the same WIT type, from a second copy of + /// its interface + aliases: Vec, + /// Definition, relative to the variables after `var` + value: Value<'b>, + /// Fully qualified C# namespace + namespace: String, + name: String, + /// Suffix of its codec methods + mangled: String, + /// Whether the world declares it outside any interface + world_level: bool, + /// Instance whose type it is, which identifies the WIT type with + /// its name + instance: Option, +} + +/// A resource type the host defines. The host implements its C# +/// interface, and the guest holds its values by handle. +struct ResourceDecl { + /// Absolute index of the type variable that introduces it + var: usize, + /// WIT name of the resource + wit_name: String, + /// Instance that defines it, or `None` for the world itself + instance: Option, + /// Fully qualified C# namespace + namespace: String, + /// C# interface name, e.g. `IFields` + name: String, + /// Name of its handle table in the resources class + table: String, +} + +/// The C# source for one world, grouped by namespace, and the names of +/// its declared types. +pub(crate) struct Root<'b> { + /// Root C# namespace + ns: String, + /// WIT name of the world + world: String, + /// C# name of the world, e.g. `Wire` for `test:wire/wire@0.1.0` + world_type: String, + /// Fully qualified name of the static class holding codec methods + codec: String, + /// Fully qualified name of the class holding resource tables + resources_class: String, + decls: Vec>, + resources: Vec, + /// `namespace:package` names the world uses at several versions + versioned: HashSet, + /// Package namespaces and version of the world, which also hold + /// its inline interfaces + world_package: (Vec, String), + /// Counter for unique local variable names + temps: usize, + /// Source of each namespace, in the order first written + namespaces: Vec<(String, Src)>, + /// Methods of the codec class + codec_methods: Src, +} + +impl<'b> Root<'b> { + fn new(ns: &str, world: &str, ct: &Component<'_>) -> Self { + let wn = split_wit_name(world); + let world_type = pascal(wn.name); + let world_package = ( + wn.namespaces + .iter() + .map(|n| n.to_string()) + .collect::>(), + wn._version.join("@"), + ); + let mut versions = HashMap::>::new(); + for ed in ct.imports.iter().chain(&ct.instance.unqualified.exports) { + if let ExternDesc::Instance(_) = ed.desc { + let (namespaces, version) = package(&world_package, ed.kebab_name); + if !namespaces.is_empty() { + versions + .entry(namespaces.join(":")) + .or_default() + .insert(version); + } + } + } + Root { + ns: ns.to_string(), + world: world.to_string(), + codec: format!("global::{ns}.{world_type}Codec"), + resources_class: format!("global::{ns}.{world_type}Resources"), + world_type, + decls: Vec::new(), + resources: Vec::new(), + versioned: versions + .into_iter() + .filter(|(_, v)| v.len() > 1) + .map(|(k, _)| k) + .collect(), + world_package, + temps: 0, + namespaces: Vec::new(), + codec_methods: Src::default(), + } + } + + /// Append a declaration to the namespace `ns`. + fn add(&mut self, ns: String, src: Src) { + match self.namespaces.iter_mut().find(|(n, _)| *n == ns) { + Some((_, existing)) => { + existing.line(""); + existing.append(src); + } + None => self.namespaces.push((ns, src)), + } + } + + fn add_codec_methods(&mut self, src: Src) { + if !self.codec_methods.is_empty() { + self.codec_methods.line(""); + } + self.codec_methods.append(src); + } + + fn render(mut self) -> String { + let name = self.codec.rsplit('.').next().unwrap().to_string(); + let mut codec = Src::default(); + codec.line(format!("internal static class {name}")); + codec.open(); + codec.append(std::mem::take(&mut self.codec_methods)); + codec.close(); + self.add(self.ns.clone(), codec); + + let mut out = Src::default(); + out.line("// "); + out.line(format!( + "// Generated by hyperlight-component-csharp from WIT world {}.", + self.world + )); + out.line("// "); + out.line(""); + out.line("#nullable enable"); + for (ns, src) in self.namespaces { + out.line(""); + out.line(format!("namespace {ns}")); + out.open(); + out.append(src); + out.close(); + } + out.into_string() + } + + /// Namespace of an instance's package relative to the root namespace, + /// e.g. `Wasi.Http`. The version is added, e.g. `Wasi.Http.V0_2_0`, + /// when the world uses the package at several versions. + fn package_namespace(&self, instance: &str) -> Option { + let (namespaces, version) = package(&self.world_package, instance); + if namespaces.is_empty() { + return None; + } + let mut ns = namespaces + .iter() + .map(|n| pascal(n)) + .collect::>() + .join("."); + if self.versioned.contains(&namespaces.join(":")) { + let version = version + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + ns.push_str(&format!(".V{version}")); + } + Some(ns) + } + + /// Namespace of an instance's types relative to the root namespace, + /// e.g. `Wasi.Http.Types`. + fn interface_namespace(&self, instance: &str) -> String { + let name = pascal(split_wit_name(instance).name); + match self.package_namespace(instance) { + Some(pkg) => format!("{pkg}.{name}"), + None => name, + } + } + + fn qualify(&self, relative: Option) -> String { + match relative { + Some(ns) => format!("{}.{ns}", self.ns), + None => self.ns.clone(), + } + } + + fn temp(&mut self) -> String { + self.temps += 1; + format!("__t{}", self.temps - 1) + } + + /// Index in `decls` of the type declared for the variable `var`. + fn decl_index(&self, var: usize) -> Option { + self.decls + .iter() + .position(|d| d.var == var || d.aliases.contains(&var)) + } + + /// Fully qualified C# name of a declared type. + fn type_name(&self, d: &Decl) -> String { + format!("global::{}.{}", d.namespace, d.name) + } + + /// Index in `resources` of the resource introduced by the variable `var`. + fn resource_index(&self, var: usize) -> Option { + self.resources.iter().position(|r| r.var == var) + } + + /// Expression for the table of resource `i`, given an expression for + /// the resources object. + fn resource_table(&self, i: usize, resources: &str) -> String { + format!( + "(({}){resources}!).{}", + self.resources_class, self.resources[i].table + ) + } +} + +/// Package namespaces and version of an instance. Inline interfaces +/// belong to the world's package. +fn package(world_package: &(Vec, String), instance: &str) -> (Vec, String) { + let wn = split_wit_name(instance); + if wn.namespaces.is_empty() { + return world_package.clone(); + } + ( + wn.namespaces.iter().map(|n| n.to_string()).collect(), + wn._version.join("@"), + ) +} + +#[cfg(test)] +mod tests; diff --git a/src/hyperlight_component_csharp/src/names.rs b/src/hyperlight_component_csharp/src/names.rs new file mode 100644 index 000000000..61808b4e8 --- /dev/null +++ b/src/hyperlight_component_csharp/src/names.rs @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# identifier and source formatting helpers. + +const KEYWORDS: &[&str] = &[ + "abstract", + "as", + "base", + "bool", + "break", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "do", + "double", + "else", + "enum", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "goto", + "if", + "implicit", + "in", + "int", + "interface", + "internal", + "is", + "lock", + "long", + "namespace", + "new", + "null", + "object", + "operator", + "out", + "override", + "params", + "private", + "protected", + "public", + "readonly", + "ref", + "return", + "sbyte", + "sealed", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unsafe", + "ushort", + "using", + "virtual", + "void", + "volatile", + "while", +]; + +/// Members every C# type inherits, plus members synthesized for +/// records. A generated member with one of these names would clash. +const RESERVED_MEMBERS: &[&str] = &[ + "Clone", + "Deconstruct", + "EqualityContract", + "Equals", + "Finalize", + "GetHashCode", + "GetType", + "MemberwiseClone", + "PrintMembers", + "ToString", +]; + +/// `foo-bar` to `FooBar`. +pub(super) fn pascal(kebab: &str) -> String { + kebab + .split('-') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } + }) + .collect() +} + +/// `foo-bar` to `fooBar`, escaped if it is a keyword. +pub(super) fn camel(kebab: &str) -> String { + let pascal = pascal(kebab); + let mut chars = pascal.chars(); + let name: String = match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + }; + if KEYWORDS.contains(&name.as_str()) { + format!("@{name}") + } else { + name + } +} + +/// A member name of type `enclosing` that does not clash with the +/// type name or an inherited member. +pub(super) fn member(kebab: &str, enclosing: &str) -> String { + let name = pascal(kebab); + if name == enclosing || RESERVED_MEMBERS.contains(&name.as_str()) { + format!("{name}_") + } else { + name + } +} + +/// The nested type name of a variant case, which must not clash with +/// the `Value` property of a case with a payload. +pub(super) fn case(kebab: &str, enclosing: &str) -> String { + let name = member(kebab, enclosing); + if name == "Value" { + format!("{name}_") + } else { + name + } +} + +/// Whether `s` is a C# identifier that needs no `@` escape. +pub(super) fn is_identifier(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') + && chars.all(|c| c.is_alphanumeric() || c == '_') + && !KEYWORDS.contains(&s) +} + +/// A C# string literal. +pub(super) fn string_literal(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Indented C# source text. +#[derive(Default)] +pub(super) struct Src { + out: String, + indent: usize, +} + +impl Src { + pub(super) fn line(&mut self, line: impl AsRef) { + let line = line.as_ref(); + if !line.is_empty() { + for _ in 0..self.indent { + self.out.push_str(" "); + } + self.out.push_str(line); + } + self.out.push('\n'); + } + + pub(super) fn open(&mut self) { + self.line("{"); + self.indent += 1; + } + + pub(super) fn close(&mut self) { + self.close_with("}"); + } + + pub(super) fn close_with(&mut self, line: &str) { + self.indent -= 1; + self.line(line); + } + + /// Run `f` one indentation level deeper. + pub(super) fn indent_lines(&mut self, f: impl FnOnce(&mut Self)) { + self.indent += 1; + f(self); + self.indent -= 1; + } + + pub(super) fn is_empty(&self) -> bool { + self.out.is_empty() + } + + /// Append `other`, indented to the current level. + pub(super) fn append(&mut self, other: Src) { + for line in other.out.lines() { + self.line(line); + } + } + + pub(super) fn into_string(self) -> String { + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers() { + assert_eq!(pascal("on-request"), "OnRequest"); + assert_eq!(pascal("flag0a"), "Flag0a"); + assert_eq!(pascal("http-URL"), "HttpURL"); + assert_eq!(camel("query-string"), "queryString"); + assert_eq!(camel("string"), "@string"); + assert_eq!(member("to-string", "Foo"), "ToString_"); + assert_eq!(member("foo", "Foo"), "Foo_"); + assert_eq!(case("value", "Foo"), "Value_"); + assert!(is_identifier("_a1")); + assert!(!is_identifier("1a")); + assert!(!is_identifier("class")); + assert_eq!(string_literal("a\"b\\c"), "\"a\\\"b\\\\c\""); + } +} diff --git a/src/hyperlight_component_csharp/src/tests.rs b/src/hyperlight_component_csharp/src/tests.rs new file mode 100644 index 000000000..20dd5a6f7 --- /dev/null +++ b/src/hyperlight_component_csharp/src/tests.rs @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use hyperlight_component_util::{emit, host, rtypes, util}; +use proc_macro2::{TokenStream, TokenTree}; + +use super::*; + +const GUEST_WIT: &str = "../tests/rust_guests/witguest/guest.wit"; + +fn generate_str(wit: &str) -> Result { + generate(WitSource::Inline(wit.to_string()), None, "Test") +} + +/// String literals in `tokens` that can be wire names. +fn string_literals(tokens: TokenStream, out: &mut BTreeSet) { + for token in tokens { + match token { + TokenTree::Group(g) => string_literals(g.stream(), out), + TokenTree::Literal(l) => { + let l = l.to_string(); + if let Some(s) = l.strip_prefix('"').and_then(|l| l.strip_suffix('"')) + && !s.is_empty() + && !s.contains(' ') + { + out.insert(s.to_string()); + } + } + _ => {} + } + } +} + +/// The first string literal after each occurrence of `call`. +fn call_names(source: &str, call: &str) -> Vec { + source + .match_indices(call) + .map(|(i, _)| { + let rest = &source[i + call.len()..]; + rest.split('"').nth(1).unwrap().to_string() + }) + .collect() +} + +#[test] +fn wire_names_match_rust_host_bindings() { + let rust = util::read_wit_type(util::WitSource::Wit(GUEST_WIT.into()), None, |kebab, ct| { + emit::run_state(false, false, |s| { + rtypes::emit_toplevel(s, &kebab, ct); + host::emit_toplevel(s, &kebab, ct); + }) + }); + let mut rust_names = BTreeSet::new(); + string_literals(rust, &mut rust_names); + + let cs = generate_from_path(&PathBuf::from(GUEST_WIT), None, "Test").unwrap(); + let cs_names = call_names(&cs, "_caller.Call(") + .into_iter() + .chain(call_names(&cs, "registry.Register(")) + .collect::>(); + + assert_eq!(rust_names, cs_names); + assert!(cs_names.contains("test:wit/roundtrip#roundtrip-composite")); + assert!(cs_names.contains("test:wit/host-resource#[constructor]testresource")); + assert!(cs_names.contains("test:wit/host-resource#[method]testresource.append-char")); + assert!(cs_names.contains("now")); + assert!(cs_names.contains("run")); +} + +#[test] +fn rejects_guest_resources() { + let resource = generate_str( + "package a:b; interface i { resource r; f: func(x: borrow); } world w { export i; }", + ); + let error = resource.unwrap_err().to_string(); + assert!(error.contains("resources the guest defines"), "{error}"); +} + +#[test] +fn names_host_resource_functions() { + let cs = generate_str( + "package a:b; \ + interface i { \ + resource r { constructor(); make: static func() -> r; get: func() -> u32; } \ + new-r: func() -> r; \ + } \ + world w { import i; export f: func(x: borrow); use i.{r}; }", + ) + .unwrap(); + for expected in [ + "public interface IR", + "uint Get();", + "IR NewR();", + "IR NewR_();", + "IR RMake();", + "public sealed class WResources", + "public static global::Test.WResources RegisterImports(", + "public WComponent(global::Hyperlight.Component.IGuestCaller caller, global::Test.WResources resources)", + "__scope.Commit();", + ] { + assert!(cs.contains(expected), "{expected}\n{cs}"); + } +} + +#[test] +fn rejects_distinct_interfaces_with_one_name() { + for wit in [ + "package a:b; interface i { record r { x: u32 } f: func() -> r; } \ + world w { import i; export i: interface { record r { x: string } f: func() -> r; } }", + "package a:b; world w { \ + import io: interface { record p { v: u32 } f: func(x: p); } \ + export io: interface { record p { v: string } g: func() -> p; } }", + ] { + let error = generate_str(wit).unwrap_err().to_string(); + assert!( + error.contains("clashes with another type"), + "{wit}: {error}" + ); + } +} + +#[test] +fn rejects_classes_named_like_namespaces() { + let error = generate_str( + "package a:b; interface foo { f: func(); } \ + interface foo-exports { record r { x: u8 } g: func() -> r; } \ + world w { export foo; export foo-exports; }", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("FooExports"), "{error}"); +} + +#[test] +fn escapes_record_member_clone() { + let cs = generate_str( + "package a:b; interface i { record r { clone: bool } f: func(x: r); } world w { export i; }", + ) + .unwrap(); + assert!(cs.contains("bool Clone_ {"), "{cs}"); +} + +#[test] +#[should_panic(expected = "async")] +fn rejects_streams() { + let _ = generate_str("package a:b; world w { export f: func(x: stream); }"); +} + +#[test] +#[should_panic(expected = "async")] +fn rejects_async_functions() { + let _ = generate_str("package a:b; world w { export f: async func(); }"); +} + +#[test] +fn rejects_invalid_namespace() { + let generate = |namespace| { + generate( + WitSource::Inline("package a:b; world w {}".to_string()), + None, + namespace, + ) + }; + for namespace in ["", "A..B", "A.B-C", "A B", "A.1B", "A.class"] { + assert!(generate(namespace).is_err(), "{namespace}"); + } + assert!(generate("A.B_c").is_ok()); +} + +#[test] +fn rejects_more_than_32_flags() { + let names = (0..33) + .map(|i| format!("g{i}")) + .collect::>() + .join(", "); + let wit = format!( + "package a:b; interface i {{ flags big {{ {names} }} f: func(x: big); }} world w {{ export i; }}" + ); + let error = generate_str(&wit).unwrap_err().to_string(); + assert!(error.contains("more than 32"), "{error}"); +} + +#[test] +fn rejects_empty_types() { + for (decl, ty) in [ + ("", "tuple<>"), + ("", "list"), + ("record e {}", "e"), + ("flags e {}", "e"), + ] { + let wit = format!( + "package a:b; interface i {{ {decl} f: func(x: {ty}); }} world w {{ export i; }}" + ); + let error = generate_str(&wit).unwrap_err().to_string(); + assert!(error.contains("must not be empty"), "{wit}: {error}"); + } +} + +#[test] +fn rejects_types_named_like_generated_classes() { + let error = generate_str( + "package a:b; world w { record w-component { x: u32 } export f: func(x: w-component); }", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("WComponent"), "{error}"); + + let error = generate_str( + "package test:wire; interface types { f: func(); } \ + world wire { record test { x: u32 } import types; export f: func(x: test); }", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("Test"), "{error}"); +} diff --git a/src/hyperlight_component_csharp/src/types.rs b/src/hyperlight_component_csharp/src/types.rs new file mode 100644 index 000000000..c8924745b --- /dev/null +++ b/src/hyperlight_component_csharp/src/types.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Declares a C# type with codec methods for each record, variant, enum +//! and flags type the world uses, and rejects what the bindings do not +//! support. + +use hyperlight_component_util::emit::ResolvedBoundVar; +use hyperlight_component_util::etypes::{ + Component, Defined, ExternDecl, ExternDesc, Func, Handleable, Tyvar, Value, +}; + +use crate::codec::{emit_decl, emit_type_codec}; +use crate::names::{Src, pascal}; +use crate::{Decl, Error, ResourceDecl, S, error}; + +/// Walk the imports, then the exports, of the world `ct`. +pub(crate) fn emit_toplevel<'b>(s: &mut S<'_, 'b>, ct: &Component<'b>) -> Result<(), Error> { + s.var_offset = ct.instance.evars.len(); + for ed in &ct.imports { + emit_extern_decl(s, ed, true)?; + } + s.var_offset = 0; + for ed in &ct.instance.unqualified.exports { + emit_extern_decl(s, ed, true)?; + } + + // World types share the root namespace with the generated classes + // and the first segment of each interface's namespace. + let root = &*s.root_mod; + let world = &root.world_type; + let mut generated = vec![ + format!("{world}Component"), + format!("I{world}Imports"), + format!("{world}Codec"), + format!("{world}Resources"), + ]; + for ed in ct.imports.iter().chain(&ct.instance.unqualified.exports) { + if let ExternDesc::Instance(_) = ed.desc { + let ns = root.interface_namespace(ed.kebab_name); + generated.push(ns.split('.').next().unwrap_or_default().to_string()); + } + } + if let Some(d) = root + .decls + .iter() + .find(|d| d.world_level && generated.contains(&d.name)) + { + return Err(error(format!( + "type '{}' clashes with the generated name {}", + s.bound_vars[d.var].origin.last_name().unwrap_or_default(), + d.name + ))); + } + if let Some(r) = root.resources.iter().find(|r| { + root.decls + .iter() + .any(|d| d.namespace == r.namespace && d.name == r.name) + || (r.instance.is_none() && generated.contains(&r.name)) + }) { + return Err(error(format!( + "resource '{}' clashes with the generated name {}", + r.wit_name, r.name + ))); + } + Ok(()) +} + +fn emit_extern_decl<'b>(s: &mut S<'_, 'b>, ed: &ExternDecl<'b>, top: bool) -> Result<(), Error> { + match &ed.desc { + ExternDesc::Type(Defined::Handleable(Handleable::Var(Tyvar::Bound(n)))) => visit_var(s, *n), + ExternDesc::Type(_) => Err(error(format!( + "type '{}': this kind of type is not supported", + ed.kebab_name + ))), + ExternDesc::Func(f) => visit_func(s, f), + ExternDesc::Instance(it) if top => { + for ed in &it.exports { + emit_extern_decl(s, ed, false)?; + } + Ok(()) + } + _ => Err(error(format!( + "'{}': nested instances, components and core modules are not supported", + ed.kebab_name + ))), + } +} + +fn visit_func<'b>(s: &mut S<'_, 'b>, f: &Func<'b>) -> Result<(), Error> { + for p in &f.params { + visit_value(s, &p.ty)?; + } + if let Some(ty) = &f.result { + visit_value(s, ty)?; + } + Ok(()) +} + +/// Check that `v` is supported and declare every type it uses that +/// needs a declaration. +fn visit_value<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> Result<(), Error> { + match v { + Value::Bool | Value::S(_) | Value::U(_) | Value::F(_) | Value::Char | Value::String => { + Ok(()) + } + // The component model forbids these, but the WIT reader does not + // validate its input. + Value::FixList(_, 0) => Err(error("fixed-length lists must not be empty")), + Value::Tuple(ts) if ts.is_empty() => Err(error("tuples must not be empty")), + Value::List(t) | Value::FixList(t, _) | Value::Option(t) => visit_value(s, t), + Value::Tuple(ts) => ts.iter().try_for_each(|t| visit_value(s, t)), + Value::Result(ok, err) => { + for t in [ok.as_ref(), err.as_ref()].into_iter().flatten() { + visit_value(s, t)?; + } + Ok(()) + } + Value::Var(Some(Tyvar::Bound(n)), _) => visit_var(s, *n), + Value::Own(Handleable::Var(Tyvar::Bound(n))) + | Value::Borrow(Handleable::Var(Tyvar::Bound(n))) => match s.resolve_bound_var(*n) { + ResolvedBoundVar::Resource { rtidx } => declare_resource(s, rtidx as usize), + ResolvedBoundVar::Definite { .. } => Err(error("handle to a non-resource type")), + }, + Value::Own(_) | Value::Borrow(_) => Err(error("unresolved resource type")), + Value::Record(_) | Value::Variant(_) | Value::Enum(_) | Value::Flags(_) => Err(error( + "anonymous records, variants, enums and flags are not supported", + )), + Value::Var(..) => Err(error("unresolved type variable")), + } +} + +/// Visit the definition of the type variable `n`, and declare it if it is +/// a record, variant, enum or flags type. Its name is assigned before +/// its fields are visited, and its source is written after. +fn visit_var<'b>(s: &mut S<'_, 'b>, n: u32) -> Result<(), Error> { + let (var, v) = match s.resolve_bound_var(n) { + ResolvedBoundVar::Definite { + final_bound_var, + ty: Defined::Value(v), + } => (s.var_offset + final_bound_var as usize, v), + ResolvedBoundVar::Resource { rtidx } => { + return declare_resource(s, rtidx as usize); + } + ResolvedBoundVar::Definite { .. } => { + return Err(error("non-value type definitions are not supported")); + } + }; + if s.root_mod.decl_index(var).is_some() { + return Ok(()); + } + let mut s = s.clone(); + s.var_offset = var + 1; + let fields = match &v { + Value::Record(fields) => fields.iter().map(|f| &f.ty).collect(), + Value::Variant(cases) => cases.iter().filter_map(|c| c.ty.as_ref()).collect(), + Value::Enum(_) | Value::Flags(_) => Vec::new(), + _ => return visit_value(&mut s, &v), + }; + let Some(i) = declare(&mut s, var, &v)? else { + return Ok(()); + }; + for ty in fields { + visit_value(&mut s, ty)?; + } + let ns = s.root_mod.decls[i].namespace.clone(); + let decl = emit_decl(&mut s, i); + s.root_mod.add(ns, decl); + let mut methods = Src::default(); + emit_type_codec(&mut s, &mut methods, i); + s.root_mod.add_codec_methods(methods); + Ok(()) +} + +/// Name the C# type for the type variable `var` after the WIT type and +/// interface it comes from, and return its index in `Root::decls`. Return +/// `None` when the type is already declared. +fn declare<'b>(s: &mut S<'_, 'b>, var: usize, v: &Value<'b>) -> Result, Error> { + let origin = &s.bound_vars[var].origin; + let path = origin.path.as_deref().unwrap_or_default(); + let (wit_name, instance) = match path { + [ty] => (ty.name(), None), + [ty, instance] => (ty.name(), Some(instance.name())), + _ => return Err(error("types of nested instances are not supported")), + }; + // The component model forbids these, but the WIT reader does not + // validate its input. + let empty = match v { + Value::Record(fields) => fields.is_empty(), + Value::Flags(flags) => flags.is_empty(), + _ => false, + }; + if empty { + return Err(error(format!("type '{wit_name}' must not be empty"))); + } + if let Value::Flags(flags) = v + && flags.len() > 32 + { + return Err(error(format!( + "type '{wit_name}': flags with more than 32 members are not supported" + ))); + } + let root = &mut *s.root_mod; + let name = pascal(wit_name); + let (namespace, base) = match instance { + Some(instance) => { + let relative = root.interface_namespace(instance); + let base = format!("{}_{name}", relative.replace('.', "_")); + (root.qualify(Some(relative)), base) + } + None => (root.ns.clone(), name.clone()), + }; + // A world that imports and exports one interface has a type variable + // for each copy of its types. Both copies use one C# type. Inline + // interfaces are distinct even when their names match. + if let Some(d) = root + .decls + .iter_mut() + .find(|d| d.namespace == namespace && d.name == name) + { + if d.instance.as_deref() != instance || !instance.is_some_and(|i| i.contains(':')) { + return Err(error(format!( + "type '{wit_name}' clashes with another type named {}.{name}", + d.namespace + ))); + } + d.aliases.push(var); + return Ok(None); + } + let mut mangled = base.clone(); + let mut n = 1; + while root.decls.iter().any(|d| d.mangled == mangled) { + n += 1; + mangled = format!("{base}{n}"); + } + root.decls.push(Decl { + var, + aliases: Vec::new(), + value: v.clone(), + namespace, + name, + mangled, + world_level: instance.is_none(), + instance: instance.map(str::to_string), + }); + Ok(Some(root.decls.len() - 1)) +} + +/// Declare the resource introduced by the variable `var`, which the host +/// must define. The Rust host bindings do not support resources the guest +/// defines either. +fn declare_resource(s: &mut S<'_, '_>, var: usize) -> Result<(), Error> { + if s.root_mod.resource_index(var).is_some() { + return Ok(()); + } + let origin = &s.bound_vars[var].origin; + let path = origin.path.as_deref().unwrap_or_default(); + let (wit_name, instance) = match path { + [ty] => (ty.name(), None), + [ty, instance] => (ty.name(), Some(instance.name())), + _ => return Err(error("resources of nested instances are not supported")), + }; + if !origin.is_imported() { + return Err(error(format!( + "resource '{wit_name}': resources the guest defines are not supported" + ))); + } + let root = &mut *s.root_mod; + let name = format!("I{}", pascal(wit_name)); + let (namespace, base) = match instance { + Some(instance) => { + let relative = root.interface_namespace(instance); + let base = format!("{}_{}", relative.replace('.', "_"), pascal(wit_name)); + (root.qualify(Some(relative)), base) + } + None => (root.ns.clone(), pascal(wit_name)), + }; + let mut table = base.clone(); + let mut n = 1; + while root.resources.iter().any(|r| r.table == table) { + n += 1; + table = format!("{base}{n}"); + } + root.resources.push(ResourceDecl { + var, + wit_name: wit_name.to_string(), + instance: instance.map(str::to_string), + namespace, + name, + table, + }); + Ok(()) +} From 616d7889bc275bf6ae7c3d9be068cc0bd62873d0 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:09:02 -0700 Subject: [PATCH 7/7] Test generated C# bindings against witguest A test-only native library runs witguest in a sandbox, and the C# tests call it through the generated bindings. They run in the X64 build and test jobs, which have the guests and the .NET SDK. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/workflows/dep_build_test.yml | 5 + Cargo.lock | 7 + Cargo.toml | 1 + Justfile | 8 + docs/component-wire-format.md | 4 +- .../examples/csharp_bindgen.rs | 37 ++ src/tests/csharp_host/ComponentTests.csproj | 53 +++ src/tests/csharp_host/EdgeTests.cs | 141 ++++++++ src/tests/csharp_host/Fakes.cs | 44 +++ src/tests/csharp_host/Program.cs | 44 +++ src/tests/csharp_host/RuntimeTests.cs | 26 ++ src/tests/csharp_host/Sandbox.cs | 175 ++++++++++ src/tests/csharp_host/WitTests.cs | 294 ++++++++++++++++ src/tests/csharp_host/edge.wit | 79 +++++ src/tests/csharp_host/native/Cargo.toml | 14 + src/tests/csharp_host/native/src/lib.rs | 326 ++++++++++++++++++ 16 files changed, 1257 insertions(+), 1 deletion(-) create mode 100644 src/hyperlight_component_csharp/examples/csharp_bindgen.rs create mode 100644 src/tests/csharp_host/ComponentTests.csproj create mode 100644 src/tests/csharp_host/EdgeTests.cs create mode 100644 src/tests/csharp_host/Fakes.cs create mode 100644 src/tests/csharp_host/Program.cs create mode 100644 src/tests/csharp_host/RuntimeTests.cs create mode 100644 src/tests/csharp_host/Sandbox.cs create mode 100644 src/tests/csharp_host/WitTests.cs create mode 100644 src/tests/csharp_host/edge.wit create mode 100644 src/tests/csharp_host/native/Cargo.toml create mode 100644 src/tests/csharp_host/native/src/lib.rs diff --git a/.github/workflows/dep_build_test.yml b/.github/workflows/dep_build_test.yml index 539338765..a9a1730ad 100644 --- a/.github/workflows/dep_build_test.yml +++ b/.github/workflows/dep_build_test.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index bed87277b..243bdae00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1751,6 +1751,13 @@ dependencies = [ "wit-parser", ] +[[package]] +name = "hyperlight-csharp-test-native" +version = "0.0.0" +dependencies = [ + "hyperlight-host", +] + [[package]] name = "hyperlight-fuzz" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9670e0f5d..487fb2853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "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, diff --git a/Justfile b/Justfile index c68ba5522..a05dd8f3d 100644 --- a/Justfile +++ b/Justfile @@ -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 @@ -349,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. @@ -428,6 +435,7 @@ clippy-exhaustive target=default-target: ./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 }} diff --git a/docs/component-wire-format.md b/docs/component-wire-format.md index 349e2ae1f..3c4a7f330 100644 --- a/docs/component-wire-format.md +++ b/docs/component-wire-format.md @@ -60,6 +60,8 @@ guest are not supported. ## Tests -The Rust host bindings are tested against +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. diff --git a/src/hyperlight_component_csharp/examples/csharp_bindgen.rs b/src/hyperlight_component_csharp/examples/csharp_bindgen.rs new file mode 100644 index 000000000..302f329a9 --- /dev/null +++ b/src/hyperlight_component_csharp/examples/csharp_bindgen.rs @@ -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 ` + +use std::process::ExitCode; + +use hyperlight_component_csharp::generate_from_path; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let [wit, namespace, out] = args.as_slice() else { + eprintln!("usage: csharp_bindgen "); + 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 + } + } +} diff --git a/src/tests/csharp_host/ComponentTests.csproj b/src/tests/csharp_host/ComponentTests.csproj new file mode 100644 index 000000000..76470e5d9 --- /dev/null +++ b/src/tests/csharp_host/ComponentTests.csproj @@ -0,0 +1,53 @@ + + + + Exe + net8.0 + Major + enable + enable + true + true + false + $([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '../../..')) + + debug + $(HyperlightProfile) + dev + hyperlight_csharp_test_native.dll + libhyperlight_csharp_test_native.so + + + + + + + + + + + + + + + + + + + $(MSBuildProjectDirectory)/$(IntermediateOutputPath)generated/ + + + + + + + + + + + + + + + diff --git a/src/tests/csharp_host/EdgeTests.cs b/src/tests/csharp_host/EdgeTests.cs new file mode 100644 index 000000000..dae78837a --- /dev/null +++ b/src/tests/csharp_host/EdgeTests.cs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Checks the naming and type cases in edge.wit. + +using System.Runtime.CompilerServices; +using Hyperlight.Tests.Edge; +using Hyperlight.Tests.Edge.Test.Edge; +using Hyperlight.Tests.Edge.Test.Edge.Shared; +using Hyperlight.Tests.Edge.Test.Edge.Types; +using Dep1 = Hyperlight.Tests.Edge.Test.Dep.V1_0_0; +using Dep2 = Hyperlight.Tests.Edge.Test.Dep.V2_0_0; + +static class EdgeTests +{ + public static void Run() + { + var guest = new Guest(); + var component = new EdgeComponent(guest); + + void Sent(string function, byte[] args, string what) => + Check.True(guest.Function == function && guest.Args.Length == 1 && guest.Args[0].SequenceEqual(args), what); + + // Fields named like their record or like object members get a suffix. + var r = new R { R_ = 1, ToString_ = 2 }; + Check.True(component.Ops.Keyword(@class: r) == r, "keyword round trip"); + Sent("test:edge/ops#keyword", [1, 2], "keyword encoding"); + Check.True(r.ToString().Contains("ToString_ = 2"), "record ToString"); + + foreach (var type in new[] { typeof(R), typeof(P), typeof(Lists) }) + { + Check.True(type.GetProperties().All(p => p.IsDefined(typeof(RequiredMemberAttribute), false)), $"{type.Name} required"); + } + + // Aliases resolve to the type they name. + IReadOnlyList

points = [new P { X = 1 }, new P { X = 2 }]; + Check.True(component.Ops.Alias(points).SequenceEqual(points), "alias round trip"); + Sent("test:edge/ops#alias", [2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "alias encoding"); + + // Lists inside records round trip. + var lists = new Lists { Id = 1, Names = ["a", "b"], Pair = (2, [3, 4]) }; + var back = component.Ops.NestedLists(lists); + Check.True( + back.Id == 1 && back.Names.SequenceEqual(lists.Names) && back.Pair.Item1 == 2 && back.Pair.Item2.SequenceEqual(lists.Pair.Item2), + "lists round trip"); + + // A case named like the Value property or a keyword gets an escaped name. + static string Describe(V v) => v switch + { + V.None => "none", + V.Class c => $"class {c.Value}", + V.Items i => $"items {i.Value.Count}", + V.Value_ x => $"value {x.Value}", + _ => "unknown", + }; + V[] cases = [new V.None(), new V.Class("c"), new V.Value_(9), new V.Items([1, 2])]; + string[] expected = ["none", "class c", "value 9", "items 2"]; + for (var i = 0; i < cases.Length; i++) + { + Check.True(Describe(component.Ops.Cases(cases[i])) == expected[i], $"{expected[i]} round trip"); + } + Sent("test:edge/ops#cases", [2, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "items encoding"); + + Check.True(component.Ops.Single(new ValueTuple(0x1234)).Item1 == 0x1234, "single tuple round trip"); + Sent("test:edge/ops#single", [0x34, 0x12], "single tuple encoding"); + + component.Other.G(); + Check.True(guest.Function == "other#g" && guest.Args.Length == 0, "inline interface call"); + + // Exports named like members of the component class get escaped names. + component.RegisterImports_.R(); + Check.True(guest.Function == "register-imports#r", "export named register-imports"); + component.EdgeComponent_.C(); + Check.True(guest.Function == "edge-component#c", "export named like the component class"); + + // The world-level export `shared` is a method, so the property for + // the interface `shared` is qualified with its package. + var s = new S { X = 4 }; + Check.True(component.TestEdgeShared.Echo(s) == s, "exported shared round trip"); + Sent("test:edge/shared#echo", [4, 0, 0, 0], "shared encoding"); + component.Shared(); + Check.True(guest.Function == "shared", "world function beside an interface property"); + guest.Reply = [7, 0, 0, 0]; + Check.True(component.TestEdgeShared.ToString_() == 7, "exported to-string"); + component.World.W(); + Check.True(guest.Function == "test:edge/world#w", "interface named world"); + + // Named arguments check the generated parameter names. Each version of + // an imported package, and each package of a name that loses its dash, + // has its own parameter. + var registry = new Registry(); + var shared = new SharedHost(); + EdgeComponent.RegisterImports( + registry, + testDepV1_0_0T: new DepV1(), + testDepV2_0_0T: new DepV2(), + shared: shared, + a_BCTypes: new BcTypes(), + aB_CTypes: new AbTypes()); + Check.True(registry.Call("test:dep/t@1.0.0#f", [5, 0, 0, 0]).SequenceEqual(new byte[] { 105, 0, 0, 0 }), "dep 1.0.0"); + Check.True(registry.Call("test:dep/t@2.0.0#f", [5, 0, 0, 0]).SequenceEqual(new byte[] { 205, 0, 0, 0 }), "dep 2.0.0"); + Check.True(registry.Call("test:edge/shared#echo", [6, 0, 0, 0]).SequenceEqual(new byte[] { 6, 0, 0, 0 }), "imported shared echo"); + registry.Call("test:edge/shared#finalize"); + Check.True(shared.Finalized, "imported finalize"); + Check.True(registry.Call("test:edge/shared#to-string").SequenceEqual(new byte[] { 3, 0, 0, 0 }), "imported to-string"); + Check.True(registry.Call("a:b-c/types#f").SequenceEqual(new byte[] { 1 }), "a:b-c types"); + Check.True(registry.Call("a-b:c/types#g").SequenceEqual(new byte[] { 2 }), "a-b:c types"); + } + + // Imported functions named like inherited members get a suffix. + sealed class SharedHost : IShared + { + public bool Finalized; + + public S Echo(S x) => x; + + public void Finalize_() => Finalized = true; + + public uint ToString_() => 3; + } + + sealed class BcTypes : Hyperlight.Tests.Edge.A.BC.ITypes + { + public byte F() => 1; + } + + sealed class AbTypes : Hyperlight.Tests.Edge.AB.C.ITypes + { + public byte G() => 2; + } + + sealed class DepV1 : Dep1.IT + { + public uint F(Dep1.T.R x) => x.X + 100; + } + + sealed class DepV2 : Dep2.IT + { + public uint F(Dep2.T.R x) => x.X + 200; + } +} diff --git a/src/tests/csharp_host/Fakes.cs b/src/tests/csharp_host/Fakes.cs new file mode 100644 index 000000000..c0e985bdb --- /dev/null +++ b/src/tests/csharp_host/Fakes.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +using Hyperlight.Component; + +///

+/// Records each call and returns the arguments concatenated, which echoes +/// a single argument. replaces the next result. +/// +sealed class Guest : IGuestCaller +{ + public string Function = ""; + + public byte[][] Args = []; + + public byte[]? Reply; + + public byte[] Call(string function, byte[][] args) + { + Function = function; + Args = args; + var result = Reply ?? args.SelectMany(a => a).ToArray(); + Reply = null; + return result; + } +} + +sealed class Registry : IHostFunctionRegistry +{ + private readonly Dictionary Function)> _functions = []; + + public void Register(string function, int parameterCount, Func implementation) => + _functions.Add(function, (parameterCount, implementation)); + + public byte[] Call(string function, params byte[][] args) + { + var (count, implementation) = _functions[function]; + if (count != args.Length) + { + throw new ArgumentException($"{function} takes {count} arguments"); + } + return implementation(args); + } +} diff --git a/src/tests/csharp_host/Program.cs b/src/tests/csharp_host/Program.cs new file mode 100644 index 000000000..33d5fd2d0 --- /dev/null +++ b/src/tests/csharp_host/Program.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Tests C# bindings generated from witguest's guest.wit against witguest, +// the bindings generated from edge.wit, and the Hyperlight.Component +// runtime they use. + +WitTests.Run(); +EdgeTests.Run(); +RuntimeTests.Run(); +return Check.Report(); + +/// Counts failed checks so one run reports all of them. +static class Check +{ + private static int s_failures; + + public static void True(bool ok, string what) + { + if (!ok) + { + s_failures++; + Console.Error.WriteLine($"FAIL: {what}"); + } + } + + public static void Throws(Action action, string what) where T : Exception + { + try + { + action(); + True(false, $"{what} did not throw"); + } + catch (T) + { + } + } + + public static int Report() + { + Console.WriteLine(s_failures == 0 ? "OK" : $"{s_failures} failures"); + return s_failures == 0 ? 0 : 1; + } +} diff --git a/src/tests/csharp_host/RuntimeTests.cs b/src/tests/csharp_host/RuntimeTests.cs new file mode 100644 index 000000000..b38cfb418 --- /dev/null +++ b/src/tests/csharp_host/RuntimeTests.cs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +using Hyperlight.Component; + +static class RuntimeTests +{ + public static void Run() + { + Check.Throws(() => new WireWriter().String("\ud800"), "lone surrogate"); + Check.True(Option.Some("a") == Option.Some("a"), "options equal"); + Check.True(Option.Some(null) != Option.None, "some null differs from none"); + Check.True(Result.FromOk("x") != Result.FromErr("x"), "ok differs from err"); + + var table = new ResourceTable(); + var aborted = new CallScope(); + var undone = table.Give("aborted", aborted); + aborted.End(); + Check.Throws(() => table.Borrow(undone), "gift undone before the call"); + var started = new CallScope(); + var kept = table.Give("kept", started); + started.Commit(); + started.End(); + Check.True(table.Take(kept) == "kept", "gift kept after the call starts"); + } +} diff --git a/src/tests/csharp_host/Sandbox.cs b/src/tests/csharp_host/Sandbox.cs new file mode 100644 index 000000000..46ff383ae --- /dev/null +++ b/src/tests/csharp_host/Sandbox.cs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using Hyperlight.Component; + +/// Thrown when the native test library reports an error. +sealed class SandboxException(string message) : Exception(message); + +/// +/// A Hyperlight sandbox driven through the C API of +/// hyperlight-csharp-test-native. Register host functions, then +/// , then call guest functions. +/// +sealed unsafe class TestSandbox : IGuestCaller, IHostFunctionRegistry, IDisposable +{ + private const string Lib = "hyperlight_csharp_test_native"; + + [StructLayout(LayoutKind.Sequential)] + private struct Bytes + { + public byte* Ptr; + public nuint Len; + } + + [StructLayout(LayoutKind.Sequential)] + private struct OwnedBytes + { + public byte* Ptr; + public nuint Len; + } + + [DllImport(Lib)] + private static extern OwnedBytes hltest_sandbox_new(Bytes path, out nint sandbox); + + [DllImport(Lib)] + private static extern void hltest_sandbox_free(nint sandbox); + + [DllImport(Lib)] + private static extern OwnedBytes hltest_register( + nint sandbox, Bytes name, nuint count, delegate* unmanaged function, nint context); + + [DllImport(Lib)] + private static extern void hltest_host_result(nint result, Bytes bytes, byte ok); + + [DllImport(Lib)] + private static extern OwnedBytes hltest_evolve(nint sandbox); + + [DllImport(Lib)] + private static extern OwnedBytes hltest_call(nint sandbox, Bytes name, Bytes* args, nuint count, out OwnedBytes result); + + [DllImport(Lib)] + private static extern void hltest_bytes_free(OwnedBytes bytes); + + /// Path of the witguest binary for this build profile. + public static string WitGuest => typeof(TestSandbox).Assembly + .GetCustomAttributes() + .Single(a => a.Key == "WitGuest").Value!; + + private readonly List _functions = []; + private nint _sandbox; + + public TestSandbox(string guest) + { + var path = Encoding.UTF8.GetBytes(guest); + fixed (byte* p = path) + { + Check(hltest_sandbox_new(new Bytes { Ptr = p, Len = (nuint)path.Length }, out _sandbox)); + } + } + + public void Register(string function, int parameterCount, Func implementation) + { + var handle = GCHandle.Alloc(implementation); + _functions.Add(handle); + var name = Encoding.UTF8.GetBytes(function); + fixed (byte* p = name) + { + Check(hltest_register(_sandbox, new Bytes { Ptr = p, Len = (nuint)name.Length }, (nuint)parameterCount, &Invoke, GCHandle.ToIntPtr(handle))); + } + } + + public void Evolve() => Check(hltest_evolve(_sandbox)); + + public byte[] Call(string function, byte[][] args) + { + var pins = args.Select(a => GCHandle.Alloc(a, GCHandleType.Pinned)).ToArray(); + try + { + var buffers = new Bytes[args.Length]; + for (var i = 0; i < args.Length; i++) + { + buffers[i] = new Bytes { Ptr = (byte*)pins[i].AddrOfPinnedObject(), Len = (nuint)args[i].Length }; + } + var name = Encoding.UTF8.GetBytes(function); + OwnedBytes result; + fixed (byte* p = name) + fixed (Bytes* a = buffers) + { + Check(hltest_call(_sandbox, new Bytes { Ptr = p, Len = (nuint)name.Length }, a, (nuint)buffers.Length, out result)); + } + try + { + return new ReadOnlySpan(result.Ptr, (int)result.Len).ToArray(); + } + finally + { + hltest_bytes_free(result); + } + } + finally + { + foreach (var pin in pins) + { + pin.Free(); + } + } + } + + public void Dispose() + { + hltest_sandbox_free(_sandbox); + _sandbox = 0; + foreach (var handle in _functions) + { + handle.Free(); + } + _functions.Clear(); + } + + private static void Check(OwnedBytes error) + { + if (error.Ptr is null) + { + return; + } + try + { + throw new SandboxException(Encoding.UTF8.GetString(error.Ptr, (int)error.Len)); + } + finally + { + hltest_bytes_free(error); + } + } + + // Exceptions must not cross into native code, so they become error results. + [UnmanagedCallersOnly] + private static void Invoke(nint context, Bytes* args, nuint count, nint result) + { + byte[] bytes; + byte ok = 1; + try + { + var function = (Func)GCHandle.FromIntPtr(context).Target!; + var managed = new byte[(int)count][]; + for (var i = 0; i < managed.Length; i++) + { + managed[i] = new ReadOnlySpan(args[i].Ptr, (int)args[i].Len).ToArray(); + } + bytes = function(managed); + } + catch (Exception e) + { + bytes = Encoding.UTF8.GetBytes(e.ToString()); + ok = 0; + } + fixed (byte* p = bytes) + { + hltest_host_result(result, new Bytes { Ptr = p, Len = (nuint)bytes.Length }, ok); + } + } +} diff --git a/src/tests/csharp_host/WitTests.cs b/src/tests/csharp_host/WitTests.cs new file mode 100644 index 000000000..3be6c34be --- /dev/null +++ b/src/tests/csharp_host/WitTests.cs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Runs the C# bindings for witguest's guest.wit against witguest. Its +// exports call the matching host imports, so each round trip encodes and +// decodes in both directions. + +using System.Collections; +using System.Reflection; +using System.Text; +using Hyperlight.Component; +using Hyperlight.Tests.Wit; +using Hyperlight.Tests.Wit.Test.Wit; +using Hyperlight.Tests.Wit.Test.Wit.HostResource; +using Hyperlight.Tests.Wit.Test.Wit.Roundtrip; + +static class WitTests +{ + public static void Run() + { + using var sandbox = new TestSandbox(TestSandbox.WitGuest); + var host = new Host(); + var resources = TestComponent.RegisterImports(sandbox, host, host, host); + sandbox.Evolve(); + var component = new TestComponent(sandbox, resources); + + Primitives(component.Roundtrip); + Lists(component.Roundtrip); + Composites(component.Roundtrip); + HostArguments(component.Roundtrip); + Resources(component.TestHostResource, host); + WorldFunctions(component); + // A trap can leave the sandbox unusable, so it runs last. + try + { + component.Failable.WillTrap(); + Check.True(false, "guest trap did not throw"); + } + catch (SandboxException e) + { + Check.True(e.Message.Contains("deliberate guest crash"), $"guest trap: {e.Message}"); + } + } + + private static void Same(Func call, T value, string what) => + Check.True(DeepEqual(call(value), value), $"{what}: {value}"); + + // Compares lists by element and other values by their fields, since + // records compare lists by reference. + private static bool DeepEqual(object? a, object? b) + { + if (Equals(a, b)) + { + return true; + } + if (a is null || b is null || a is string || b is string) + { + return false; + } + if (a is IEnumerable ea && b is IEnumerable eb) + { + var la = ea.Cast().ToList(); + var lb = eb.Cast().ToList(); + return la.Count == lb.Count && la.Zip(lb).All(p => DeepEqual(p.First, p.Second)); + } + if (a.GetType() != b.GetType()) + { + return false; + } + const BindingFlags fields = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + return a.GetType().GetFields(fields).All(f => DeepEqual(f.GetValue(a), f.GetValue(b))); + } + + private static void Primitives(RoundtripExports r) + { + foreach (var x in new[] { false, true }) Same(r.RoundtripBool, x, "bool"); + foreach (var x in new[] { sbyte.MinValue, -2, sbyte.MaxValue }) Same(r.RoundtripS8, (sbyte)x, "s8"); + foreach (var x in new[] { short.MinValue, -300, short.MaxValue }) Same(r.RoundtripS16, (short)x, "s16"); + foreach (var x in new[] { int.MinValue, -70_000, int.MaxValue }) Same(r.RoundtripS32, x, "s32"); + foreach (var x in new[] { long.MinValue, -5_000_000_000, long.MaxValue }) Same(r.RoundtripS64, x, "s64"); + foreach (var x in new[] { 0, 0xab, byte.MaxValue }) Same(r.RoundtripU8, (byte)x, "u8"); + foreach (var x in new[] { 0, 0xbeef, ushort.MaxValue }) Same(r.RoundtripU16, (ushort)x, "u16"); + foreach (var x in new uint[] { 0, 0xdead_beef, uint.MaxValue }) Same(r.RoundtripU32, x, "u32"); + foreach (var x in new ulong[] { 0, 0x0123_4567_89ab_cdef, ulong.MaxValue }) Same(r.RoundtripU64, x, "u64"); + foreach (var x in new[] { 1.5f, -0.0f, float.MaxValue, float.NegativeInfinity }) Same(r.RoundtripF32, x, "f32"); + foreach (var x in new[] { -2.25, double.MaxValue, double.Epsilon }) Same(r.RoundtripF64, x, "f64"); + foreach (var x in new[] { new Rune('a'), new Rune(0xe9), new Rune(0x1F388) }) Same(r.RoundtripChar, x, "char"); + foreach (var x in new[] { "", "héllo, 世界 🎈" }) Same(r.RoundtripString, x, "string"); + } + + private static void Lists(RoundtripExports r) + { + Same(r.RoundtripList, [], "empty list"); + Same(r.RoundtripList, [1, 2, 3], "list"); + Same(r.RoundtripFixList, [1, 2, 3, 4], "fixed list"); + Same>(r.RoundtripFixListU32, [0, 1, uint.MaxValue, 7], "fixed u32 list"); + Same>(r.RoundtripFixListU64, [0, 1, ulong.MaxValue, 7], "fixed u64 list"); + Same>(r.RoundtripFixListI8, [sbyte.MinValue, -1, 0, sbyte.MaxValue], "fixed s8 list"); + Same>(r.RoundtripFixListI16, [short.MinValue, -1, 0, short.MaxValue], "fixed s16 list"); + Same>(r.RoundtripFixListI32, [int.MinValue, -1, 0, int.MaxValue], "fixed s32 list"); + Same>(r.RoundtripFixListI64, [long.MinValue, -1, 0, long.MaxValue], "fixed s64 list"); + Same>(r.RoundtripFixListF32, [1.5f, -1, 0, float.MaxValue], "fixed f32 list"); + Same>(r.RoundtripFixListF64, [1.5, -1, 0, double.MaxValue], "fixed f64 list"); + Same(r.RoundtripFixListU8Size8, [1, 2, 3, 4, 5, 6, 7, 8], "fixed list of 8"); + Same>(r.RoundtripFixListU64Size2, [1, ulong.MaxValue], "fixed list of 2"); + Same>(r.RoundtripFixListString, ["a", "", "bc", "🎈"], "fixed string list"); + Same>(r.RoundtripFixArrayOfLists, [[1], [], [2, 3]], "fixed list of lists"); + Same>>(r.RoundtripFixArrayOfStringLists, [["a", "bc"], []], "fixed list of string lists"); + Same>>(r.RoundtripNestedList, [["a", "bc"], [], [""]], "nested list"); + Same>(r.RoundtripRecordList, [Record("a", 1), Record("", ulong.MaxValue)], "record list"); + } + + private static Testrecord Record(string contents, ulong length) => new() { Contents = contents, Length = length }; + + private static void Composites(RoundtripExports r) + { + Same(r.RoundtripTuple, ("t", (byte)12), "tuple"); + foreach (var x in new[] { null, "", "m" }) Same(r.RoundtripOption, x, "option"); + foreach (var x in new[] { Option.None, Option.Some(null), Option.Some("mm") }) + { + Same(r.RoundtripNestedOption, x, "nested option"); + } + Same(r.RoundtripResult, Result.FromOk(new Rune('x')), "ok"); + Same(r.RoundtripResult, Result.FromErr("bad"), "err"); + Same(r.RoundtripResultUnit, Result.FromOk(default), "ok unit"); + Same(r.RoundtripResultUnit, Result.FromErr("e"), "err string"); + Same(r.RoundtripResultEmpty, Result.FromOk(default), "empty ok"); + Same(r.RoundtripResultEmpty, Result.FromErr(default), "empty err"); + Same(r.RoundtripRecord, Record("héllo", 0x0123_4567_89ab_cdef), "record"); + foreach (var x in new[] { Smallflags.None, Smallflags.FlagA | Smallflags.FlagC, (Smallflags)7 }) + { + Same(r.RoundtripFlagsSmall, x, "small flags"); + } + foreach (var x in new[] { Mediumflags.None, Mediumflags.M0 | Mediumflags.M9, Mediumflags.M8 }) + { + Same(r.RoundtripFlagsMedium, x, "medium flags"); + } + foreach (var x in new[] { Largeflags.None, Largeflags.Flag00 | Largeflags.Flag1f, (Largeflags)uint.MaxValue }) + { + Same(r.RoundtripFlagsLarge, x, "large flags"); + } + foreach (var x in new Testvariant[] + { + new Testvariant.VariantA(), + new Testvariant.VariantB("b"), + new Testvariant.VariantC(new Rune(0x1F388)), + new Testvariant.VariantD(Record("d", 4)), + }) + { + Same(r.RoundtripVariant, x, "variant"); + } + foreach (var x in Enum.GetValues()) Same(r.RoundtripEnum, x, "enum"); + + var full = new Testcomposite + { + Nested = Option.Some(17), + Records = [Record("a", 1), Record("b", 2)], + Fixed = [7, 8, 9], + Res = Result.FromOk(Record("ok", 10)), + Tup = ("t", 12, Record("tup", 14)), + Choice = new Testvariant.VariantD(Record("v", 16)), + Kind = Testenum.EnumB, + Mask = Mediumflags.M1 | Mediumflags.M8, + C = new Rune(0x1F388), + B = true, + }; + Same(r.RoundtripComposite, full, "composite"); + Same(r.RoundtripComposite, full with + { + Nested = Option.Some(null), + Records = [], + Res = Result.FromErr("bad"), + Choice = new Testvariant.VariantA(), + Mask = Mediumflags.None, + B = false, + }, "composite alternative"); + Same(r.RoundtripComposite, full with { Nested = Option.None }, "composite none"); + + Check.True(r.RoundtripTwoArgs(5, "five") == (5, "five"), "two arguments"); + Check.True(r.RoundtripNoArgs() == 42, "no arguments"); + r.RoundtripNoResult(9); + } + + // The bindings check values that C# types allow but WIT types do not. + private static void HostArguments(RoundtripExports r) + { + Check.Throws(() => r.RoundtripFixList([1]), "fixed list length"); + Check.Throws(() => r.RoundtripFixListU32([]), "fixed list length"); + Check.Throws(() => r.RoundtripEnum((Testenum)3), "enum range"); + Check.Throws(() => r.RoundtripFlagsSmall((Smallflags)8), "flags range"); + Check.Throws(() => r.RoundtripList(null!), "null list"); + Check.Throws(() => r.RoundtripRecord(null!), "null record"); + } + + // witguest appends a, b and c to a resource made with "str" and 'z'. + private static void Resources(TestHostResourceExports t, Host host) + { + Check.True(t.TestUsesLocally(), "uses locally"); + Check.True(host.Made.Count == 1 && host.Made[0].ToString() == "strabc c", "used locally"); + + var r = t.TestMakes(); + Check.True(host.Made.Count == 2 && ReferenceEquals(r, host.Made[1]), "made by the guest"); + t.TestAcceptsBorrow(r); + t.TestAcceptsOwn(r); + var returned = t.TestReturns(); + Check.True(ReferenceEquals(returned, r), "returned to the host"); + Check.True(r.ToString() == "strabc c", "passed in and out"); + } + + private static void WorldFunctions(TestComponent c) + { + Check.True(c.Run("go") == Result.FromOk("go 7"), "run"); + Check.True(c.Run("") == Result.FromErr("empty input"), "run error"); + Check.True(c.Extra.Ping(0xa5) == 0xa5, "inline interface"); + } + + private sealed class Resource(string x, Rune last) : ITestresource + { + private readonly StringBuilder _x = new(x); + private Rune _last = last; + + public void AppendChar(Rune c) + { + _x.Append(c.ToString()); + _last = c; + } + + public override string ToString() => $"{_x} {_last}"; + } + + private sealed class Host : IRoundtrip, IHostResource, ITestImports + { + public List Made { get; } = []; + + public ITestresource NewTestresource(string init1, Rune init2) + { + var r = new Resource(init1, init2); + Made.Add(r); + return r; + } + + public ITestresource RoundtripOwn(ITestresource x) => x; + public void ReturnOwn(ITestresource x) { } + + public ulong Now() => 7; + + public bool RoundtripBool(bool x) => x; + public sbyte RoundtripS8(sbyte x) => x; + public short RoundtripS16(short x) => x; + public int RoundtripS32(int x) => x; + public long RoundtripS64(long x) => x; + public byte RoundtripU8(byte x) => x; + public ushort RoundtripU16(ushort x) => x; + public uint RoundtripU32(uint x) => x; + public ulong RoundtripU64(ulong x) => x; + public float RoundtripF32(float x) => x; + public double RoundtripF64(double x) => x; + public Rune RoundtripChar(Rune x) => x; + public string RoundtripString(string x) => x; + public byte[] RoundtripList(byte[] x) => x; + public (string, byte) RoundtripTuple((string, byte) x) => x; + public string? RoundtripOption(string? x) => x; + public Result RoundtripResult(Result x) => x; + public void RoundtripNoResult(uint x) { } + public byte[] RoundtripFixList(byte[] x) => x; + public IReadOnlyList RoundtripFixListU32(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListU64(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListI8(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListI16(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListI32(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListI64(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListF32(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListF64(IReadOnlyList x) => x; + public byte[] RoundtripFixListU8Size8(byte[] x) => x; + public IReadOnlyList RoundtripFixListU64Size2(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixListString(IReadOnlyList x) => x; + public IReadOnlyList RoundtripFixArrayOfLists(IReadOnlyList x) => x; + public IReadOnlyList> RoundtripFixArrayOfStringLists(IReadOnlyList> x) => x; + public Testrecord RoundtripRecord(Testrecord x) => x; + public Smallflags RoundtripFlagsSmall(Smallflags x) => x; + public Largeflags RoundtripFlagsLarge(Largeflags x) => x; + public Testvariant RoundtripVariant(Testvariant x) => x; + public Testenum RoundtripEnum(Testenum x) => x; + public Mediumflags RoundtripFlagsMedium(Mediumflags x) => x; + public Option RoundtripNestedOption(Option x) => x; + public IReadOnlyList> RoundtripNestedList(IReadOnlyList> x) => x; + public IReadOnlyList RoundtripRecordList(IReadOnlyList x) => x; + public Result RoundtripResultUnit(Result x) => x; + public Result RoundtripResultEmpty(Result x) => x; + public (uint, string) RoundtripTwoArgs(uint first, string second) => (first, second); + public uint RoundtripNoArgs() => 42; + public Testcomposite RoundtripComposite(Testcomposite x) => x; + } +} diff --git a/src/tests/csharp_host/edge.wit b/src/tests/csharp_host/edge.wit new file mode 100644 index 000000000..267828076 --- /dev/null +++ b/src/tests/csharp_host/edge.wit @@ -0,0 +1,79 @@ +// C# naming and type cases checked by EdgeTests.cs. +package test:edge; + +interface types { + record r { r: u8, to-string: u8 } + record p { x: u32 } + type q = p; + type l = list; + record lists { id: u32, names: list, pair: tuple> } + variant v { none, class(string), items(list), value(u8) } +} + +interface ops { + use types.{r, l, lists, v}; + keyword: func(class: r) -> r; + alias: func(x: l) -> l; + nested-lists: func(x: lists) -> lists; + cases: func(x: v) -> v; + single: func(x: tuple) -> tuple; +} + +// Imported and exported, so its types have two copies. +interface shared { + record s { x: u32 } + echo: func(x: s) -> s; + finalize: func(); + to-string: func() -> u32; +} + +interface %world { + w: func(); +} + +world edge { + import test:dep/t@1.0.0; + import test:dep/t@2.0.0; + import shared; + import a:b-c/types; + import a-b:c/types; + export ops; + export shared; + export shared: func(); + export %world; + export other: interface { + g: func(); + } + export register-imports: interface { + r: func(); + } + export edge-component: interface { + c: func(); + } +} + +package a:b-c { + interface types { + f: func() -> u8; + } +} + +package a-b:c { + interface types { + g: func() -> u8; + } +} + +package test:dep@1.0.0 { + interface t { + record r { x: u32 } + f: func(x: r) -> u32; + } +} + +package test:dep@2.0.0 { + interface t { + record r { x: u32 } + f: func(x: r) -> u32; + } +} diff --git a/src/tests/csharp_host/native/Cargo.toml b/src/tests/csharp_host/native/Cargo.toml new file mode 100644 index 000000000..b608656d7 --- /dev/null +++ b/src/tests/csharp_host/native/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hyperlight-csharp-test-native" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false +description = "C API over hyperlight-host for the C# binding tests" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hyperlight-host = { workspace = true, default-features = true } diff --git a/src/tests/csharp_host/native/src/lib.rs b/src/tests/csharp_host/native/src/lib.rs new file mode 100644 index 000000000..975bfc5d8 --- /dev/null +++ b/src/tests/csharp_host/native/src/lib.rs @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! A C API over hyperlight-host for the C# binding tests. Guest and host +//! functions take and return byte buffers. Each function returns an error +//! message, or empty [`OwnedBytes`] on success. + +use std::ffi::c_void; +use std::fmt::Display; +use std::ptr; + +use hyperlight_host::{GuestBinary, HyperlightError, Sandbox, UninitializedSandbox}; + +/// Bytes the caller owns. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Bytes { + ptr: *const u8, + len: usize, +} + +/// Bytes this library owns. Free them with [`hltest_bytes_free`]. +#[repr(C)] +pub struct OwnedBytes { + ptr: *mut u8, + len: usize, +} + +const OK: OwnedBytes = OwnedBytes { + ptr: ptr::null_mut(), + len: 0, +}; + +/// A sandbox that host functions are registered on, then evolved and +/// called. +pub struct TestSandbox { + uninit: Option, + sandbox: Option, +} + +/// The result a host function sets with [`hltest_host_result`]. +pub struct HostResult(Result, String>); + +/// A host function. It receives the `context` it was registered with and +/// `count` arguments, and sets its result with [`hltest_host_result`]. +pub type HostFunction = unsafe extern "C" fn( + context: *mut c_void, + args: *const Bytes, + count: usize, + result: *mut HostResult, +); + +/// # Safety +/// +/// `b.ptr` must point to `b.len` readable bytes for `'a`, or `b.len` must +/// be zero. +unsafe fn slice<'a>(b: Bytes) -> &'a [u8] { + if b.len == 0 { + &[] + } else { + // SAFETY: the caller guarantees `b.ptr` points to `b.len` bytes. + unsafe { std::slice::from_raw_parts(b.ptr, b.len) } + } +} + +/// # Safety +/// +/// As for [`slice`]. +unsafe fn string(b: Bytes) -> Result { + // SAFETY: the caller upholds the preconditions of `slice`. + let bytes = unsafe { slice(b) }; + std::str::from_utf8(bytes) + .map(str::to_string) + .map_err(report) +} + +fn owned(v: Vec) -> OwnedBytes { + let b = v.into_boxed_slice(); + let len = b.len(); + OwnedBytes { + ptr: Box::into_raw(b).cast(), + len, + } +} + +fn report(e: impl Display) -> OwnedBytes { + owned(e.to_string().into_bytes()) +} + +/// Frees bytes this library returned. +/// +/// # Safety +/// +/// `b` must come from this library and not have been freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_bytes_free(b: OwnedBytes) { + if !b.ptr.is_null() { + // SAFETY: `b` is a boxed slice from `owned` that the caller has + // not freed. + drop(unsafe { Box::from_raw(ptr::slice_from_raw_parts_mut(b.ptr, b.len)) }); + } +} + +/// Creates a sandbox for the guest binary at the UTF-8 path `path`. +/// +/// # Safety +/// +/// `path` must be valid for reads and `out` for writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_sandbox_new(path: Bytes, out: *mut *mut TestSandbox) -> OwnedBytes { + // SAFETY: the caller guarantees `path` is valid. + let path = match unsafe { string(path) } { + Ok(path) => path, + Err(e) => return e, + }; + match UninitializedSandbox::new(GuestBinary::FilePath(path.into()), None) { + Ok(uninit) => { + let sandbox = Box::new(TestSandbox { + uninit: Some(uninit), + sandbox: None, + }); + // SAFETY: the caller guarantees `out` is valid for writes. + unsafe { *out = Box::into_raw(sandbox) }; + OK + } + Err(e) => report(e), + } +} + +/// Frees a sandbox. +/// +/// # Safety +/// +/// `sandbox` must come from [`hltest_sandbox_new`], not have been freed, +/// and not be in use by another call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_sandbox_free(sandbox: *mut TestSandbox) { + if !sandbox.is_null() { + // SAFETY: the caller guarantees `sandbox` is a live box from + // `hltest_sandbox_new` that nothing else uses. + drop(unsafe { Box::from_raw(sandbox) }); + } +} + +/// The host function and context of one registration. +#[derive(Clone, Copy)] +struct Callback { + function: HostFunction, + context: usize, +} + +impl Callback { + fn call(self, args: Vec>) -> Result, HyperlightError> { + let args = args + .iter() + .map(|a| Bytes { + ptr: a.as_ptr(), + len: a.len(), + }) + .collect::>(); + let mut result = HostResult(Err("the host function set no result".to_string())); + // SAFETY: `hltest_register` requires `function` to be callable with + // `context` while the sandbox lives. `args` and `result` outlive the + // call. + unsafe { + (self.function)( + self.context as *mut c_void, + args.as_ptr(), + args.len(), + &mut result, + ) + }; + result.0.map_err(HyperlightError::Error) + } +} + +/// Registers a host function named `name` with `count` parameters. +/// +/// # Safety +/// +/// `sandbox` must be live, not evolved, and not in use by another call. +/// `name` must be valid for reads. +/// `function` must be safe to call with `context` from any thread until +/// the sandbox is freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_register( + sandbox: *mut TestSandbox, + name: Bytes, + count: usize, + function: HostFunction, + context: *mut c_void, +) -> OwnedBytes { + // SAFETY: the caller guarantees `sandbox` is live and not in use + // elsewhere. + let sandbox = unsafe { &mut *sandbox }; + let Some(uninit) = sandbox.uninit.as_mut() else { + return report("the sandbox is already evolved"); + }; + // SAFETY: the caller guarantees `name` is valid. + let name = match unsafe { string(name) } { + Ok(name) => name, + Err(e) => return e, + }; + let cb = Callback { + function, + context: context as usize, + }; + macro_rules! register { + ($($n:literal => ($($a:ident),*)),*) => { + match count { + $($n => uninit.register(&name, move |$($a: Vec),*| cb.call(vec![$($a),*])),)* + n => return report(format!("host functions take at most 2 parameters, not {n}")), + } + }; + } + let result = register!( + 0 => (), + 1 => (a0), + 2 => (a0, a1) + ); + match result { + Ok(()) => OK, + Err(e) => report(e), + } +} + +/// Sets the result of a host function call. `ok` selects whether `bytes` +/// is the encoded result or a UTF-8 error message. +/// +/// # Safety +/// +/// `result` must be the pointer passed to the host function, and `bytes` +/// valid for reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_host_result(result: *mut HostResult, bytes: Bytes, ok: bool) { + // SAFETY: the caller guarantees `bytes` is valid. + let data = unsafe { slice(bytes) }.to_vec(); + let value = if ok { + Ok(data) + } else { + Err(String::from_utf8_lossy(&data).into_owned()) + }; + // SAFETY: the caller guarantees `result` is the live pointer passed to + // the host function. + unsafe { (*result).0 = value }; +} + +/// Initializes the guest. Host functions must be registered before. +/// +/// # Safety +/// +/// `sandbox` must be live and not in use by another call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_evolve(sandbox: *mut TestSandbox) -> OwnedBytes { + // SAFETY: the caller guarantees `sandbox` is live and not in use + // elsewhere. + let sandbox = unsafe { &mut *sandbox }; + let Some(uninit) = sandbox.uninit.take() else { + return report("the sandbox is already evolved"); + }; + match uninit.evolve() { + Ok(s) => { + sandbox.sandbox = Some(s); + OK + } + Err(e) => report(e), + } +} + +/// Calls the guest function `name` with `count` arguments and writes its +/// result to `out`. +/// +/// # Safety +/// +/// `sandbox` must be live and not in use by another call. `name` and +/// `count` elements of `args` must be valid for reads, and `out` valid for +/// writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hltest_call( + sandbox: *mut TestSandbox, + name: Bytes, + args: *const Bytes, + count: usize, + out: *mut OwnedBytes, +) -> OwnedBytes { + // SAFETY: the caller guarantees `sandbox` is live and not in use + // elsewhere. + let sandbox = unsafe { &mut *sandbox }; + let Some(sb) = sandbox.sandbox.as_mut() else { + return report("the sandbox is not evolved"); + }; + // SAFETY: the caller guarantees `name` is valid. + let name = match unsafe { string(name) } { + Ok(name) => name, + Err(e) => return e, + }; + let args = (0..count) + // SAFETY: the caller guarantees `args` has `count` valid elements. + .map(|i| unsafe { slice(*args.add(i)) }.to_vec()) + .collect::>(); + let mut args = args.into_iter(); + macro_rules! call { + ($($n:literal => ($($a:ident),*)),*) => { + match count { + $($n => { + $(let $a = args.next().unwrap();)* + sb.call::>(&name, ($($a,)*)) + })* + n => return report(format!("guest functions take at most 2 parameters, not {n}")), + } + }; + } + let result = call!( + 0 => (), + 1 => (a0), + 2 => (a0, a1) + ); + match result { + Ok(bytes) => { + // SAFETY: the caller guarantees `out` is valid for writes. + unsafe { *out = owned(bytes) }; + OK + } + Err(e) => report(format!("{e:?}")), + } +}