From 53a720c82bb88c560285ee7269bee879fc21693a Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Tue, 13 Oct 2026 17:25:29 +1300 Subject: [PATCH 1/4] csharp: build test projects with wasi-sdk 34 and on linux-arm64 wasi-sdk 34's wasm-ld puts the stack first by default, which needs the data to start above the stack, but the ILCompiler sets a global base of 1024, so every C# test failed to link. Put the stack after the data instead: raising the global base to the stack size links, but breaks the runtime's exception unwinding. Also reference the ILCompiler runtime package for the host architecture rather than always x64, so the tests run on aarch64 Linux. --- crates/csharp/src/csproj.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/csharp/src/csproj.rs b/crates/csharp/src/csproj.rs index 76b8c5d57..dbcb4de82 100644 --- a/crates/csharp/src/csproj.rs +++ b/crates/csharp/src/csproj.rs @@ -96,6 +96,14 @@ impl CSProjectLLVMBuilder { + + + + + " ); @@ -105,6 +113,12 @@ impl CSProjectLLVMBuilder { "linux" => std::env::consts::OS, other => todo!("OS {} not supported", other), }; + // The ILCompiler runs on the host. Windows on Arm uses the x64 package (see the + // `_hostArchitecture` workaround in the test runner). + let arch = match (os, std::env::consts::ARCH) { + ("linux", "aarch64") => "arm64", + _ => "x64", + }; // Share nuget packages between codegen and runtime tests. let packages_path = if self.dir.to_str().unwrap().contains("codegen") { @@ -117,7 +131,7 @@ impl CSProjectLLVMBuilder { r#" - + "#), ); From 8229e8ce32978f151f4ccd6bc02ed2696ff2ad03 Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Tue, 13 Oct 2026 17:25:29 +1300 Subject: [PATCH 2/4] csharp: fix component-model async codegen - Declare an async function returning `result` without payloads as `Task`, not `Task`. - Await the export in the async-lift wrapper and wrap its value in the Result, instead of calling it unawaited and awaiting the Result. - Call async imports through the interface's interop class rather than a nonexistent `IImportsInterop`. - Number futures and streams together, in signature order, in the `[future-*-N]` / `[stream-*-N]` intrinsic names, as the canonical ABI does. A function with a stream parameter and a future result (such as `wasi:cli/stdout.write-via-stream`) produced an invalid component. - Name and number futures and streams in an async import's result after the function, instead of an empty function name. - Pin only primitive future/stream buffers. Other payloads, such as `Result<,>`, hold references or differ from the canonical ABI layout, so they go through native memory and the vtable's Lift. Size native buffers for the whole read, and don't call a missing Lift. - Rethrow an async export's failure before its first suspension instead of reporting a NullReferenceException, fail fast on a later failure, and explain an export waiting on something that is not a waitable. - Fully qualify Task, List, Action and TaskContinuationOptions so the generated code compiles without ImplicitUsings. Adds runtime tests for async exports returning results and for a stream parameter with a future result. --- crates/csharp/src/AsyncSupport.cs | 19 +++-- crates/csharp/src/function.rs | 78 +++++++++++-------- crates/csharp/src/interface.rs | 72 ++++++++++++++--- crates/csharp/src/world_generator.rs | 1 + tests/runtime/async-export-result/runner.rs | 18 +++++ tests/runtime/async-export-result/test.cs | 28 +++++++ tests/runtime/async-export-result/test.rs | 22 ++++++ tests/runtime/async-export-result/test.wit | 19 +++++ .../stream-param-future-result/runner.cs | 34 ++++++++ .../stream-param-future-result/runner.rs | 28 +++++++ .../stream-param-future-result/test.rs | 23 ++++++ .../stream-param-future-result/test.wit | 22 ++++++ 12 files changed, 312 insertions(+), 52 deletions(-) create mode 100644 tests/runtime/async-export-result/runner.rs create mode 100644 tests/runtime/async-export-result/test.cs create mode 100644 tests/runtime/async-export-result/test.rs create mode 100644 tests/runtime/async-export-result/test.wit create mode 100644 tests/runtime/stream-param-future-result/runner.cs create mode 100644 tests/runtime/stream-param-future-result/runner.rs create mode 100644 tests/runtime/stream-param-future-result/test.rs create mode 100644 tests/runtime/stream-param-future-result/test.wit diff --git a/crates/csharp/src/AsyncSupport.cs b/crates/csharp/src/AsyncSupport.cs index 37c617511..3dfc676e5 100644 --- a/crates/csharp/src/AsyncSupport.cs +++ b/crates/csharp/src/AsyncSupport.cs @@ -475,8 +475,12 @@ internal int TakeHandle() protected unsafe IntPtr GetBuffer(int length, T[]? userBuffer, IVTable vTable, List cleanups) { - // For primitive, blittable types, TODO: this probably does not align 100% with the component ABI? - if (typeof(T).IsPrimitive || typeof(T).IsValueType) + // Primitives have the same layout in C# and the canonical ABI, so their array can be pinned + // and used directly. Any other type (records, variants, results, strings, ...) has a + // different managed layout, and Result<,> and friends hold references so cannot be pinned: + // they go through native memory and the vtable's Lift/Lower, which the generator emits for + // every non-primitive payload. + if (typeof(T).IsPrimitive) { T[] buffer; // For Streams, the user passes the buffer, so use that. @@ -495,7 +499,7 @@ protected unsafe IntPtr GetBuffer(int length, T[]? userBuffer, IVTable vTable else { System.Diagnostics.Debug.Assert(vTable.Size > 0, $"Did not compute size for {typeof(T)}."); - IntPtr bufferPtr = (IntPtr)global::System.Runtime.InteropServices.NativeMemory.AlignedAlloc(vTable.Size, vTable.Align); + IntPtr bufferPtr = (IntPtr)global::System.Runtime.InteropServices.NativeMemory.AlignedAlloc(vTable.Size * (uint)length, vTable.Align); cleanups.Add(() => global::System.Runtime.InteropServices.NativeMemory.Free((void*)bufferPtr)); return bufferPtr; } @@ -503,8 +507,8 @@ protected unsafe IntPtr GetBuffer(int length, T[]? userBuffer, IVTable vTable protected unsafe T[] LiftBuffer(IntPtr buffer, T[] resultBuffer, Lift? liftFunc) { - // For primitive, blittable types - if (typeof(T).IsPrimitive || typeof(T).IsValueType) + // Primitives were read straight into the pinned array; see GetBuffer. + if (typeof(T).IsPrimitive) { // TODO array length > 1 resultBuffer[0] = *(T*)buffer; @@ -625,7 +629,7 @@ public ComponentTask Read() { try { - readTask.SetResult(((T[])VTable.Lift(buf, new T[1]))[0]); + readTask.SetResult(LiftBuffer(buf, new T[1], VTable.Lift)[0]); } catch(Exception e) { @@ -710,7 +714,8 @@ public ComponentTask Read(T[] resultBuffer) var task = ReadInternal(buf, resultBuffer.Length, VTable); task.ContinueWith(it => { - if (it.IsCompletedSuccessfully) + // Primitives were read straight into resultBuffer, and have no Lift. + if (it.IsCompletedSuccessfully && VTable.Lift != null) { VTable.Lift(buf, resultBuffer); } diff --git a/crates/csharp/src/function.rs b/crates/csharp/src/function.rs index 9de5852cf..ed564bcad 100644 --- a/crates/csharp/src/function.rs +++ b/crates/csharp/src/function.rs @@ -35,6 +35,8 @@ pub(crate) struct FunctionBindgen<'a, 'b> { parameter_type: ParameterType, pub(crate) result_type: Option, pub(crate) resource_type_name: Option, + /// The function's futures and streams in signature order; see `future_or_stream_index`. + pub(crate) futures_and_streams: Vec, } impl<'a, 'b> FunctionBindgen<'a, 'b> { @@ -73,9 +75,20 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { parameter_type: parameter_type, result_type: result_type, resource_type_name: None, + futures_and_streams: Vec::new(), } } + /// Index of a future or stream type among the function's futures and streams, which the + /// canonical ABI numbers together. Payloads nested inside another future or stream are not in + /// that list and use 0. + fn future_or_stream_index(&self, ty: TypeId) -> usize { + self.futures_and_streams + .iter() + .position(|t| *t == ty) + .unwrap_or(0) + } + fn lower_variant( &mut self, cases: &[(&str, Option)], @@ -315,7 +328,7 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { let is_async = InterfaceGenerator::is_async(&func.kind); if is_async && !use_await { - uwriteln!(self.src, "Task<{ty}> {ret};"); + uwriteln!(self.src, "global::System.Threading.Tasks.Task<{ty}> {ret};"); } else { uwriteln!(self.src, "{ty} {ret};"); } @@ -358,17 +371,15 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { } let head = oks.concat(); let tail = oks.iter().map(|_| ")").collect::>().concat(); + // Await the export itself; the Result wrapping its value is not awaitable. + let await_ = if use_await { "await " } else { "" }; let val = if payload_is_void { - uwriteln!(self.src, "{target}.{func_name}({oper});"); + uwriteln!(self.src, "{await_}{target}.{func_name}({oper});"); "new None()".to_owned() } else { - format!("{target}.{func_name}({oper})") + format!("({await_}{target}.{func_name}({oper}))") }; - uwriteln!( - self.src, - "{ret} = {}{head}{val}{tail};", - if use_await { "await " } else { "" } - ); + uwriteln!(self.src, "{ret} = {head}{val}{tail};"); if !self.results.is_empty() { self.interface_gen.csharp_gen.needs_wit_exception = true; let cases = cases.join("\n"); @@ -1166,26 +1177,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { let func_name = self.func_name.to_upper_camel_case(); let operands = operands.join(", "); - let (_namespace, interface_name) = - &CSharp::get_class_name_from_qualified_name(self.interface_gen.name); - let mut interop_name = format!( - "{}ImportsInterop", - interface_name - .strip_prefix("I") - .unwrap() - .strip_suffix(if self.interface_gen.direction == Direction::Import { - "Imports" - } else { - "Exports" - }) - .unwrap() - .to_upper_camel_case() - ); - - if self.interface_gen.is_world && self.interface_gen.direction == Direction::Import - { - interop_name = format!("Imports.{interop_name}"); - } + let interop_name = self.interface_gen.interop_class_name(); let resource_type_name = match self.kind { FunctionKind::Method(resource_type_id) @@ -1250,8 +1242,22 @@ impl Bindgen for FunctionBindgen<'_, '_> { return (int)CallbackCode.Exit; }} + if (task.IsFaulted) + {{ + global::System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(task.Exception!.InnerException!); + }} + + // After this returns nothing observes the task, so a later failure must not be lost. + task.ContinueWith( + t => global::System.Environment.FailFast("Async export {async_func_name} failed", t.Exception), + global::System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted | global::System.Threading.Tasks.TaskContinuationOptions.ExecuteSynchronously); + // TODO: Defer dropping borrowed resources until a result is returned. ContextTask* contextTaskPtr = AsyncSupport.ContextGet(); + if (contextTaskPtr == null) + {{ + throw new global::System.InvalidOperationException("Async export {async_func_name} is waiting on something other than a component-model waitable, such as Task.Yield or Task.Delay; that is not supported yet."); + }} return (int)CallbackCode.Wait | (int)(contextTaskPtr->WaitableSetHandle << 4); }} @@ -1261,7 +1267,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { // Start the Async function uwriteln!( self.src, - r#"public static async Task {async_func_name}({}) + r#"public static async global::System.Threading.Tasks.Task {async_func_name}({}) {{ var cleanups = new global::System.Collections.Generic.List(); @@ -1368,7 +1374,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { {name}TaskReturn({ret_param}); - }}, TaskContinuationOptions.ExecuteSynchronously); + }}, global::System.Threading.Tasks.TaskContinuationOptions.ExecuteSynchronously); // TODO: Defer dropping borrowed resources until a result is returned. ContextTask* contextTaskPtr = AsyncSupport.ContextGet(); @@ -1633,8 +1639,8 @@ impl Bindgen for FunctionBindgen<'_, '_> { results.extend(operands.iter().take(*amt).cloned()); } - Instruction::FutureLower { payload, ty: _ } - | Instruction::StreamLower { payload, ty: _ } => { + Instruction::FutureLower { payload, ty } | Instruction::StreamLower { payload, ty } => { + let index = self.future_or_stream_index(*ty); let op = &operands[0]; let (generic_type_name, generic_type_name_with_qualifier) = match payload { Some(generic_type) => { @@ -1653,6 +1659,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { Instruction::FutureLower { .. } => { self.interface_gen.add_future( self.func_name, + index, &generic_type_name, &generic_type_name_with_qualifier, **payload, @@ -1661,6 +1668,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { _ => { self.interface_gen.add_stream( self.func_name, + index, &generic_type_name, &generic_type_name_with_qualifier, **payload, @@ -1693,8 +1701,8 @@ impl Bindgen for FunctionBindgen<'_, '_> { } } - Instruction::FutureLift { payload, ty: _ } - | Instruction::StreamLift { payload, ty: _ } => { + Instruction::FutureLift { payload, ty } | Instruction::StreamLift { payload, ty } => { + let index = self.future_or_stream_index(*ty); let generic_type_name_with_qualifier = match payload { Some(generic_type) => &self .interface_gen @@ -1740,6 +1748,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { Instruction::FutureLift { .. } => { self.interface_gen.add_future( self.func_name, + index, &generic_type_name, &generic_type_name_with_qualifier, **payload, @@ -1748,6 +1757,7 @@ impl Bindgen for FunctionBindgen<'_, '_> { _ => { self.interface_gen.add_stream( self.func_name, + index, &generic_type_name, &generic_type_name_with_qualifier, **payload, diff --git a/crates/csharp/src/interface.rs b/crates/csharp/src/interface.rs index ef8ade04c..12d1450b4 100644 --- a/crates/csharp/src/interface.rs +++ b/crates/csharp/src/interface.rs @@ -46,6 +46,9 @@ impl InterfaceTypeAndFragments { #[derive(Clone)] pub(crate) struct FutureInfo { pub name: String, + /// Position among the function's futures and streams together, as numbered in the + /// `[future-*-N]` / `[stream-*-N]` intrinsic import names. + pub index: usize, pub generic_type_name: String, pub qualified_generic_type_name: String, pub ty: Option, @@ -68,6 +71,31 @@ pub(crate) struct InterfaceGenerator<'a> { } impl InterfaceGenerator<'_> { + /// Name of the class holding this interface's `[DllImport]` declarations, relative to the + /// interface's namespace: `FooImportsInterop` for `IFooImports`. + pub(crate) fn interop_class_name(&self) -> String { + let (_namespace, interface_name) = &CSharp::get_class_name_from_qualified_name(self.name); + let interop_name = format!( + "{}ImportsInterop", + interface_name + .strip_prefix("I") + .unwrap() + .strip_suffix(if self.direction == Direction::Import { + "Imports" + } else { + "Exports" + }) + .unwrap() + .to_upper_camel_case() + ); + + if self.is_world && self.direction == Direction::Import { + format!("Imports.{interop_name}") + } else { + interop_name + } + } + pub fn is_async(kind: &FunctionKind) -> bool { matches!( kind, @@ -244,7 +272,6 @@ impl InterfaceGenerator<'_> { (self.streams.iter().cloned().collect(), ", uint length") }; - let mut index = 0; let mut size = 0; let mut align = 0; for future in futures_or_streams { @@ -269,6 +296,7 @@ impl InterfaceGenerator<'_> { let mut lift_func = "null".to_string(); let mut lower_func = "null".to_string(); let future_name = &future.name; + let index = future.index; let generic_type_name = &future.generic_type_name; let upper_camel_future_type = generic_type_name.to_upper_camel_case(); @@ -396,7 +424,7 @@ impl InterfaceGenerator<'_> { if lift_func != "null" && let Some(payload_type) = canonical_payload { - let (lift, result) = self.lift_from_memory("buffer", &payload_type); + let (lift, result) = self.lift_from_memory("buffer", &payload_type, None); uwrite!( self.csharp_interop_src, @@ -432,7 +460,7 @@ impl InterfaceGenerator<'_> { uwrite!( self.csharp_interop_src, r#" - public static unsafe void {future_stream_name}Lower{upper_camel_future_type}(object toLower, List cleanups) + public static unsafe void {future_stream_name}Lower{upper_camel_future_type}(object toLower, global::System.Collections.Generic.List cleanups) {{ var ptr = InteropReturnArea.returnArea.AddressOfReturnArea(); var typedToLower = ({qualified_generic_type_name})toLower; @@ -465,7 +493,6 @@ impl InterfaceGenerator<'_> { } generated_future_types.insert(canonical_payload); - index = index + 1; } self.csharp_gen.needs_async_support = true; @@ -668,9 +695,10 @@ impl InterfaceGenerator<'_> { let asyncified_type = match &func.kind { FunctionKind::AsyncFreestanding | FunctionKind::AsyncStatic(_) - | FunctionKind::AsyncMethod(_) => match func.result { - None => "Task".to_string(), - Some(_ty) => format!("Task<{base_type}>"), + // A `result` without payloads lowers to `void` too, which `Task<>` cannot wrap. + | FunctionKind::AsyncMethod(_) => match base_type.as_str() { + "void" => "global::System.Threading.Tasks.Task".to_string(), + _ => format!("global::System.Threading.Tasks.Task<{base_type}>"), }, _ => base_type, }; @@ -687,6 +715,7 @@ impl InterfaceGenerator<'_> { results: &[TypeId], parameter_type: ParameterType, ) -> (String, String) { + let interop_name = self.interop_class_name(); let mut bindgen = FunctionBindgen::new( self, func.item_name(), @@ -706,6 +735,7 @@ impl InterfaceGenerator<'_> { parameter_type, func.result, ); + bindgen.futures_and_streams = func.find_futures_and_streams(bindgen.interface_gen.resolve); let async_ = InterfaceGenerator::is_async(&func.kind); let mut src: String; @@ -755,7 +785,7 @@ impl InterfaceGenerator<'_> { (mem::take(&mut bindgen.src), wasm_params) }; let name = func.name.to_upper_camel_case(); - let raw_name = format!("IImportsInterop.{name}WasmInterop.wasmImport{name}"); + let raw_name = format!("{interop_name}.{name}WasmInterop.wasmImport{name}"); let mut wasm_param_refs: Vec<&str> = wasm_params.iter().map(|v| v.as_str()).collect(); @@ -783,7 +813,7 @@ var {async_status_var} = {raw_name}({wasm_params}); let bindgen_src = bindgen.src; if let Some(buffer) = async_return_buffer { let ty = bindgen.result_type.expect("expected a result type"); - let (lift_expr, res) = self.lift_from_memory("address", &ty); + let (lift_expr, res) = self.lift_from_memory("address", &ty, Some(func)); uwriteln!(src, "{}", bindgen_src); let return_type = self.type_name_with_qualifier(&ty, true); @@ -881,6 +911,8 @@ var {async_status_var} = {raw_name}({wasm_params}); func.result, ); + bindgen.futures_and_streams = func.find_futures_and_streams(bindgen.interface_gen.resolve); + let async_ = matches!( func.kind, FunctionKind::AsyncFreestanding @@ -1013,6 +1045,9 @@ var {async_status_var} = {raw_name}({wasm_params}); func.result, ); + bindgen.futures_and_streams = + func.find_futures_and_streams(bindgen.interface_gen.resolve); + abi::post_return(bindgen.interface_gen.resolve, func, &mut bindgen); let src = bindgen.src; @@ -1607,12 +1642,14 @@ var {async_status_var} = {raw_name}({wasm_params}); pub(crate) fn add_future( &mut self, func_name: &str, + index: usize, generic_type_name: &str, qualified_generic_type_name: &str, ty: Option, ) { self.futures.push(FutureInfo { name: func_name.to_string(), + index, generic_type_name: generic_type_name.to_string(), qualified_generic_type_name: qualified_generic_type_name.to_string(), ty: ty, @@ -1622,30 +1659,43 @@ var {async_status_var} = {raw_name}({wasm_params}); pub(crate) fn add_stream( &mut self, func_name: &str, + index: usize, generic_type_name: &str, qualified_generic_type_name: &str, ty: Option, ) { self.streams.push(FutureInfo { name: func_name.to_string(), + index, generic_type_name: generic_type_name.to_string(), qualified_generic_type_name: qualified_generic_type_name.to_string(), ty: ty, }); } - fn lift_from_memory(&mut self, address: &str, ty: &Type) -> (String, String) { + fn lift_from_memory( + &mut self, + address: &str, + ty: &Type, + func: Option<&Function>, + ) -> (String, String) { let boxed: Box<[String]> = Vec::new().into_boxed_slice(); + // Futures and streams in the lifted value are named after, and numbered within, the + // function they belong to. + let futures_and_streams = func + .map(|func| func.find_futures_and_streams(self.resolve)) + .unwrap_or_default(); let mut f = FunctionBindgen::new( self, - "", + func.map_or("", |func| func.item_name()), &FunctionKind::AsyncFreestanding, boxed, vec![], ParameterType::ABI, None, ); + f.futures_and_streams = futures_and_streams; let result = abi::lift_from_memory(f.interface_gen.resolve, &mut f, address.into(), ty); (f.src, result) diff --git a/crates/csharp/src/world_generator.rs b/crates/csharp/src/world_generator.rs index 1a8d9b39a..23340ff7e 100644 --- a/crates/csharp/src/world_generator.rs +++ b/crates/csharp/src/world_generator.rs @@ -434,6 +434,7 @@ impl WorldGenerator for CSharp { using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; + using System.Collections.Generic; " ); uwrite!( diff --git a/tests/runtime/async-export-result/runner.rs b/tests/runtime/async-export-result/runner.rs new file mode 100644 index 000000000..a95540f13 --- /dev/null +++ b/tests/runtime/async-export-result/runner.rs @@ -0,0 +1,18 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +include!(env!("BINDINGS")); + +use crate::my::test::i::*; + +struct Component; + +export!(Component); + +impl Guest for Component { + async fn run() { + assert_eq!(unit_ok().await, Ok(())); + assert_eq!(unit_err().await, Err("unit".to_string())); + assert_eq!(halve(8).await, Ok(4)); + assert_eq!(halve(7).await, Err("odd".to_string())); + } +} diff --git a/tests/runtime/async-export-result/test.cs b/tests/runtime/async-export-result/test.cs new file mode 100644 index 000000000..5fe8f5415 --- /dev/null +++ b/tests/runtime/async-export-result/test.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace TestWorld.wit.Exports.my.test +{ + public class IExportsImpl : IIExports + { + public static async Task UnitOk() + { + await Task.CompletedTask; + } + + public static async Task UnitErr() + { + await Task.CompletedTask; + throw new WitException("unit", 0); + } + + public static async Task Halve(uint x) + { + await Task.CompletedTask; + if (x % 2 != 0) + { + throw new WitException("odd", 0); + } + return x / 2; + } + } +} diff --git a/tests/runtime/async-export-result/test.rs b/tests/runtime/async-export-result/test.rs new file mode 100644 index 000000000..e98de2e2f --- /dev/null +++ b/tests/runtime/async-export-result/test.rs @@ -0,0 +1,22 @@ +include!(env!("BINDINGS")); + +struct Component; + +export!(Component); + +impl crate::exports::my::test::i::Guest for Component { + async fn unit_ok() -> Result<(), ()> { + Ok(()) + } + + async fn unit_err() -> Result<(), String> { + Err("unit".to_string()) + } + + async fn halve(x: u32) -> Result { + if x % 2 != 0 { + return Err("odd".to_string()); + } + Ok(x / 2) + } +} diff --git a/tests/runtime/async-export-result/test.wit b/tests/runtime/async-export-result/test.wit new file mode 100644 index 000000000..6b7900e2e --- /dev/null +++ b/tests/runtime/async-export-result/test.wit @@ -0,0 +1,19 @@ +//@ async = true +package my:test; + +interface i { + // `result` without payloads, which the C# generator once declared as `Task`. + unit-ok: async func() -> result; + unit-err: async func() -> result<_, string>; + halve: async func(x: u32) -> result; +} + +world test { + export i; +} + +world runner { + import i; + + export run: async func(); +} diff --git a/tests/runtime/stream-param-future-result/runner.cs b/tests/runtime/stream-param-future-result/runner.cs new file mode 100644 index 000000000..ad382dea9 --- /dev/null +++ b/tests/runtime/stream-param-future-result/runner.cs @@ -0,0 +1,34 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using RunnerWorld; +using RunnerWorld.wit.Imports.my.test; + +public class RunnerWorldExportsImpl +{ + public static async Task Run() + { + var (rx, tx) = IIImports.StreamNewByte(); + var result = await IIImports.Consume(rx); + var written = await tx.Write([1, 2, 3]); + Debug.Assert(written == 3); + tx.Dispose(); + var sum = await result.Read(); + Debug.Assert(sum.IsOk && sum.AsOk == 6); + + (rx, tx) = IIImports.StreamNewByte(); + result = await IIImports.Consume(rx); + written = await tx.Write([0]); + Debug.Assert(written == 1); + tx.Dispose(); + var zero = await result.Read(); + Debug.Assert(zero.IsErr && zero.AsErr == "zero"); + } + + public static int RunCallback() + { + throw new NotImplementedException(); + } +} diff --git a/tests/runtime/stream-param-future-result/runner.rs b/tests/runtime/stream-param-future-result/runner.rs new file mode 100644 index 000000000..f8ca96cbf --- /dev/null +++ b/tests/runtime/stream-param-future-result/runner.rs @@ -0,0 +1,28 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +include!(env!("BINDINGS")); + +use crate::my::test::i::*; +use wit_bindgen::StreamResult; + +struct Component; + +export!(Component); + +impl Guest for Component { + async fn run() { + let (mut tx, rx) = wit_stream::new(); + let result = consume(rx).await; + let (written, _) = tx.write(vec![1, 2, 3]).await; + assert_eq!(written, StreamResult::Complete(3)); + drop(tx); + assert_eq!(result.await, Ok(6)); + + let (mut tx, rx) = wit_stream::new(); + let result = consume(rx).await; + let (written, _) = tx.write(vec![0]).await; + assert_eq!(written, StreamResult::Complete(1)); + drop(tx); + assert_eq!(result.await, Err("zero".to_string())); + } +} diff --git a/tests/runtime/stream-param-future-result/test.rs b/tests/runtime/stream-param-future-result/test.rs new file mode 100644 index 000000000..b4c6dd3d5 --- /dev/null +++ b/tests/runtime/stream-param-future-result/test.rs @@ -0,0 +1,23 @@ +use wit_bindgen::{FutureReader, StreamReader}; + +include!(env!("BINDINGS")); + +struct Component; + +export!(Component); + +impl crate::exports::my::test::i::Guest for Component { + async fn consume(data: StreamReader) -> FutureReader> { + let (tx, rx) = wit_future::new(|| unreachable!()); + wit_bindgen::spawn_local(async move { + let bytes = data.collect().await; + let result = if bytes.contains(&0) { + Err("zero".to_string()) + } else { + Ok(bytes.iter().map(|b| u32::from(*b)).sum()) + }; + tx.write(result).await.unwrap(); + }); + rx + } +} diff --git a/tests/runtime/stream-param-future-result/test.wit b/tests/runtime/stream-param-future-result/test.wit new file mode 100644 index 000000000..2d55c9fb2 --- /dev/null +++ b/tests/runtime/stream-param-future-result/test.wit @@ -0,0 +1,22 @@ +//@ async = true +package my:test; + +interface i { + // Like `wasi:cli/stdout.write-via-stream`: a stream parameter and a future result. It is async + // so the export can keep reading after it returns the future. + // Futures and streams share one index space in the canonical ABI, so the stream is 0 and the + // future is 1 in this function's intrinsic import names. + // + // Sums the bytes read from `data` once it closes, or fails if one of them is zero. + consume: async func(data: stream) -> future>; +} + +world test { + export i; +} + +world runner { + import i; + + export run: async func(); +} From e2e1bc508eca2d80c67c143a2a75e61a7e15e5de Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Fri, 16 Oct 2026 08:01:53 +1300 Subject: [PATCH 3/4] csharp: fix async import result buffers and stream value lifting - Size the buffer an async import writes its result into from the result type's canonical ABI layout. It was 4 bytes for any result, so a string or record result overran it, and larger flat results hit a todo!() in the generator. - Free that buffer with AlignedFree once the call's task completes. It was freed with Free right after starting the call, so a call that was still pending wrote and lifted its result from freed memory. - Don't throw from LiftingTaskCompletionSource after setting a result. - Pass the number of values read to a stream's Lift and lift each of them, instead of only the first. - Release a primitive stream read's pinned buffer after a successful read; it took the faulted branch and skipped its cleanups. Adds a runtime test with async imports that return a string and a record while still pending, and a multi-value stream read. --- crates/csharp/src/AsyncSupport.cs | 21 ++++++---- crates/csharp/src/function.rs | 41 ++++--------------- crates/csharp/src/interface.rs | 28 +++++++------ tests/runtime/async-import-results/runner.cs | 42 ++++++++++++++++++++ tests/runtime/async-import-results/runner.rs | 22 ++++++++++ tests/runtime/async-import-results/test.rs | 34 ++++++++++++++++ tests/runtime/async-import-results/test.wit | 28 +++++++++++++ 7 files changed, 161 insertions(+), 55 deletions(-) create mode 100644 tests/runtime/async-import-results/runner.cs create mode 100644 tests/runtime/async-import-results/runner.rs create mode 100644 tests/runtime/async-import-results/test.rs create mode 100644 tests/runtime/async-import-results/test.wit diff --git a/crates/csharp/src/AsyncSupport.cs b/crates/csharp/src/AsyncSupport.cs index 3dfc676e5..def30c7da 100644 --- a/crates/csharp/src/AsyncSupport.cs +++ b/crates/csharp/src/AsyncSupport.cs @@ -279,7 +279,10 @@ internal LiftingTaskCompletionSource(TaskCompletionSource innerTaskCompleti { SetCanceled(); } - throw new NotImplementedException("LiftingTaskCompletionSource unexpected task status " + t.Status); + else + { + throw new NotImplementedException("LiftingTaskCompletionSource unexpected task status " + t.Status); + } }, TaskContinuationOptions.ExecuteSynchronously); } } @@ -306,7 +309,9 @@ internal LiftingTaskCompletionSource(TaskCompletionSource innerTaskCompleti public delegate uint StreamWrite(int handle, IntPtr buffer, uint length); public delegate uint StreamRead(int handle, IntPtr buffer, uint length); -public delegate Array Lift(IntPtr buffer, Array? resultBuffer); +/// Lifts the first values from canonical ABI memory at +/// into . +public delegate Array Lift(IntPtr buffer, Array resultBuffer, int count); public delegate void Lower(object payload, List cleanups); public delegate uint CancelRead(int handle); public delegate uint CancelWrite(int handle); @@ -505,17 +510,17 @@ protected unsafe IntPtr GetBuffer(int length, T[]? userBuffer, IVTable vTable } } + // Lifts one future value. Primitives were read into the pinned array GetBuffer returned, so copy + // that value; anything else goes through the vtable's Lift. protected unsafe T[] LiftBuffer(IntPtr buffer, T[] resultBuffer, Lift? liftFunc) { - // Primitives were read straight into the pinned array; see GetBuffer. if (typeof(T).IsPrimitive) { - // TODO array length > 1 resultBuffer[0] = *(T*)buffer; } else { - liftFunc(buffer, resultBuffer); + liftFunc!(buffer, resultBuffer, 1); } return resultBuffer; @@ -714,10 +719,10 @@ public ComponentTask Read(T[] resultBuffer) var task = ReadInternal(buf, resultBuffer.Length, VTable); task.ContinueWith(it => { - // Primitives were read straight into resultBuffer, and have no Lift. - if (it.IsCompletedSuccessfully && VTable.Lift != null) + if (it.IsCompletedSuccessfully) { - VTable.Lift(buf, resultBuffer); + // Primitives were read straight into resultBuffer, and have no Lift. + VTable.Lift?.Invoke(buf, resultBuffer, it.Result); } else if (!it.IsCanceled) { diff --git a/crates/csharp/src/function.rs b/crates/csharp/src/function.rs index ed564bcad..76a1c8604 100644 --- a/crates/csharp/src/function.rs +++ b/crates/csharp/src/function.rs @@ -401,46 +401,19 @@ impl<'a, 'b> FunctionBindgen<'a, 'b> { ret } - pub fn emit_allocation_for_type(&mut self, results: &[WasmType]) -> String { + /// Allocates native memory for an async import to write its result into, sized and aligned + /// for the result type in the canonical ABI. The caller frees it once the call completes. + pub fn emit_allocation_for_type(&mut self, ty: &Type) -> String { let address = self.locals.tmp("address"); - let buffer_size = self.get_size_for_type(results); - let align = self.get_align_for_type(results); + // TODO: Wasm64 + let size = self.interface_gen.csharp_gen.sizes.size(ty).size_wasm32(); + let align = self.interface_gen.csharp_gen.sizes.align(ty).align_wasm32(); uwriteln!( self.src, - "void* {address} = global::System.Runtime.InteropServices.NativeMemory.AlignedAlloc({buffer_size}, {align});" + "void* {address} = global::System.Runtime.InteropServices.NativeMemory.AlignedAlloc({size}, {align});" ); - - // TODO: Store the address somewhere so we can free it when the task completes. address } - - fn get_size_for_type(&self, results: &[WasmType]) -> usize { - match results { - [WasmType::I32] => 4, - [WasmType::I64] => 8, - [WasmType::F32] => 4, - [WasmType::F64] => 8, - [WasmType::Pointer, WasmType::Length] => 4, // TODO: Wasm64 - [WasmType::PointerOrI64, WasmType::Length] => 8, - _ => { - todo!("other types not yet supported"); - } - } - } - - fn get_align_for_type(&self, results: &[WasmType]) -> usize { - match results { - [WasmType::I32] => 4, - [WasmType::I64] => 8, - [WasmType::F32] => 4, - [WasmType::F64] => 8, - [WasmType::Pointer, WasmType::Length] => 4, // TODO: Wasm64 - [WasmType::PointerOrI64, WasmType::Length] => 8, - _ => { - todo!("other types not yet supported"); - } - } - } } impl Bindgen for FunctionBindgen<'_, '_> { diff --git a/crates/csharp/src/interface.rs b/crates/csharp/src/interface.rs index 12d1450b4..48b6bab2d 100644 --- a/crates/csharp/src/interface.rs +++ b/crates/csharp/src/interface.rs @@ -424,17 +424,23 @@ impl InterfaceGenerator<'_> { if lift_func != "null" && let Some(payload_type) = canonical_payload { - let (lift, result) = self.lift_from_memory("buffer", &payload_type, None); + let (lift, result) = + self.lift_from_memory("elementAddress", &payload_type, None); + // Values are laid out like a list: `size` is already a multiple of the + // alignment, so it is also the stride. uwrite!( self.csharp_interop_src, r#" - public static unsafe Array {future_stream_name}Lift{upper_camel_future_type}(IntPtr buffer, Array resultBuffer) + public static unsafe Array {future_stream_name}Lift{upper_camel_future_type}(IntPtr buffer, Array resultBuffer, int count) {{ - {lift} + for (int i = 0; i < count; i++) + {{ + IntPtr elementAddress = buffer + i * {size}; + {lift} - // TODO: length > 1 - resultBuffer.SetValue({result}, 0); + resultBuffer.SetValue({result}, i); + }} return resultBuffer; }} "# @@ -751,12 +757,7 @@ impl InterfaceGenerator<'_> { let async_status_var = "result"; // String::new(); let requires_async_return_buffer_param = func.result.is_some(); let async_return_buffer = if requires_async_return_buffer_param { - let buffer = bindgen.emit_allocation_for_type(&sig.results); - uwriteln!( - bindgen.src, - "//TODO: store somewhere with the TaskCompletionSource, possibly in the state, using Task.AsyncState to retrieve it later." - ); - Some(buffer) + Some(bindgen.emit_allocation_for_type(&func.result.unwrap())) } else { None }; @@ -830,10 +831,11 @@ var {async_status_var} = {raw_name}({wasm_params}); var task = AsyncSupport.TaskFromStatus<{return_type}>({async_status_var}, {});", lift_func ); + // The callee writes the result into the buffer when it returns, which may be after + // this method has returned, so free it only once the task has completed (and lifted). uwriteln!( src, - "global::System.Runtime.InteropServices.NativeMemory.Free({});", - buffer + "task.ContinueWith(_ => global::System.Runtime.InteropServices.NativeMemory.AlignedFree({buffer}), global::System.Threading.Tasks.TaskContinuationOptions.ExecuteSynchronously);" ); uwriteln!(src, "return task;"); } else { diff --git a/tests/runtime/async-import-results/runner.cs b/tests/runtime/async-import-results/runner.cs new file mode 100644 index 000000000..ae4220c6e --- /dev/null +++ b/tests/runtime/async-import-results/runner.cs @@ -0,0 +1,42 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using RunnerWorld; +using RunnerWorld.wit.Imports.my.test; + +public class RunnerWorldExportsImpl +{ + public static async Task Run() + { + var echoed = await IIImports.Echo("hi"); + Debug.Assert(echoed == "hi!"); + + var p = await IIImports.MakePoint(3, 4); + Debug.Assert(p.x == 3 && p.y == 4 && p.label == "(3, 4)"); + + // Read with room for every element, so one read can lift several values. + string[] all = ["a", "bb", "ccc"]; + var stream = await IIImports.Words(new List(all)); + var received = new List(); + var buffer = new string[all.Length]; + while (received.Count < all.Length) + { + var read = await stream.Read(buffer); + Debug.Assert(read > 0); + for (var i = 0; i < read; i++) + { + received.Add(buffer[i]); + } + } + Debug.Assert(string.Join(",", received) == "a,bb,ccc"); + stream.Dispose(); + } + + public static int RunCallback() + { + throw new NotImplementedException(); + } +} diff --git a/tests/runtime/async-import-results/runner.rs b/tests/runtime/async-import-results/runner.rs new file mode 100644 index 000000000..8b053c0ab --- /dev/null +++ b/tests/runtime/async-import-results/runner.rs @@ -0,0 +1,22 @@ +//@ wasmtime-flags = '-Wcomponent-model-async' + +include!(env!("BINDINGS")); + +use crate::my::test::i::*; + +struct Component; + +export!(Component); + +impl Guest for Component { + async fn run() { + assert_eq!(echo("hi".to_string()).await, "hi!"); + + let p = make_point(3, 4).await; + assert_eq!((p.x, p.y, p.label.as_str()), (3, 4, "(3, 4)")); + + let all = vec!["a".to_string(), "bb".to_string(), "ccc".to_string()]; + let rx = words(all.clone()).await; + assert_eq!(rx.collect().await, all); + } +} diff --git a/tests/runtime/async-import-results/test.rs b/tests/runtime/async-import-results/test.rs new file mode 100644 index 000000000..f08eaf909 --- /dev/null +++ b/tests/runtime/async-import-results/test.rs @@ -0,0 +1,34 @@ +use wit_bindgen::StreamReader; + +include!(env!("BINDINGS")); + +use crate::exports::my::test::i::Point; + +struct Component; + +export!(Component); + +impl crate::exports::my::test::i::Guest for Component { + async fn echo(s: String) -> String { + wit_bindgen::yield_async().await; + s + "!" + } + + async fn make_point(x: u32, y: u32) -> Point { + wit_bindgen::yield_async().await; + Point { + x, + y, + label: format!("({x}, {y})"), + } + } + + async fn words(all: Vec) -> StreamReader { + let (mut tx, rx) = wit_stream::new(); + wit_bindgen::spawn_local(async move { + let remaining = tx.write_all(all).await; + assert!(remaining.is_empty()); + }); + rx + } +} diff --git a/tests/runtime/async-import-results/test.wit b/tests/runtime/async-import-results/test.wit new file mode 100644 index 000000000..cce78febb --- /dev/null +++ b/tests/runtime/async-import-results/test.wit @@ -0,0 +1,28 @@ +//@ async = true +package my:test; + +interface i { + record point { + x: u32, + y: u32, + label: string, + } + + // Each returns after yielding, so the caller sees the call pending and reads the result from + // its return buffer later. + echo: async func(s: string) -> string; + make-point: async func(x: u32, y: u32) -> point; + + // Writes the strings it was given to the returned stream. + words: async func(all: list) -> stream; +} + +world test { + export i; +} + +world runner { + import i; + + export run: async func(); +} From 26820bdd9973781b9de3ebe5df66422e52235b15 Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Fri, 16 Oct 2026 08:01:53 +1300 Subject: [PATCH 4/4] ci: re-enable C# testing C# was removed from the test matrix when moving it from wasi-sdk 29 to 34, whose linker rejected the ILCompiler's memory layout; the test harness now links with --no-stack-first. Also install .NET 10, which the generated test projects target. Fix the one C# codegen test failure, issue1703.wit, added while C# was out of CI: a variant case named `tag` or `tags` generated a factory method clashing with the variant's `Tag` field or `Tags` class. Those cases now get the same `_` suffix as a case named after its variant. --- .github/workflows/main.yml | 5 ++--- crates/csharp/src/interface.rs | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c3465c6a4..8d8ccf5de 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -79,8 +79,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] # moonbit removed from language matrix for now - causing CI failures - # csharp removed from language matrix for now - causing CI failures - lang: [c, rust, cpp, go, d] + lang: [c, rust, csharp, cpp, go, d] exclude: # For now csharp doesn't work on macos, so exclude it from testing. - os: macos-latest @@ -102,7 +101,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.x' + dotnet-version: '10.x' if: matrix.lang == 'csharp' # As of this writing async tests require [a patched build of diff --git a/crates/csharp/src/interface.rs b/crates/csharp/src/interface.rs index 48b6bab2d..594d50f06 100644 --- a/crates/csharp/src/interface.rs +++ b/crates/csharp/src/interface.rs @@ -2048,9 +2048,10 @@ fn needs_ptr(ty: &Type) -> bool { } } -// Handles the tag being the same name as the variant, which would cause a method with the same name as the type in C# which is not valid. +// Names the factory method for a variant case. A method can't share its name with the variant type, +// or with the `Tag` field and `Tags` class every variant has, so those cases get a suffix. pub fn variant_new_func_name(variant_name: &String, tag: &String) -> String { - if *tag == *variant_name { + if *tag == *variant_name || tag == "Tag" || tag == "Tags" { format!("{tag}_") // Underscores are not valid in wit identifiers so this should be safe. } else { tag.clone()