Skip to content

Commit 92cc474

Browse files
committed
Test generated C# bindings with .NET
Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent 62933b1 commit 92cc474

11 files changed

Lines changed: 589 additions & 2 deletions

File tree

‎.github/workflows/dep_code_checks.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ jobs:
9898
- name: Run loom concurrency tests
9999
run: just test-loom
100100

101+
# Uses the .NET SDK installed in the runner image.
102+
- name: Test generated C# bindings
103+
run: just test-csharp-bindings
104+
101105
# A job step, not a post-job step, so the save runs before rust-cache's
102106
# post-job cleanup deletes the sysroot. Only main saves, as caches are not
103107
# shared across branches.
@@ -185,6 +189,10 @@ jobs:
185189
just test-compilation-no-default-features debug
186190
just test-compilation-no-default-features release
187191
192+
# Uses the .NET SDK installed in the runner image.
193+
- name: Test generated C# bindings
194+
run: just test-csharp-bindings
195+
188196
# A job step, not a post-job step, so the save runs before rust-cache's
189197
# post-job cleanup deletes the sysroot. Only main saves, as caches are not
190198
# shared across branches.

‎Justfile‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ code-checks-like-ci config=default-target hypervisor="kvm":
160160
just test-compilation-no-default-features debug
161161
just test-compilation-no-default-features release
162162

163+
@# Test generated C# bindings
164+
just test-csharp-bindings
165+
163166
build-guests-like-ci config=default-target:
164167
@# Build and move Rust guests
165168
just build-rust-guests {{config}}
@@ -337,6 +340,10 @@ test-rust-tracing target=default-target features="":
337340
test-doc target=default-target features="":
338341
{{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} {{ if features =="" {''} else { "--features " + features } }} --doc
339342

343+
# generates and tests C# bindings for the component test fixtures. Needs the .NET 8 SDK or newer.
344+
test-csharp-bindings:
345+
dotnet run --project src/tests/component_wire/csharp
346+
340347
# Nextest runs one Miri process per job, each with its own interpreter, so
341348
# memory grows with the job count at roughly 0.4GB per job. Lower MIRI_JOBS on
342349
# a machine with many cores but little RAM.

‎docs/component-wire-format.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ their parts with no alignment.
4848

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

5457
## C# host bindings
5558

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 The Hyperlight Authors.
3+
4+
//! Generates C# host bindings for a WIT world.
5+
//!
6+
//! Usage: `csharp_bindgen <wit path> <namespace> <output file>`
7+
8+
use std::path::Path;
9+
use std::process::ExitCode;
10+
11+
use hyperlight_component_csharp::generate_from_path;
12+
13+
fn main() -> ExitCode {
14+
let args: Vec<String> = std::env::args().skip(1).collect();
15+
let [wit, namespace, out] = args.as_slice() else {
16+
eprintln!("usage: csharp_bindgen <wit path> <namespace> <output file>");
17+
return ExitCode::FAILURE;
18+
};
19+
let result = generate_from_path(Path::new(wit), None, namespace)
20+
.map_err(|e| e.to_string())
21+
.and_then(|cs| std::fs::write(out, cs).map_err(|e| format!("{out}: {e}")));
22+
match result {
23+
Ok(()) => ExitCode::SUCCESS,
24+
Err(e) => {
25+
eprintln!("{e}");
26+
ExitCode::FAILURE
27+
}
28+
}
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<RollForward>Major</RollForward>
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
10+
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
11+
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '../../../..'))</RepoRoot>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<Compile Include="Program.cs" />
16+
<Compile Include="Fakes.cs" />
17+
<Compile Include="WireTests.cs" />
18+
<Compile Include="EdgeTests.cs" />
19+
<Compile Include="RuntimeTests.cs" />
20+
<Compile Include="../../../hyperlight_component_csharp/src/Runtime.cs" Link="Runtime.cs" />
21+
<None Include="../vectors.txt" Link="vectors.txt" CopyToOutputDirectory="PreserveNewest" />
22+
<WitFixture Include="../wire.wit" Namespace="Hyperlight.Tests.Wire" GeneratedFile="Wire.g.cs" />
23+
<WitFixture Include="edge.wit" Namespace="Hyperlight.Tests.Edge" GeneratedFile="Edge.g.cs" />
24+
</ItemGroup>
25+
26+
<!-- Runs the generator under test on each fixture. -->
27+
<Target Name="GenerateBindings" BeforeTargets="CoreCompile">
28+
<PropertyGroup>
29+
<GeneratedDir>$(MSBuildProjectDirectory)/$(IntermediateOutputPath)generated/</GeneratedDir>
30+
</PropertyGroup>
31+
<MakeDir Directories="$(GeneratedDir)" />
32+
<Exec Command="cargo run --quiet -p hyperlight-component-csharp --example csharp_bindgen -- &quot;%(WitFixture.FullPath)&quot; %(WitFixture.Namespace) &quot;$(GeneratedDir)%(WitFixture.GeneratedFile)&quot;"
33+
WorkingDirectory="$(RepoRoot)" />
34+
<ItemGroup>
35+
<Compile Include="$(GeneratedDir)%(WitFixture.GeneratedFile)" />
36+
</ItemGroup>
37+
</Target>
38+
39+
</Project>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 The Hyperlight Authors.
3+
4+
// Checks the naming and type cases in edge.wit.
5+
6+
using System.Runtime.CompilerServices;
7+
using Hyperlight.Tests.Edge;
8+
using Hyperlight.Tests.Edge.Test.Edge.Types;
9+
using Dep1 = Hyperlight.Tests.Edge.Test.Dep.V1_0_0;
10+
using Dep2 = Hyperlight.Tests.Edge.Test.Dep.V2_0_0;
11+
12+
static class EdgeTests
13+
{
14+
public static void Run()
15+
{
16+
var guest = new Guest();
17+
var component = new EdgeComponent(guest);
18+
19+
void Sent(string function, byte[] args, string what) =>
20+
Check.True(guest.Function == function && guest.Args.Length == 1 && guest.Args[0].SequenceEqual(args), what);
21+
22+
// Fields named like their record or like object members get a suffix.
23+
var r = new R { R_ = 1, ToString_ = 2 };
24+
Check.True(component.Ops.Keyword(@class: r) == r, "keyword round trip");
25+
Sent("test:edge/ops#keyword", [1, 2], "keyword encoding");
26+
Check.True(r.ToString().Contains("ToString_ = 2"), "record ToString");
27+
28+
foreach (var type in new[] { typeof(R), typeof(P), typeof(Lists) })
29+
{
30+
Check.True(type.GetProperties().All(p => p.IsDefined(typeof(RequiredMemberAttribute), false)), $"{type.Name} required");
31+
}
32+
33+
// Aliases resolve to the type they name.
34+
IReadOnlyList<P> points = [new P { X = 1 }, new P { X = 2 }];
35+
Check.True(component.Ops.Alias(points).SequenceEqual(points), "alias round trip");
36+
Sent("test:edge/ops#alias", [2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "alias encoding");
37+
38+
// Lists inside records and variant cases compare by element.
39+
Lists MakeLists(byte last) => new() { Id = 1, Names = ["a", "b"], Pair = (2, [3, last]) };
40+
var lists = MakeLists(4);
41+
Check.True(lists == MakeLists(4) && lists.GetHashCode() == MakeLists(4).GetHashCode(), "lists equal");
42+
Check.True(lists != MakeLists(5), "tuple bytes differ");
43+
Check.True(lists != (lists with { Names = ["a"] }), "names differ");
44+
Check.True(component.Ops.Structural(lists) == lists, "lists round trip");
45+
V items = new V.Items([1, 2]);
46+
Check.True(items == new V.Items([1, 2]) && items.GetHashCode() == new V.Items([1, 2]).GetHashCode(), "items equal");
47+
Check.True(items != new V.Items([1]), "items differ");
48+
49+
// Cases named like Match, Value or a keyword get escaped names and parameters.
50+
string Describe(V v) => v.Match(
51+
none: () => "none",
52+
match: m => $"match {m}",
53+
@class: c => $"class {c}",
54+
items: i => $"items {i.Count}",
55+
value: x => $"value {x}");
56+
V[] cases = [new V.None(), new V.Match_(7), new V.Class("c"), new V.Value_(9), items];
57+
string[] expected = ["none", "match 7", "class c", "value 9", "items 2"];
58+
for (var i = 0; i < cases.Length; i++)
59+
{
60+
Check.True(Describe(cases[i]) == expected[i], $"match {expected[i]}");
61+
var switched = "";
62+
cases[i].Switch(
63+
none: () => switched = "none",
64+
match: m => switched = $"match {m}",
65+
@class: c => switched = $"class {c}",
66+
items: i => switched = $"items {i.Count}",
67+
value: x => switched = $"value {x}");
68+
Check.True(switched == expected[i], $"switch {expected[i]}");
69+
Check.True(component.Ops.Cases(cases[i]) == cases[i], $"{expected[i]} round trip");
70+
}
71+
Sent("test:edge/ops#cases", [3, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "items encoding");
72+
73+
Check.True(component.Ops.Single(new ValueTuple<ushort>(0x1234)).Item1 == 0x1234, "single tuple round trip");
74+
Sent("test:edge/ops#single", [0x34, 0x12], "single tuple encoding");
75+
76+
component.Other.G();
77+
Check.True(guest.Function == "other#g" && guest.Args.Length == 0, "inline interface call");
78+
79+
// Exports named like members of the component class get escaped names.
80+
component.RegisterImports_.R();
81+
Check.True(guest.Function == "register-imports#r", "export named register-imports");
82+
component.EdgeComponent_.C();
83+
Check.True(guest.Function == "edge-component#c", "export named like the component class");
84+
85+
// Each version of an imported package has its own namespace and wire name.
86+
var registry = new Registry();
87+
EdgeComponent.RegisterImports(registry, new DepV1(), new DepV2());
88+
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");
89+
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");
90+
}
91+
92+
sealed class DepV1 : Dep1.IT
93+
{
94+
public uint F(Dep1.T.R x) => x.X + 100;
95+
}
96+
97+
sealed class DepV2 : Dep2.IT
98+
{
99+
public uint F(Dep2.T.R x) => x.X + 200;
100+
}
101+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 The Hyperlight Authors.
3+
4+
using Hyperlight.Component;
5+
6+
/// <summary>
7+
/// Records each call and returns the arguments concatenated, which echoes
8+
/// a single argument. <see cref="Reply"/> replaces the next result.
9+
/// </summary>
10+
sealed class Guest : IGuestCaller
11+
{
12+
public string Function = "";
13+
14+
public byte[][] Args = [];
15+
16+
public byte[]? Reply;
17+
18+
public byte[] Call(string function, byte[][] args)
19+
{
20+
Function = function;
21+
Args = args;
22+
var result = Reply ?? args.SelectMany(a => a).ToArray();
23+
Reply = null;
24+
return result;
25+
}
26+
}
27+
28+
sealed class Registry : IHostFunctionRegistry
29+
{
30+
private readonly Dictionary<string, (int Count, Func<byte[][], byte[]> Function)> _functions = [];
31+
32+
public void Register(string function, int parameterCount, Func<byte[][], byte[]> implementation) =>
33+
_functions.Add(function, (parameterCount, implementation));
34+
35+
public byte[] Call(string function, params byte[][] args)
36+
{
37+
var (count, implementation) = _functions[function];
38+
if (count != args.Length)
39+
{
40+
throw new ArgumentException($"{function} takes {count} arguments");
41+
}
42+
return implementation(args);
43+
}
44+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 The Hyperlight Authors.
3+
4+
// Tests C# bindings generated from wire.wit and edge.wit, and the
5+
// Hyperlight.Component runtime they use.
6+
7+
WireTests.Run();
8+
EdgeTests.Run();
9+
RuntimeTests.Run();
10+
return Check.Report();
11+
12+
/// <summary>Counts failed checks so one run reports all of them.</summary>
13+
static class Check
14+
{
15+
private static int s_failures;
16+
17+
public static void True(bool ok, string what)
18+
{
19+
if (!ok)
20+
{
21+
s_failures++;
22+
Console.Error.WriteLine($"FAIL: {what}");
23+
}
24+
}
25+
26+
public static void Throws<T>(Action action, string what) where T : Exception
27+
{
28+
try
29+
{
30+
action();
31+
True(false, $"{what} did not throw");
32+
}
33+
catch (T)
34+
{
35+
}
36+
}
37+
38+
public static int Report()
39+
{
40+
Console.WriteLine(s_failures == 0 ? "OK" : $"{s_failures} failures");
41+
return s_failures == 0 ? 0 : 1;
42+
}
43+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 The Hyperlight Authors.
3+
4+
using Hyperlight.Component;
5+
6+
static class RuntimeTests
7+
{
8+
public static void Run()
9+
{
10+
Check.Throws<WireFormatException>(() => new WireReader([1, 0]).U32(), "truncated u32");
11+
Check.Throws<WireFormatException>(() => new WireReader([5, 0, 0, 0, (byte)'a']).String(), "truncated string");
12+
Check.Throws<WireFormatException>(() => new WireReader([2, 0, 0, 0, 0xc3, 0x28]).String(), "invalid UTF-8");
13+
Check.Throws<WireFormatException>(() => new WireReader([0x00, 0xd8, 0, 0]).Char(), "surrogate char");
14+
Check.Throws<WireFormatException>(() => new WireReader([0xff, 0xff, 0xff, 0x7f]).Length(), "oversized list");
15+
Check.Throws<WireFormatException>(() =>
16+
{
17+
var r = new WireReader([1, 2]);
18+
r.U8();
19+
r.End();
20+
}, "trailing bytes");
21+
22+
Check.True(Option<byte[]?>.Some([1]) == Option<byte[]?>.Some([1]), "option compares lists");
23+
IReadOnlyList<byte> array = new byte[] { 1, 2 };
24+
IReadOnlyList<byte> list = new List<byte> { 1, 2 };
25+
Check.True(
26+
Structural.Equal(array, list) && Structural.Hash(array) == Structural.Hash(list),
27+
"equal byte collections hash alike");
28+
Check.True(Option<byte[]?>.Some([1]) != Option<byte[]?>.Some([2]), "option lists differ");
29+
Check.True(Option<byte[]?>.Some(null) != Option<byte[]?>.None, "some null differs from none");
30+
Check.True(Result<byte[], string>.FromOk([1]) == Result<byte[], string>.FromOk([1]), "result compares lists");
31+
Check.True(Result<string, string>.FromOk("x") != Result<string, string>.FromErr("x"), "ok differs from err");
32+
33+
var ok = Result<uint, string>.FromOk(7);
34+
var err = Result<uint, string>.FromErr("e");
35+
Check.True(ok.Match(ok: v => v.ToString(), err: e => e) == "7", "result match ok");
36+
Check.True(err.Match(ok: v => v.ToString(), err: e => e) == "e", "result match err");
37+
var switched = "";
38+
err.Switch(ok: v => switched = "ok", err: e => switched = e);
39+
Check.True(switched == "e", "result switch err");
40+
}
41+
}

0 commit comments

Comments
 (0)