From 20b36e42e016bdf47e6b12f9c11c479534127709 Mon Sep 17 00:00:00 2001 From: Martin Evans Date: Sun, 17 Aug 2025 21:41:26 +0100 Subject: [PATCH 1/4] Initial sketching out what's required for wasm component support --- src/Components/Component.cs | 189 ++++++++++++++++++++++++++++ src/Components/ComponentExport.cs | 57 +++++++++ src/Components/ComponentFunction.cs | 19 +++ src/Components/ComponentInstance.cs | 16 +++ src/Components/ComponentLinker.cs | 75 +++++++++++ src/Components/ComponentValue.cs | 130 +++++++++++++++++++ src/Module.cs | 33 +++-- src/Wasmtime.csproj | 21 +--- 8 files changed, 510 insertions(+), 30 deletions(-) create mode 100644 src/Components/Component.cs create mode 100644 src/Components/ComponentExport.cs create mode 100644 src/Components/ComponentFunction.cs create mode 100644 src/Components/ComponentInstance.cs create mode 100644 src/Components/ComponentLinker.cs create mode 100644 src/Components/ComponentValue.cs diff --git a/src/Components/Component.cs b/src/Components/Component.cs new file mode 100644 index 00000000..1dfcd573 --- /dev/null +++ b/src/Components/Component.cs @@ -0,0 +1,189 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; + +namespace Wasmtime.Components; + +/// +/// Representation of a component in the component model. +/// +public class Component + : IDisposable +{ + private readonly Handle handle; + + internal Handle NativeHandle + { + get + { + if (handle.IsInvalid || handle.IsClosed) + { + throw new ObjectDisposedException(typeof(Module).FullName); + } + + return handle; + } + } + + internal Component(IntPtr handle) + { + this.handle = new Handle(handle); + } + + /// + public void Dispose() + { + handle.Dispose(); + } + + /// + /// Creates a given bytes. + /// + /// The engine to use for the Component. + /// The bytes of the Component. + /// Returns a new . + public static Component FromBytes(Engine engine, ReadOnlySpan bytes) + { + if (engine is null) + { + throw new ArgumentNullException(nameof(engine)); + } + + unsafe + { + fixed (byte* ptr = bytes) + { + var error = Native.wasmtime_component_new(engine.NativeHandle, ptr, (UIntPtr)bytes.Length, out var handle); + if (error != IntPtr.Zero) + { + throw new WasmtimeException($"WebAssembly component is not valid: {WasmtimeException.FromOwnedError(error).Message}"); + } + + return new Component(handle); + } + } + } + + /// + /// This function serializes compiled component artifacts as blob data. + /// + /// If the conversion is successful, the serialized compiled component. + public byte[] Serialize() + { + var error = Native.wasmtime_component_serialize(NativeHandle, out var bytes); + if (error != IntPtr.Zero) + { + throw WasmtimeException.FromOwnedError(error); + } + + using (bytes) + return bytes.ToArray(); + } + + /// + /// Deserializes a previously serialized component from a span of bytes. + /// + /// The engine to use to deserialize the component. + /// The previously serialized component bytes. + /// Returns the that was previously serialized. + /// The passed bytes must come from a previous call to . + public static Component Deserialize(Engine engine, ReadOnlySpan bytes) + { + if (engine is null) + { + throw new ArgumentNullException(nameof(engine)); + } + + unsafe + { + fixed (byte* ptr = bytes) + { + var error = Native.wasmtime_component_deserialize(engine.NativeHandle, ptr, (UIntPtr)bytes.Length, out var handle); + if (error != IntPtr.Zero) + { + throw WasmtimeException.FromOwnedError(error); + } + + return new Component(handle); + } + } + } + + /// + /// Deserializes a previously serialized component from a file. + /// + /// The engine to deserialize the component with. + /// The path to the previously serialized component. + /// Returns the that was previously serialized. + /// The file's contents must come from a previous call to . + public static Component DeserializeFile(Engine engine, string path) + { + if (engine is null) + { + throw new ArgumentNullException(nameof(engine)); + } + + var error = Native.wasmtime_component_deserialize_file(engine.NativeHandle, path, out var handle); + if (error != IntPtr.Zero) + { + throw WasmtimeException.FromOwnedError(error); + } + + return new Component(handle); + } + + public ComponentExport? GetExport(string name) + { + var ret = Native.wasmtime_component_get_export_index(NativeHandle, null, name, (nuint)name.Length); + if (ret == IntPtr.Zero) + return null; + + return new ComponentExport(ret); + } + + public ComponentExport? GetExport(string name, ComponentExport instance_export_index) + { + var ret = Native.wasmtime_component_get_export_index(NativeHandle, instance_export_index.NativeHandle, name, (nuint)name.Length); + if (ret == IntPtr.Zero) + return null; + + return new ComponentExport(ret); + } + + internal class Handle + : SafeHandleZeroOrMinusOneIsInvalid + { + public Handle(IntPtr handle) + : base(true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + Native.wasmtime_component_delete(handle); + return true; + } + } + + internal static class Native + { + [DllImport(Engine.LibraryName)] + public static extern unsafe IntPtr wasmtime_component_new(Engine.Handle engine, byte* bytes, nuint size, out IntPtr handle); + + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_delete(IntPtr handle); + + [DllImport(Engine.LibraryName)] + public static extern IntPtr wasmtime_component_serialize(Handle component, out ByteArray ret); + + [DllImport(Engine.LibraryName)] + public static extern unsafe IntPtr wasmtime_component_deserialize(Engine.Handle engine, byte* bytes, nuint size, out IntPtr handle); + + [DllImport(Engine.LibraryName)] + public static extern IntPtr wasmtime_component_deserialize_file(Engine.Handle engine, string path, out IntPtr handle); + + [DllImport(Engine.LibraryName)] + public static extern IntPtr wasmtime_component_get_export_index(Handle component, ComponentExport.Handle? instance_export_index, string name, nuint name_len); + } +} \ No newline at end of file diff --git a/src/Components/ComponentExport.cs b/src/Components/ComponentExport.cs new file mode 100644 index 00000000..4fb03259 --- /dev/null +++ b/src/Components/ComponentExport.cs @@ -0,0 +1,57 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; + +namespace Wasmtime.Components; + +public class ComponentExport + : IDisposable +{ + private readonly Handle handle; + + internal Handle NativeHandle + { + get + { + if (handle.IsInvalid || handle.IsClosed) + { + throw new ObjectDisposedException(typeof(Module).FullName); + } + + return handle; + } + } + + internal ComponentExport(IntPtr handle) + { + this.handle = new Handle(handle); + } + + /// + public void Dispose() + { + handle.Dispose(); + } + + internal class Handle + : SafeHandleZeroOrMinusOneIsInvalid + { + public Handle(IntPtr handle) + : base(true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + Native.wasmtime_component_export_index_delete(handle); + return true; + } + } + + internal static class Native + { + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_export_index_delete(IntPtr /* wasmtime_component_export_index_t* */ export_index); + } +} \ No newline at end of file diff --git a/src/Components/ComponentFunction.cs b/src/Components/ComponentFunction.cs new file mode 100644 index 00000000..7e390e0d --- /dev/null +++ b/src/Components/ComponentFunction.cs @@ -0,0 +1,19 @@ +namespace Wasmtime.Components; + +/// +/// Represents a Wasmtime function. +/// +public class ComponentFunction +{ + //todo: everything! + + + internal static class Native + { + // [DllImport(Engine.LibraryName)] + //todo: wasmtime_error_t * wasmtime_component_func_call (const wasmtime_component_func_t *func, wasmtime_context_t *context, const wasmtime_component_val_t *args, size_t args_size, wasmtime_component_val_t *results, size_t results_size) + + // [DllImport(Engine.LibraryName)] + //todo: wasmtime_error_t * wasmtime_component_func_post_return (const wasmtime_component_func_t *func, wasmtime_context_t *context) + } +} \ No newline at end of file diff --git a/src/Components/ComponentInstance.cs b/src/Components/ComponentInstance.cs new file mode 100644 index 00000000..00f19632 --- /dev/null +++ b/src/Components/ComponentInstance.cs @@ -0,0 +1,16 @@ +namespace Wasmtime.Components; + +public class ComponentInstance +{ + //todo: everything! + + + internal static class Native + { + //[DllImport(Engine.LibraryName)] + //public static extern IntPtr /* wasmtime_component_export_index_t* */ wasmtime_component_instance_get_export_index (wasmtime_component_instance_t *instance, wasmtime_context_t *context, ComponentExport.Handle instance_export_index, string name, nuint name_len) + + // [DllImport(Engine.LibraryName)] + //todo: bool wasmtime_component_instance_get_func (const wasmtime_component_instance_t *instance, wasmtime_context_t *context, const wasmtime_component_export_index_t *export_index, wasmtime_component_func_t *func_out) + } +} \ No newline at end of file diff --git a/src/Components/ComponentLinker.cs b/src/Components/ComponentLinker.cs new file mode 100644 index 00000000..7f3047e3 --- /dev/null +++ b/src/Components/ComponentLinker.cs @@ -0,0 +1,75 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; + +namespace Wasmtime.Components; + +public class ComponentLinker + : IDisposable +{ + private readonly Handle handle; + + internal Handle NativeHandle + { + get + { + if (handle.IsInvalid || handle.IsClosed) + { + throw new ObjectDisposedException(typeof(Module).FullName); + } + + return handle; + } + } + + internal ComponentLinker(IntPtr handle) + { + this.handle = new Handle(handle); + } + + /// + public void Dispose() + { + handle.Dispose(); + } + + internal class Handle + : SafeHandleZeroOrMinusOneIsInvalid + { + public Handle(IntPtr handle) + : base(true) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() + { + Native.wasmtime_component_linker_delete(handle); + return true; + } + } + + internal static class Native + { + // [DllImport(Engine.LibraryName)] + //todo: wasmtime_component_linker_t * wasmtime_component_linker_new (const wasm_engine_t *engine) + + // [DllImport(Engine.LibraryName)] + //todo: wasmtime_component_linker_instance_t * wasmtime_component_linker_root (wasmtime_component_linker_t *linker) + + // [DllImport(Engine.LibraryName)] + //todo: wasmtime_error_t * wasmtime_component_linker_instantiate (const wasmtime_component_linker_t *linker, wasmtime_context_t *context, const wasmtime_component_t *component, wasmtime_component_instance_t *instance_out) + + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_linker_delete(IntPtr /* wasmtime_component_linker_t* */ linker); + + //todo: wasmtime_error_t * wasmtime_component_linker_instance_add_instance (wasmtime_component_linker_instance_t *linker_instance, const char *name, size_t name_len, wasmtime_component_linker_instance_t **linker_instance_out) + //todo: wasmtime_error_t* wasmtime_component_linker_instance_add_module(wasmtime_component_linker_instance_t* linker_instance, const char* name, size_t name_len, const wasmtime_module_t* module) + //todo: wasmtime_error_t * wasmtime_component_linker_instance_add_func (wasmtime_component_linker_instance_t *linker_instance, const char *name, size_t name_len, wasmtime_component_func_callback_t callback, void *data, void(*finalizer)(void *)) + //todo: wasmtime_error_t * wasmtime_component_linker_add_wasip2 (wasmtime_component_linker_t *linker) + + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_linker_instance_delete(IntPtr /* wasmtime_component_linker_instance_t* */ linker_instance); + + } +} \ No newline at end of file diff --git a/src/Components/ComponentValue.cs b/src/Components/ComponentValue.cs new file mode 100644 index 00000000..c4b8f440 --- /dev/null +++ b/src/Components/ComponentValue.cs @@ -0,0 +1,130 @@ +using System.Drawing; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Wasmtime.Components; + +// todo: everything here: https://docs.wasmtime.dev/c-api/component_2val_8h.html +/* + wasmtime_component_vallist + A vec of a struct wasmtime_component_val + + wasmtime_component_valrecord + A vec of a struct wasmtime_component_valrecord_entry + + wasmtime_component_valtuple + A vec of a struct wasmtime_component_val + + wasmtime_component_valflags + A vec of a wasm_name_t + + wasmtime_component_valvariant_t + Represents a variant type + + wasmtime_component_valresult_t + Represents a result type + + wasmtime_component_valunion_t + Represents possible runtime values which a component function can either consume or produce + + wasmtime_component_val + Represents possible runtime values which a component function can either consume or produce + + wasmtime_component_valrecord_entry + A pair of a name and a value that represents one entry in a value with kind WASMTIME_COMPONENT_RECORD +*/ + +internal enum ComponentValueKind +{ + Bool = 0, + S8 = 1, + U8 = 2, + S16 = 3, + U16 = 4, + S32 = 5, + U32 = 6, + S64 = 7, + U64 = 8, + F32 = 9, + F64 = 10, + Char = 11, + String = 12, + List = 13, + Record = 14, + Tuple = 15, + Variant = 16, + Enum = 17, + Option = 18, + Result = 19, + Flags = 20, +} + +internal static class ComponentValueNative +{ + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_vallist_new(wasmtime_component_vallist_t*out, size_t size, struct wasmtime_component_val * ptr) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_vallist_new_empty (wasmtime_component_vallist_t*out) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_vallist_new_uninit (wasmtime_component_vallist_t*out, size_t size) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_vallist_copy (wasmtime_component_vallist_t* dst, const wasmtime_component_vallist_t* src) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_vallist_delete (wasmtime_component_vallist_t* value) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valrecord_new (wasmtime_component_valrecord_t*out, size_t size, struct wasmtime_component_valrecord_entry * ptr) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valrecord_new_empty (wasmtime_component_valrecord_t*out) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valrecord_new_uninit (wasmtime_component_valrecord_t*out, size_t size) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valrecord_copy (wasmtime_component_valrecord_t* dst, const wasmtime_component_valrecord_t* src) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valrecord_delete (wasmtime_component_valrecord_t* value) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valtuple_new (wasmtime_component_valtuple_t*out, size_t size, struct wasmtime_component_val * ptr) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valtuple_new_empty (wasmtime_component_valtuple_t*out) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valtuple_new_uninit (wasmtime_component_valtuple_t*out, size_t size) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valtuple_copy (wasmtime_component_valtuple_t* dst, const wasmtime_component_valtuple_t* src) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valtuple_delete (wasmtime_component_valtuple_t* value) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valflags_new (wasmtime_component_valflags_t*out, size_t size, wasm_name_t* ptr) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valflags_new_empty (wasmtime_component_valflags_t*out) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valflags_new_uninit (wasmtime_component_valflags_t*out, size_t size) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valflags_copy (wasmtime_component_valflags_t* dst, const wasmtime_component_valflags_t* src) + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_valflags_delete (wasmtime_component_valflags_t* value) + + //[DllImport(Engine.LibraryName)] + //public static extern wasmtime_component_val_t * wasmtime_component_val_new () + + //[DllImport(Engine.LibraryName)] + //public static extern void wasmtime_component_val_delete(wasmtime_component_val_t* value) +} \ No newline at end of file diff --git a/src/Module.cs b/src/Module.cs index 421558e7..6df18def 100644 --- a/src/Module.cs +++ b/src/Module.cs @@ -1,12 +1,15 @@ +using Microsoft.Win32.SafeHandles; using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Text; -using Microsoft.Win32.SafeHandles; namespace Wasmtime { + /// + /// Equivalent to wasm_byte_vec_t + /// [StructLayout(LayoutKind.Sequential)] internal unsafe struct ByteArray : IDisposable { @@ -18,6 +21,20 @@ public void Dispose() Native.wasm_byte_vec_delete(this); } + public Span AsSpan() + { + return new Span(data, checked((int)size)); + } + + public byte[] ToArray() + { + var src = AsSpan(); + var dst = new byte[src.Length]; + src.CopyTo(dst); + + return dst; + } + private static class Native { [DllImport(Engine.LibraryName)] @@ -236,22 +253,14 @@ public static Module FromTextStream(Engine engine, string name, Stream stream) /// Returns the serialized module as an array of bytes. public byte[] Serialize() { - var error = Native.wasmtime_module_serialize(this.handle, out var array); + var error = Native.wasmtime_module_serialize(handle, out var bytes); if (error != IntPtr.Zero) { throw WasmtimeException.FromOwnedError(error); } - using (array) - { - var len = checked((int)array.size); - var bytes = new byte[len]; - unsafe - { - Marshal.Copy((IntPtr)array.data, bytes, 0, len); - } - return bytes; - } + using (bytes) + return bytes.ToArray(); } /// diff --git a/src/Wasmtime.csproj b/src/Wasmtime.csproj index b88a7d93..34702165 100644 --- a/src/Wasmtime.csproj +++ b/src/Wasmtime.csproj @@ -157,26 +157,11 @@ The .NET embedding of Wasmtime enables .NET code to instantiate WebAssembly modu - + - - + + From 49ea67e983308dc0eabb8c4fd765d603b30afb9d Mon Sep 17 00:00:00 2001 From: Martin Evans Date: Sun, 17 Aug 2025 21:43:31 +0100 Subject: [PATCH 2/4] Ignoring user settings --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 09fc0eab..c3f91418 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ bin/ obj/ BenchmarkDotNet.Artifacts/ +/Wasmtime.sln.DotSettings.user From 6c2fb31b2360ac0f7dbcbdd7de4d79876263aac6 Mon Sep 17 00:00:00 2001 From: Martin Evans Date: Wed, 20 Aug 2025 16:34:25 +0100 Subject: [PATCH 3/4] Remoed unnecessary imports --- src/Components/ComponentValue.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Components/ComponentValue.cs b/src/Components/ComponentValue.cs index c4b8f440..12d3ece2 100644 --- a/src/Components/ComponentValue.cs +++ b/src/Components/ComponentValue.cs @@ -1,9 +1,4 @@ -using System.Drawing; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using static System.Runtime.InteropServices.JavaScript.JSType; - -namespace Wasmtime.Components; +namespace Wasmtime.Components; // todo: everything here: https://docs.wasmtime.dev/c-api/component_2val_8h.html /* From 0b3382dc1800f7ad8afe97115df4aae85591f30d Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Thu, 17 Sep 2026 14:40:19 +1200 Subject: [PATCH 4/4] Implement ComponentValueMarshaller for native encoding and decoding of component values - Added ComponentValueMarshaller class to handle conversion between ComponentValue and native wasmtime_component_val_t. - Implemented allocation management for native memory in AllocationScope. - Created methods for writing and reading various component value types, including scalars, strings, lists, tuples, records, options, and results. - Introduced tests for validating the layout and round-trip encoding of component values against Wasmtime's definitions. - Added comprehensive unit tests to ensure correct behavior of the marshaller and its handling of different component value types. --- src/Components/ComponentValue.cs | 555 +++++++++++++++++---- src/Components/ComponentValueMarshaller.cs | 372 ++++++++++++++ tests/ComponentValueLayoutTests.cs | 300 +++++++++++ tests/ComponentValueTests.cs | 443 ++++++++++++++++ 4 files changed, 1572 insertions(+), 98 deletions(-) create mode 100644 src/Components/ComponentValueMarshaller.cs create mode 100644 tests/ComponentValueLayoutTests.cs create mode 100644 tests/ComponentValueTests.cs diff --git a/src/Components/ComponentValue.cs b/src/Components/ComponentValue.cs index 12d3ece2..3b4eee7f 100644 --- a/src/Components/ComponentValue.cs +++ b/src/Components/ComponentValue.cs @@ -1,125 +1,484 @@ -namespace Wasmtime.Components; - -// todo: everything here: https://docs.wasmtime.dev/c-api/component_2val_8h.html -/* - wasmtime_component_vallist - A vec of a struct wasmtime_component_val - - wasmtime_component_valrecord - A vec of a struct wasmtime_component_valrecord_entry - - wasmtime_component_valtuple - A vec of a struct wasmtime_component_val - - wasmtime_component_valflags - A vec of a wasm_name_t - - wasmtime_component_valvariant_t - Represents a variant type - - wasmtime_component_valresult_t - Represents a result type - - wasmtime_component_valunion_t - Represents possible runtime values which a component function can either consume or produce - - wasmtime_component_val - Represents possible runtime values which a component function can either consume or produce - - wasmtime_component_valrecord_entry - A pair of a name and a value that represents one entry in a value with kind WASMTIME_COMPONENT_RECORD -*/ - -internal enum ComponentValueKind +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace Wasmtime.Components; + +/// +/// The type discriminant of a , mirroring +/// wasmtime_component_valkind_t. +/// +public enum ComponentValueKind : byte { + /// A bool. Bool = 0, + /// A signed 8-bit integer. S8 = 1, + /// An unsigned 8-bit integer. U8 = 2, + /// A signed 16-bit integer. S16 = 3, + /// An unsigned 16-bit integer. U16 = 4, + /// A signed 32-bit integer. S32 = 5, + /// An unsigned 32-bit integer. U32 = 6, + /// A signed 64-bit integer. S64 = 7, + /// An unsigned 64-bit integer. U64 = 8, + /// A 32-bit IEEE-754 float. F32 = 9, + /// A 64-bit IEEE-754 float. F64 = 10, + /// A Unicode scalar value. Not yet supported by this binding. Char = 11, + /// A UTF-8 string. String = 12, + /// A homogeneous list. List = 13, + /// A record of named fields. Record = 14, + /// A tuple of positional values. Tuple = 15, + /// A variant. Not yet supported by this binding. Variant = 16, + /// An enumeration, identified by case name. Enum = 17, + /// An optional value. Option = 18, + /// A result value. Result = 19, + /// A set of flags. Not yet supported by this binding. Flags = 20, + /// A resource handle. Not yet supported by this binding. + Resource = 21, + /// A map. Not yet supported by this binding. + Map = 22, } -internal static class ComponentValueNative +/// +/// A value which a component function can consume or produce, mirroring +/// wasmtime_component_val_t. +/// +/// +/// Create values with the static factory methods and read them back with the typed accessors, +/// each of which throws if the value has a different . The +/// , , +/// , and +/// kinds are not yet supported. +/// +public sealed class ComponentValue { - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_vallist_new(wasmtime_component_vallist_t*out, size_t size, struct wasmtime_component_val * ptr) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_vallist_new_empty (wasmtime_component_vallist_t*out) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_vallist_new_uninit (wasmtime_component_vallist_t*out, size_t size) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_vallist_copy (wasmtime_component_vallist_t* dst, const wasmtime_component_vallist_t* src) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_vallist_delete (wasmtime_component_vallist_t* value) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valrecord_new (wasmtime_component_valrecord_t*out, size_t size, struct wasmtime_component_valrecord_entry * ptr) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valrecord_new_empty (wasmtime_component_valrecord_t*out) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valrecord_new_uninit (wasmtime_component_valrecord_t*out, size_t size) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valrecord_copy (wasmtime_component_valrecord_t* dst, const wasmtime_component_valrecord_t* src) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valrecord_delete (wasmtime_component_valrecord_t* value) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valtuple_new (wasmtime_component_valtuple_t*out, size_t size, struct wasmtime_component_val * ptr) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valtuple_new_empty (wasmtime_component_valtuple_t*out) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valtuple_new_uninit (wasmtime_component_valtuple_t*out, size_t size) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valtuple_copy (wasmtime_component_valtuple_t* dst, const wasmtime_component_valtuple_t* src) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valtuple_delete (wasmtime_component_valtuple_t* value) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valflags_new (wasmtime_component_valflags_t*out, size_t size, wasm_name_t* ptr) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valflags_new_empty (wasmtime_component_valflags_t*out) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valflags_new_uninit (wasmtime_component_valflags_t*out, size_t size) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valflags_copy (wasmtime_component_valflags_t* dst, const wasmtime_component_valflags_t* src) - - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_valflags_delete (wasmtime_component_valflags_t* value) - - //[DllImport(Engine.LibraryName)] - //public static extern wasmtime_component_val_t * wasmtime_component_val_new () + private ComponentValue(ComponentValueKind kind) + { + Kind = kind; + } + + /// + /// Gets the type discriminant of this value. + /// + public ComponentValueKind Kind { get; } + + /// Backing store for the integral kinds and for . + internal long Integer { get; private set; } + + /// Backing store for the floating-point kinds. + internal double Real { get; private set; } + + /// String contents, or the case name of an enum. + internal string? Text { get; private set; } + + /// Elements of a list or tuple. + internal IReadOnlyList Items { get; private set; } = Array.Empty(); + + /// Fields of a record, in declaration order. + internal IReadOnlyList> Fields { get; private set; } = + Array.Empty>(); + + /// True for some options and ok results. + internal bool Flag { get; private set; } + + /// + /// Gets the payload of an or + /// , or null when there is none. + /// + public ComponentValue? Payload { get; private set; } + + /// + /// Gets whether this carries a value. + /// + public bool IsSome => Expect(ComponentValueKind.Option, Flag); + + /// + /// Gets whether this is the ok case. + /// + public bool IsOk => Expect(ComponentValueKind.Result, Flag); + + /// Creates a bool value. + /// The value. + /// The component value. + public static ComponentValue Bool(bool value) => + new ComponentValue(ComponentValueKind.Bool) { Integer = value ? 1 : 0 }; + + /// Creates a signed 8-bit integer value. + /// The value. + /// The component value. + public static ComponentValue S8(sbyte value) => + new ComponentValue(ComponentValueKind.S8) { Integer = value }; + + /// Creates an unsigned 8-bit integer value. + /// The value. + /// The component value. + public static ComponentValue U8(byte value) => + new ComponentValue(ComponentValueKind.U8) { Integer = value }; + + /// Creates a signed 16-bit integer value. + /// The value. + /// The component value. + public static ComponentValue S16(short value) => + new ComponentValue(ComponentValueKind.S16) { Integer = value }; + + /// Creates an unsigned 16-bit integer value. + /// The value. + /// The component value. + public static ComponentValue U16(ushort value) => + new ComponentValue(ComponentValueKind.U16) { Integer = value }; + + /// Creates a signed 32-bit integer value. + /// The value. + /// The component value. + public static ComponentValue S32(int value) => + new ComponentValue(ComponentValueKind.S32) { Integer = value }; + + /// Creates an unsigned 32-bit integer value. + /// The value. + /// The component value. + public static ComponentValue U32(uint value) => + new ComponentValue(ComponentValueKind.U32) { Integer = value }; + + /// Creates a signed 64-bit integer value. + /// The value. + /// The component value. + public static ComponentValue S64(long value) => + new ComponentValue(ComponentValueKind.S64) { Integer = value }; + + /// Creates an unsigned 64-bit integer value. + /// The value. + /// The component value. + public static ComponentValue U64(ulong value) => + new ComponentValue(ComponentValueKind.U64) { Integer = unchecked((long)value) }; + + /// Creates a 32-bit float value. + /// The value. + /// The component value. + public static ComponentValue F32(float value) => + new ComponentValue(ComponentValueKind.F32) { Real = value }; + + /// Creates a 64-bit float value. + /// The value. + /// The component value. + public static ComponentValue F64(double value) => + new ComponentValue(ComponentValueKind.F64) { Real = value }; + + /// Creates a string value. + /// The value. + /// The component value. + /// Thrown if is null. + public static ComponentValue String(string value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return new ComponentValue(ComponentValueKind.String) { Text = value }; + } + + /// Creates an enumeration value. + /// The name of the enum case, as declared in WIT. + /// The component value. + /// Thrown if is null. + /// Component model enums travel by case name rather than by ordinal. + public static ComponentValue Enum(string caseName) + { + if (caseName is null) + { + throw new ArgumentNullException(nameof(caseName)); + } + + return new ComponentValue(ComponentValueKind.Enum) { Text = caseName }; + } + + /// Creates a list value. + /// The elements of the list, which are copied. + /// The component value. + /// Thrown if is null. + public static ComponentValue List(IReadOnlyList items) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + return new ComponentValue(ComponentValueKind.List) { Items = Copy(items) }; + } + + /// Creates a tuple value. + /// The elements of the tuple, which are copied. + /// The component value. + /// Thrown if is null. + public static ComponentValue Tuple(IReadOnlyList items) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + return new ComponentValue(ComponentValueKind.Tuple) { Items = Copy(items) }; + } + + /// Creates a record value. + /// The fields of the record in declaration order, which are copied. + /// The component value. + /// Thrown if is null. + public static ComponentValue Record(IReadOnlyList> fields) + { + if (fields is null) + { + throw new ArgumentNullException(nameof(fields)); + } + + return new ComponentValue(ComponentValueKind.Record) { Fields = Copy(fields) }; + } + + // The Owned* factories take ownership instead of copying, for lists nothing else references. + internal static ComponentValue OwnedList(List items) => + new ComponentValue(ComponentValueKind.List) { Items = items }; + + internal static ComponentValue OwnedTuple(List items) => + new ComponentValue(ComponentValueKind.Tuple) { Items = items }; + + internal static ComponentValue OwnedRecord(List> fields) => + new ComponentValue(ComponentValueKind.Record) { Fields = fields }; + + private static T[] Copy(IReadOnlyList source) + { + if (source.Count == 0) + { + return Array.Empty(); + } + + var copy = new T[source.Count]; + for (var i = 0; i < copy.Length; i++) + { + copy[i] = source[i]; + } + + return copy; + } + + /// Creates an option value carrying a payload. + /// The payload. + /// The component value. + /// Thrown if is null. + public static ComponentValue Some(ComponentValue value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return new ComponentValue(ComponentValueKind.Option) { Payload = value, Flag = true }; + } + + /// Creates an empty option value. + /// The component value. + public static ComponentValue None() => + new ComponentValue(ComponentValueKind.Option) { Flag = false }; + + /// Creates the ok case of a result. + /// The payload, or null for a result with no ok type. + /// The component value. + public static ComponentValue Ok(ComponentValue? value = null) => + new ComponentValue(ComponentValueKind.Result) { Payload = value, Flag = true }; + + /// Creates the err case of a result. + /// The payload, or null for a result with no err type. + /// The component value. + public static ComponentValue Err(ComponentValue? value = null) => + new ComponentValue(ComponentValueKind.Result) { Payload = value, Flag = false }; + + /// Gets the value as a bool. + /// The value. + public bool AsBool() => Expect(ComponentValueKind.Bool, Integer != 0); + + /// Gets the value as a signed 8-bit integer. + /// The value. + public sbyte AsS8() => Expect(ComponentValueKind.S8, unchecked((sbyte)Integer)); + + /// Gets the value as an unsigned 8-bit integer. + /// The value. + public byte AsU8() => Expect(ComponentValueKind.U8, unchecked((byte)Integer)); + + /// Gets the value as a signed 16-bit integer. + /// The value. + public short AsS16() => Expect(ComponentValueKind.S16, unchecked((short)Integer)); + + /// Gets the value as an unsigned 16-bit integer. + /// The value. + public ushort AsU16() => Expect(ComponentValueKind.U16, unchecked((ushort)Integer)); + + /// Gets the value as a signed 32-bit integer. + /// The value. + public int AsS32() => Expect(ComponentValueKind.S32, unchecked((int)Integer)); + + /// Gets the value as an unsigned 32-bit integer. + /// The value. + public uint AsU32() => Expect(ComponentValueKind.U32, unchecked((uint)Integer)); + + /// Gets the value as a signed 64-bit integer. + /// The value. + public long AsS64() => Expect(ComponentValueKind.S64, Integer); + + /// Gets the value as an unsigned 64-bit integer. + /// The value. + public ulong AsU64() => Expect(ComponentValueKind.U64, unchecked((ulong)Integer)); + + /// Gets the value as a 32-bit float. + /// The value. + public float AsF32() => Expect(ComponentValueKind.F32, (float)Real); + + /// Gets the value as a 64-bit float. + /// The value. + public double AsF64() => Expect(ComponentValueKind.F64, Real); + + /// Gets the value as a string. + /// The value. + public string AsString() => Expect(ComponentValueKind.String, Text ?? string.Empty); + + /// Gets the case name of an enumeration value. + /// The case name. + public string AsEnum() => Expect(ComponentValueKind.Enum, Text ?? string.Empty); + + /// Gets the elements of a list. + /// The elements. + public IReadOnlyList AsList() => Expect(ComponentValueKind.List, Items); + + /// Gets the elements of a tuple. + /// The elements. + public IReadOnlyList AsTuple() => Expect(ComponentValueKind.Tuple, Items); + + /// Gets the fields of a record. + /// The fields, in declaration order. + public IReadOnlyList> AsRecord() => + Expect(ComponentValueKind.Record, Fields); + + /// + /// Gets the value of a record field by name. + /// + /// The field name. + /// The field's value. + /// + /// Thrown if this is not a record, or if it has no such field. + /// + public ComponentValue Field(string name) + { + if (!TryGetField(name, out var value)) + { + throw new InvalidOperationException($"Record has no field '{name}'."); + } + + return value!; + } + + /// + /// Gets the value of a record field by name, if present. + /// + /// The field name. + /// The field's value, or null if there is no such field. + /// True if the field was found. + /// Thrown if this is not a record. + public bool TryGetField(string name, out ComponentValue? value) + { + foreach (var field in AsRecord()) + { + if (field.Key == name) + { + value = field.Value; + return true; + } + } + + value = null; + return false; + } + + private T Expect(ComponentValueKind expected, T value) + { + if (Kind != expected) + { + throw new InvalidOperationException($"Expected component value kind {expected}, got {Kind}."); + } + + return value; + } + + /// + public override string ToString() + { + switch (Kind) + { + case ComponentValueKind.Bool: + return (Integer != 0).ToString(CultureInfo.InvariantCulture); + case ComponentValueKind.F32: + case ComponentValueKind.F64: + return Real.ToString(CultureInfo.InvariantCulture); + case ComponentValueKind.U64: + return unchecked((ulong)Integer).ToString(CultureInfo.InvariantCulture); + case ComponentValueKind.String: + return $"\"{Text}\""; + case ComponentValueKind.Enum: + return Text ?? string.Empty; + case ComponentValueKind.List: + case ComponentValueKind.Tuple: + return $"[{string.Join(", ", Items)}]"; + case ComponentValueKind.Record: + var fields = new string[Fields.Count]; + for (var i = 0; i < Fields.Count; i++) + { + fields[i] = $"{Fields[i].Key}: {Fields[i].Value}"; + } + + return $"{{{string.Join(", ", fields)}}}"; + case ComponentValueKind.Option: + return Flag ? $"some({Payload})" : "none"; + case ComponentValueKind.Result: + return Flag ? $"ok({Payload})" : $"err({Payload})"; + default: + return Integer.ToString(CultureInfo.InvariantCulture); + } + } +} - //[DllImport(Engine.LibraryName)] - //public static extern void wasmtime_component_val_delete(wasmtime_component_val_t* value) +internal static class ComponentValueNative +{ + /// + /// Performs a deep copy of into . The contents + /// of are owned by Wasmtime and must be released with + /// . + /// + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_val_clone( + IntPtr /* const wasmtime_component_val_t* */ src, + IntPtr /* wasmtime_component_val_t* */ dst); + + /// + /// Deallocates the memory owned by , but not the storage of + /// itself. Only valid for embedder-owned storage. + /// + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_val_delete(IntPtr /* wasmtime_component_val_t* */ value); } \ No newline at end of file diff --git a/src/Components/ComponentValueMarshaller.cs b/src/Components/ComponentValueMarshaller.cs new file mode 100644 index 00000000..705a8e40 --- /dev/null +++ b/src/Components/ComponentValueMarshaller.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace Wasmtime.Components; + +/// +/// Converts between and the native wasmtime_component_val_t +/// layout. +/// +/// +/// +/// Ownership is asymmetric. Arguments are declared const by the C API, so Wasmtime never +/// takes them: everything written here is allocated by us and released by the caller's +/// . Results are the opposite, being allocated by Wasmtime's own +/// allocator, so their contents must be released with +/// wasmtime_component_val_delete rather than freed directly. Mixing the two allocators +/// corrupts the heap. +/// +/// +/// The native structures are written field by field rather than through the +/// wasmtime_component_vallist_new family of helpers, because a value must be built in +/// place inside an array element or a record entry. +/// +/// +internal static class ComponentValueMarshaller +{ + /// Size of wasmtime_component_val_t: a 1-byte kind padded to 8, plus a 24-byte union. + public const int ValueSize = 32; + + /// Offset of the of union within wasmtime_component_val_t. + public const int ValuePayloadOffset = 8; + + /// Size of wasmtime_component_valrecord_entry_t: a 16-byte name plus a 32-byte value. + public const int RecordEntrySize = 48; + + /// Offset of the value within wasmtime_component_valrecord_entry_t. + public const int RecordEntryValueOffset = 16; + + /// Offset of the data pointer within a vec or a wasm_name_t. + private const int VectorDataOffset = 8; + + /// + /// Tracks the native allocations made while writing arguments, so they can be released + /// together once the call has returned. + /// + public sealed class AllocationScope : IDisposable + { + private readonly List allocations = new List(); + private bool disposed; + + ~AllocationScope() + { + FreeAllocations(); + } + + /// + /// Allocates zeroed native memory whose lifetime is bound to this scope. + /// + /// The number of bytes to allocate. + /// A pointer to the allocation. + public IntPtr Allocate(int bytes) + { + if (disposed) + { + throw new ObjectDisposedException(nameof(AllocationScope)); + } + + var pointer = Marshal.AllocHGlobal(bytes); + allocations.Add(pointer); + + unsafe + { + new Span((void*)pointer, bytes).Clear(); + } + + return pointer; + } + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + FreeAllocations(); + GC.SuppressFinalize(this); + } + + private void FreeAllocations() + { + foreach (var pointer in allocations) + { + Marshal.FreeHGlobal(pointer); + } + + allocations.Clear(); + } + } + + /// + /// Writes a value into caller-owned native storage. + /// + /// The value to write. + /// A pointer to bytes of storage. + /// The scope owning any secondary allocations the value needs. + public static void Write(ComponentValue value, IntPtr destination, AllocationScope scope) + { + Marshal.WriteByte(destination, (byte)value.Kind); + var payload = destination + ValuePayloadOffset; + + switch (value.Kind) + { + case ComponentValueKind.Bool: + Marshal.WriteByte(payload, (byte)(value.Integer != 0 ? 1 : 0)); + break; + + case ComponentValueKind.S8: + case ComponentValueKind.U8: + Marshal.WriteByte(payload, unchecked((byte)value.Integer)); + break; + + case ComponentValueKind.S16: + case ComponentValueKind.U16: + Marshal.WriteInt16(payload, unchecked((short)value.Integer)); + break; + + case ComponentValueKind.S32: + case ComponentValueKind.U32: + Marshal.WriteInt32(payload, unchecked((int)value.Integer)); + break; + + case ComponentValueKind.F32: + Marshal.WriteInt32(payload, Extensions.SingleToInt32Bits((float)value.Real)); + break; + + case ComponentValueKind.S64: + case ComponentValueKind.U64: + Marshal.WriteInt64(payload, value.Integer); + break; + + case ComponentValueKind.F64: + Marshal.WriteInt64(payload, BitConverter.DoubleToInt64Bits(value.Real)); + break; + + case ComponentValueKind.String: + case ComponentValueKind.Enum: + WriteName(value.Text ?? string.Empty, payload, scope); + break; + + case ComponentValueKind.List: + case ComponentValueKind.Tuple: + WriteVector(value.Items, payload, scope); + break; + + case ComponentValueKind.Record: + WriteRecord(value.Fields, payload, scope); + break; + + case ComponentValueKind.Option: + Marshal.WriteIntPtr(payload, value.Flag ? WriteBoxed(value.Payload!, scope) : IntPtr.Zero); + break; + + case ComponentValueKind.Result: + Marshal.WriteByte(payload, (byte)(value.Flag ? 1 : 0)); + Marshal.WriteIntPtr( + payload + VectorDataOffset, + value.Payload is null ? IntPtr.Zero : WriteBoxed(value.Payload, scope)); + break; + + default: + throw new NotSupportedException( + $"Writing component values of kind {value.Kind} is not supported."); + } + } + + /// + /// Reads a value from native storage. + /// + /// A pointer to a wasmtime_component_val_t. + /// The value that was read. + public static ComponentValue Read(IntPtr source) + { + var kind = (ComponentValueKind)Marshal.ReadByte(source); + var payload = source + ValuePayloadOffset; + + switch (kind) + { + case ComponentValueKind.Bool: + return ComponentValue.Bool(Marshal.ReadByte(payload) != 0); + + case ComponentValueKind.S8: + return ComponentValue.S8(unchecked((sbyte)Marshal.ReadByte(payload))); + + case ComponentValueKind.U8: + return ComponentValue.U8(Marshal.ReadByte(payload)); + + case ComponentValueKind.S16: + return ComponentValue.S16(Marshal.ReadInt16(payload)); + + case ComponentValueKind.U16: + return ComponentValue.U16(unchecked((ushort)Marshal.ReadInt16(payload))); + + case ComponentValueKind.S32: + return ComponentValue.S32(Marshal.ReadInt32(payload)); + + case ComponentValueKind.U32: + return ComponentValue.U32(unchecked((uint)Marshal.ReadInt32(payload))); + + case ComponentValueKind.S64: + return ComponentValue.S64(Marshal.ReadInt64(payload)); + + case ComponentValueKind.U64: + return ComponentValue.U64(unchecked((ulong)Marshal.ReadInt64(payload))); + + case ComponentValueKind.F32: + return ComponentValue.F32(Extensions.Int32BitsToSingle(Marshal.ReadInt32(payload))); + + case ComponentValueKind.F64: + return ComponentValue.F64(BitConverter.Int64BitsToDouble(Marshal.ReadInt64(payload))); + + case ComponentValueKind.String: + return ComponentValue.String(ReadName(payload)); + + case ComponentValueKind.Enum: + return ComponentValue.Enum(ReadName(payload)); + + case ComponentValueKind.List: + return ComponentValue.OwnedList(ReadVector(payload)); + + case ComponentValueKind.Tuple: + return ComponentValue.OwnedTuple(ReadVector(payload)); + + case ComponentValueKind.Record: + return ComponentValue.OwnedRecord(ReadRecord(payload)); + + case ComponentValueKind.Option: + var some = Marshal.ReadIntPtr(payload); + return some == IntPtr.Zero ? ComponentValue.None() : ComponentValue.Some(Read(some)); + + case ComponentValueKind.Result: + var isOk = Marshal.ReadByte(payload) != 0; + var inner = Marshal.ReadIntPtr(payload + VectorDataOffset); + var result = inner == IntPtr.Zero ? null : Read(inner); + return isOk ? ComponentValue.Ok(result) : ComponentValue.Err(result); + + default: + throw new NotSupportedException( + $"Reading component values of kind {kind} is not supported."); + } + } + + private static IntPtr WriteBoxed(ComponentValue value, AllocationScope scope) + { + var boxed = scope.Allocate(ValueSize); + Write(value, boxed, scope); + return boxed; + } + + private static void WriteName(string text, IntPtr destination, AllocationScope scope) + { + var bytes = Encoding.UTF8.GetBytes(text); + + // A zero-length allocation would still need a non-null pointer, so always take at least one byte. + var buffer = scope.Allocate(Math.Max(bytes.Length, 1)); + Marshal.Copy(bytes, 0, buffer, bytes.Length); + + Marshal.WriteIntPtr(destination, (IntPtr)bytes.Length); + Marshal.WriteIntPtr(destination + VectorDataOffset, buffer); + } + + private static void WriteVector(IReadOnlyList items, IntPtr destination, AllocationScope scope) + { + // Read once: the buffer size, loop bound and written length must agree even if the list changes. + var count = items.Count; + + // checked: an overflowing size would otherwise under-allocate and be written past. + var buffer = scope.Allocate(Math.Max(checked(count * ValueSize), 1)); + var element = buffer; + + for (var i = 0; i < count; i++) + { + Write(items[i], element, scope); + element += ValueSize; + } + + Marshal.WriteIntPtr(destination, (IntPtr)count); + Marshal.WriteIntPtr(destination + VectorDataOffset, buffer); + } + + private static void WriteRecord( + IReadOnlyList> fields, + IntPtr destination, + AllocationScope scope) + { + var count = fields.Count; + var buffer = scope.Allocate(Math.Max(checked(count * RecordEntrySize), 1)); + var entry = buffer; + + for (var i = 0; i < count; i++) + { + WriteName(fields[i].Key, entry, scope); + Write(fields[i].Value, entry + RecordEntryValueOffset, scope); + entry += RecordEntrySize; + } + + Marshal.WriteIntPtr(destination, (IntPtr)count); + Marshal.WriteIntPtr(destination + VectorDataOffset, buffer); + } + + private static string ReadName(IntPtr source) + { + var size = ReadLength(source); + var data = Marshal.ReadIntPtr(source + VectorDataOffset); + + return data == IntPtr.Zero || size == 0 ? string.Empty : Extensions.PtrToStringUTF8(data, size); + } + + private static List ReadVector(IntPtr source) + { + var size = ReadLength(source); + var element = Marshal.ReadIntPtr(source + VectorDataOffset); + var items = new List(size); + + for (var i = 0; i < size; i++) + { + items.Add(Read(element)); + element += ValueSize; + } + + return items; + } + + private static List> ReadRecord(IntPtr source) + { + var size = ReadLength(source); + var entry = Marshal.ReadIntPtr(source + VectorDataOffset); + var fields = new List>(size); + + for (var i = 0; i < size; i++) + { + fields.Add(new KeyValuePair( + ReadName(entry), + Read(entry + RecordEntryValueOffset))); + + entry += RecordEntrySize; + } + + return fields; + } + + /// + /// Reads a native size_t length, rejecting values too large to be represented + /// managed-side rather than silently truncating them. + /// + private static int ReadLength(IntPtr source) + { + // Reinterpreted rather than converted, because size_t is unsigned. + var size = unchecked((ulong)Marshal.ReadIntPtr(source).ToInt64()); + if (size > int.MaxValue) + { + throw new NotSupportedException( + $"A component value with {size} elements exceeds the maximum supported length of {int.MaxValue}."); + } + + return (int)size; + } +} diff --git a/tests/ComponentValueLayoutTests.cs b/tests/ComponentValueLayoutTests.cs new file mode 100644 index 00000000..c77ae501 --- /dev/null +++ b/tests/ComponentValueLayoutTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using FluentAssertions; +using Wasmtime.Components; +using Xunit; + +namespace Wasmtime.Tests +{ + /// + /// Validates the native encoding of component values against Wasmtime itself. + /// + /// + /// + /// only proves that our writer and reader agree with each + /// other; it would still pass if every offset were wrong in the same way. These tests close + /// that gap by routing each value through wasmtime_component_val_clone, which walks + /// the structure using Wasmtime's own definition of the layout: + /// + /// + /// we encode a value into source; + /// Wasmtime deep-copies it, reading source with its layout; + /// we decode destination, which Wasmtime wrote with its layout. + /// + /// + /// A mismatch in any size or offset therefore corrupts the value or crashes, rather than + /// cancelling out. This needs no engine, store or component. + /// + /// + public sealed class ComponentValueLayoutTests + { + private static ComponentValue Clone(ComponentValue value) + { + using var scope = new ComponentValueMarshaller.AllocationScope(); + var source = scope.Allocate(ComponentValueMarshaller.ValueSize); + ComponentValueMarshaller.Write(value, source, scope); + + var destination = Marshal.AllocHGlobal(ComponentValueMarshaller.ValueSize); + try + { + unsafe + { + new Span((void*)destination, ComponentValueMarshaller.ValueSize).Clear(); + } + + ComponentValueNative.wasmtime_component_val_clone(source, destination); + return ComponentValueMarshaller.Read(destination); + } + finally + { + ComponentValueNative.wasmtime_component_val_delete(destination); + Marshal.FreeHGlobal(destination); + } + } + + /// + /// Clones the value through Wasmtime and asserts the result is structurally identical. + /// + private static void Survives(ComponentValue value) + { + AssertEquivalent(value, Clone(value), "$"); + } + + private static void AssertEquivalent(ComponentValue expected, ComponentValue actual, string path) + { + actual.Kind.Should().Be(expected.Kind, "kind at {0}", path); + + switch (expected.Kind) + { + case ComponentValueKind.Bool: + actual.AsBool().Should().Be(expected.AsBool(), "value at {0}", path); + break; + + case ComponentValueKind.S8: + case ComponentValueKind.U8: + case ComponentValueKind.S16: + case ComponentValueKind.U16: + case ComponentValueKind.S32: + case ComponentValueKind.U32: + case ComponentValueKind.S64: + case ComponentValueKind.U64: + actual.Integer.Should().Be(expected.Integer, "value at {0}", path); + break; + + case ComponentValueKind.F32: + case ComponentValueKind.F64: + actual.Real.Should().Be(expected.Real, "value at {0}", path); + break; + + case ComponentValueKind.String: + case ComponentValueKind.Enum: + actual.Text.Should().Be(expected.Text, "text at {0}", path); + break; + + case ComponentValueKind.List: + case ComponentValueKind.Tuple: + actual.Items.Should().HaveCount(expected.Items.Count, "length at {0}", path); + for (var i = 0; i < expected.Items.Count; i++) + { + AssertEquivalent(expected.Items[i], actual.Items[i], $"{path}[{i}]"); + } + + break; + + case ComponentValueKind.Record: + actual.Fields.Should().HaveCount(expected.Fields.Count, "field count at {0}", path); + for (var i = 0; i < expected.Fields.Count; i++) + { + actual.Fields[i].Key.Should().Be(expected.Fields[i].Key, "field name at {0}[{1}]", path, i); + AssertEquivalent( + expected.Fields[i].Value, + actual.Fields[i].Value, + $"{path}.{expected.Fields[i].Key}"); + } + + break; + + case ComponentValueKind.Option: + case ComponentValueKind.Result: + actual.Flag.Should().Be(expected.Flag, "discriminant at {0}", path); + if (expected.Payload is null) + { + actual.Payload.Should().BeNull("payload at {0}", path); + } + else + { + actual.Payload.Should().NotBeNull("payload at {0}", path); + AssertEquivalent(expected.Payload, actual.Payload!, $"{path}.payload"); + } + + break; + + default: + throw new NotSupportedException($"No comparison for kind {expected.Kind}."); + } + } + + [Fact] + public void ScalarsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Bool(true)); + Survives(ComponentValue.Bool(false)); + Survives(ComponentValue.S8(sbyte.MinValue)); + Survives(ComponentValue.S8(-1)); + Survives(ComponentValue.U8(byte.MaxValue)); + Survives(ComponentValue.S16(short.MinValue)); + Survives(ComponentValue.U16(ushort.MaxValue)); + Survives(ComponentValue.S32(int.MinValue)); + Survives(ComponentValue.S32(-1)); + Survives(ComponentValue.U32(uint.MaxValue)); + Survives(ComponentValue.S64(long.MinValue)); + Survives(ComponentValue.U64(ulong.MaxValue)); + Survives(ComponentValue.F32(float.MinValue)); + Survives(ComponentValue.F32(float.NaN)); + Survives(ComponentValue.F64(-2.718281828459045)); + Survives(ComponentValue.F64(double.PositiveInfinity)); + } + + [Fact] + public void StringsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.String(string.Empty)); + Survives(ComponentValue.String("hello")); + Survives(ComponentValue.String("h\u00e9llo \u4e16\u754c \ud83c\udf89")); + Survives(ComponentValue.String(new string('x', 4096))); + } + + [Fact] + public void EnumsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Enum("warning")); + Survives(ComponentValue.Enum("a")); + } + + [Fact] + public void ListsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.List([])); + Survives(ComponentValue.List(new[] { ComponentValue.S32(1) })); + Survives(ComponentValue.List(new[] + { + ComponentValue.S32(1), + ComponentValue.S32(-2), + ComponentValue.S32(int.MaxValue), + })); + Survives(ComponentValue.List(new[] + { + ComponentValue.String("first"), + ComponentValue.String(string.Empty), + ComponentValue.String("third"), + })); + } + + [Fact] + public void NestedListsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.List(new[] + { + ComponentValue.List(new[] { ComponentValue.String("a"), ComponentValue.String("b") }), + ComponentValue.List([]), + ComponentValue.List(new[] { ComponentValue.String("c") }), + })); + } + + [Fact] + public void TuplesSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Tuple(new[] + { + ComponentValue.Bool(true), + ComponentValue.String("two"), + ComponentValue.F64(3.5), + })); + } + + [Fact] + public void RecordsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Record([])); + Survives(ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(36.6)), + new KeyValuePair("timestamp", ComponentValue.U64(1234567890123)), + })); + } + + [Fact] + public void OptionsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.None()); + Survives(ComponentValue.Some(ComponentValue.S32(42))); + Survives(ComponentValue.Some(ComponentValue.String("boxed"))); + Survives(ComponentValue.Some(ComponentValue.Some(ComponentValue.String("x")))); + } + + [Fact] + public void ResultsSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Ok()); + Survives(ComponentValue.Err()); + Survives(ComponentValue.Ok(ComponentValue.S32(7))); + Survives(ComponentValue.Err(ComponentValue.String("boom"))); + } + + [Fact] + public void DeeplyNestedValuesSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Record(new[] + { + new KeyValuePair("readings", ComponentValue.List(new[] + { + ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(1.5)), + new KeyValuePair("note", ComponentValue.Some(ComponentValue.String("ok"))), + }), + ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(2.5)), + new KeyValuePair("note", ComponentValue.None()), + }), + })), + new KeyValuePair("level", ComponentValue.Enum("warning")), + new KeyValuePair("outcome", ComponentValue.Ok(ComponentValue.Tuple(new[] + { + ComponentValue.U16(200), + ComponentValue.String("done"), + }))), + })); + } + + /// + /// A large list exercises the element stride, which a single-element list cannot: an + /// over-estimated still round-trips one + /// element correctly but misaligns every element after the first. + /// + [Fact] + public void ElementStrideSurvivesACloneThroughWasmtime() + { + var items = new ComponentValue[64]; + for (var i = 0; i < items.Length; i++) + { + items[i] = ComponentValue.Record(new[] + { + new KeyValuePair("index", ComponentValue.S32(i)), + new KeyValuePair("name", ComponentValue.String($"item-{i}")), + }); + } + + var result = Clone(ComponentValue.List(items)).AsList(); + + result.Should().HaveCount(items.Length); + for (var i = 0; i < items.Length; i++) + { + result[i].Field("index").AsS32().Should().Be(i); + result[i].Field("name").AsString().Should().Be($"item-{i}"); + } + } + } +} diff --git a/tests/ComponentValueTests.cs b/tests/ComponentValueTests.cs new file mode 100644 index 00000000..70f0cb46 --- /dev/null +++ b/tests/ComponentValueTests.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Wasmtime.Components; +using Xunit; + +namespace Wasmtime.Tests +{ + /// + /// Round-trip tests for the component value marshaller. + /// + /// + /// These verify that the writer and the reader agree on the native encoding. They do not + /// prove that the encoding matches Wasmtime's, which is fixed by the struct sizes and + /// offsets taken from wasmtime/component/val.h and is exercised end to end once a + /// component function is actually called. + /// + public sealed class ComponentValueTests + { + private static ComponentValue RoundTrip(ComponentValue value) + { + using var scope = new ComponentValueMarshaller.AllocationScope(); + var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); + ComponentValueMarshaller.Write(value, pointer, scope); + return ComponentValueMarshaller.Read(pointer); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ItRoundTripsBool(bool value) + { + var result = RoundTrip(ComponentValue.Bool(value)); + + result.Kind.Should().Be(ComponentValueKind.Bool); + result.AsBool().Should().Be(value); + } + + [Theory] + [InlineData(sbyte.MinValue)] + [InlineData((sbyte)-1)] + [InlineData((sbyte)0)] + [InlineData(sbyte.MaxValue)] + public void ItRoundTripsS8(sbyte value) + { + RoundTrip(ComponentValue.S8(value)).AsS8().Should().Be(value); + } + + [Theory] + [InlineData(byte.MinValue)] + [InlineData((byte)1)] + [InlineData(byte.MaxValue)] + public void ItRoundTripsU8(byte value) + { + RoundTrip(ComponentValue.U8(value)).AsU8().Should().Be(value); + } + + [Theory] + [InlineData(short.MinValue)] + [InlineData((short)-1)] + [InlineData(short.MaxValue)] + public void ItRoundTripsS16(short value) + { + RoundTrip(ComponentValue.S16(value)).AsS16().Should().Be(value); + } + + [Theory] + [InlineData(ushort.MinValue)] + [InlineData(ushort.MaxValue)] + public void ItRoundTripsU16(ushort value) + { + RoundTrip(ComponentValue.U16(value)).AsU16().Should().Be(value); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(int.MaxValue)] + public void ItRoundTripsS32(int value) + { + RoundTrip(ComponentValue.S32(value)).AsS32().Should().Be(value); + } + + [Theory] + [InlineData(uint.MinValue)] + [InlineData(1u)] + [InlineData(uint.MaxValue)] + public void ItRoundTripsU32(uint value) + { + RoundTrip(ComponentValue.U32(value)).AsU32().Should().Be(value); + } + + [Theory] + [InlineData(long.MinValue)] + [InlineData(-1L)] + [InlineData(long.MaxValue)] + public void ItRoundTripsS64(long value) + { + RoundTrip(ComponentValue.S64(value)).AsS64().Should().Be(value); + } + + [Theory] + [InlineData(ulong.MinValue)] + [InlineData(1ul)] + [InlineData(ulong.MaxValue)] + public void ItRoundTripsU64(ulong value) + { + RoundTrip(ComponentValue.U64(value)).AsU64().Should().Be(value); + } + + [Theory] + [InlineData(0.0f)] + [InlineData(3.14159f)] + [InlineData(float.MinValue)] + [InlineData(float.MaxValue)] + [InlineData(float.Epsilon)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void ItRoundTripsF32(float value) + { + RoundTrip(ComponentValue.F32(value)).AsF32().Should().Be(value); + } + + /// + /// -0.0 == 0.0, so the sign of zero is only observable through the bits. + /// + [Fact] + public void ItPreservesTheSignOfZero() + { + var f32 = RoundTrip(ComponentValue.F32(-0.0f)).AsF32(); + BitConverter.SingleToInt32Bits(f32).Should().Be(BitConverter.SingleToInt32Bits(-0.0f)); + + var f64 = RoundTrip(ComponentValue.F64(-0.0)).AsF64(); + BitConverter.DoubleToInt64Bits(f64).Should().Be(BitConverter.DoubleToInt64Bits(-0.0)); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-2.718281828459045)] + [InlineData(double.MinValue)] + [InlineData(double.MaxValue)] + [InlineData(double.Epsilon)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void ItRoundTripsF64(double value) + { + RoundTrip(ComponentValue.F64(value)).AsF64().Should().Be(value); + } + + [Theory] + [InlineData("")] + [InlineData("hello")] + [InlineData("h\u00e9llo \u4e16\u754c \ud83c\udf89")] + [InlineData("embedded\0null")] + public void ItRoundTripsString(string value) + { + var result = RoundTrip(ComponentValue.String(value)); + + result.Kind.Should().Be(ComponentValueKind.String); + result.AsString().Should().Be(value); + } + + [Fact] + public void ItRoundTripsEnum() + { + var result = RoundTrip(ComponentValue.Enum("warning")); + + result.Kind.Should().Be(ComponentValueKind.Enum); + result.AsEnum().Should().Be("warning"); + } + + [Fact] + public void ItRoundTripsEmptyList() + { + RoundTrip(ComponentValue.List([])).AsList().Should().BeEmpty(); + } + + [Fact] + public void ItRoundTripsList() + { + var value = ComponentValue.List(new[] + { + ComponentValue.S32(1), + ComponentValue.S32(-2), + ComponentValue.S32(int.MaxValue), + }); + + var result = RoundTrip(value).AsList(); + + result.Should().HaveCount(3); + result[0].AsS32().Should().Be(1); + result[1].AsS32().Should().Be(-2); + result[2].AsS32().Should().Be(int.MaxValue); + } + + [Fact] + public void ItRoundTripsNestedLists() + { + var value = ComponentValue.List(new[] + { + ComponentValue.List(new[] { ComponentValue.String("a"), ComponentValue.String("b") }), + ComponentValue.List([]), + ComponentValue.List(new[] { ComponentValue.String("c") }), + }); + + var result = RoundTrip(value).AsList(); + + result.Should().HaveCount(3); + result[0].AsList().Should().HaveCount(2); + result[0].AsList()[1].AsString().Should().Be("b"); + result[1].AsList().Should().BeEmpty(); + result[2].AsList()[0].AsString().Should().Be("c"); + } + + [Fact] + public void ItRoundTripsTupleOfMixedKinds() + { + var value = ComponentValue.Tuple(new[] + { + ComponentValue.Bool(true), + ComponentValue.String("two"), + ComponentValue.F64(3.5), + }); + + var result = RoundTrip(value); + + result.Kind.Should().Be(ComponentValueKind.Tuple); + result.AsTuple()[0].AsBool().Should().BeTrue(); + result.AsTuple()[1].AsString().Should().Be("two"); + result.AsTuple()[2].AsF64().Should().Be(3.5); + } + + [Fact] + public void ItRoundTripsRecordPreservingFieldOrder() + { + var value = ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(36.6)), + new KeyValuePair("timestamp", ComponentValue.U64(1234567890123)), + }); + + var result = RoundTrip(value); + + result.Kind.Should().Be(ComponentValueKind.Record); + result.AsRecord().Should().HaveCount(2); + result.AsRecord()[0].Key.Should().Be("value"); + result.AsRecord()[1].Key.Should().Be("timestamp"); + result.Field("value").AsF64().Should().Be(36.6); + result.Field("timestamp").AsU64().Should().Be(1234567890123); + } + + [Fact] + public void ItRoundTripsEmptyRecord() + { + RoundTrip(ComponentValue.Record([])) + .AsRecord().Should().BeEmpty(); + } + + [Fact] + public void ItRoundTripsSomeAndNone() + { + var some = RoundTrip(ComponentValue.Some(ComponentValue.S32(42))); + some.Kind.Should().Be(ComponentValueKind.Option); + some.IsSome.Should().BeTrue(); + some.Payload!.AsS32().Should().Be(42); + + var none = RoundTrip(ComponentValue.None()); + none.Kind.Should().Be(ComponentValueKind.Option); + none.IsSome.Should().BeFalse(); + none.Payload.Should().BeNull(); + } + + [Fact] + public void ItRoundTripsNestedOption() + { + var result = RoundTrip(ComponentValue.Some(ComponentValue.Some(ComponentValue.String("x")))); + + result.IsSome.Should().BeTrue(); + result.Payload!.IsSome.Should().BeTrue(); + result.Payload!.Payload!.AsString().Should().Be("x"); + } + + [Fact] + public void ItRoundTripsResultWithPayload() + { + var ok = RoundTrip(ComponentValue.Ok(ComponentValue.S32(7))); + ok.Kind.Should().Be(ComponentValueKind.Result); + ok.IsOk.Should().BeTrue(); + ok.Payload!.AsS32().Should().Be(7); + + var err = RoundTrip(ComponentValue.Err(ComponentValue.String("boom"))); + err.IsOk.Should().BeFalse(); + err.Payload!.AsString().Should().Be("boom"); + } + + [Fact] + public void ItRoundTripsResultWithoutPayload() + { + var ok = RoundTrip(ComponentValue.Ok()); + ok.IsOk.Should().BeTrue(); + ok.Payload.Should().BeNull(); + + var err = RoundTrip(ComponentValue.Err()); + err.IsOk.Should().BeFalse(); + err.Payload.Should().BeNull(); + } + + [Fact] + public void ItRoundTripsDeeplyNestedValues() + { + var value = ComponentValue.Record(new[] + { + new KeyValuePair("readings", ComponentValue.List(new[] + { + ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(1.5)), + new KeyValuePair("note", ComponentValue.Some(ComponentValue.String("ok"))), + }), + ComponentValue.Record(new[] + { + new KeyValuePair("value", ComponentValue.F64(2.5)), + new KeyValuePair("note", ComponentValue.None()), + }), + })), + new KeyValuePair("level", ComponentValue.Enum("warning")), + }); + + var result = RoundTrip(value); + var readings = result.Field("readings").AsList(); + + readings.Should().HaveCount(2); + readings[0].Field("value").AsF64().Should().Be(1.5); + readings[0].Field("note").Payload!.AsString().Should().Be("ok"); + readings[1].Field("value").AsF64().Should().Be(2.5); + readings[1].Field("note").IsSome.Should().BeFalse(); + result.Field("level").AsEnum().Should().Be("warning"); + } + + [Fact] + public void ItThrowsReadingAnUnsupportedKind() + { + using var scope = new ComponentValueMarshaller.AllocationScope(); + var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); + System.Runtime.InteropServices.Marshal.WriteByte(pointer, (byte)ComponentValueKind.Char); + + var act = () => ComponentValueMarshaller.Read(pointer); + + act.Should().Throw().WithMessage("*Char*"); + } + + /// + /// A native length is a size_t, so it can exceed what a managed length can hold. + /// Truncating it would produce a negative or far-too-small length and read out of bounds, + /// so it has to be rejected instead. + /// + [Theory] + [InlineData(ComponentValueKind.String)] + [InlineData(ComponentValueKind.List)] + [InlineData(ComponentValueKind.Record)] + public void ItRejectsALengthTooLargeToRepresent(ComponentValueKind kind) + { + using var scope = new ComponentValueMarshaller.AllocationScope(); + var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); + + System.Runtime.InteropServices.Marshal.WriteByte(pointer, (byte)kind); + + // All bits set reads back as a huge unsigned size on both 32- and 64-bit. + System.Runtime.InteropServices.Marshal.WriteIntPtr( + pointer + ComponentValueMarshaller.ValuePayloadOffset, new IntPtr(-1)); + + // Non-null so the guard, rather than a null check, is what rejects this. + System.Runtime.InteropServices.Marshal.WriteIntPtr( + pointer + ComponentValueMarshaller.ValuePayloadOffset + 8, pointer); + + var act = () => ComponentValueMarshaller.Read(pointer); + + act.Should().Throw() + .WithMessage("*exceeds the maximum supported length*"); + } + + [Fact] + public void ItThrowsAccessingTheWrongKind() + { + var value = ComponentValue.S32(1); + + value.Invoking(v => v.AsString()).Should().Throw(); + value.Invoking(v => v.AsRecord()).Should().Throw(); + value.Invoking(v => v.IsOk).Should().Throw(); + } + + [Fact] + public void ItCopiesTheCallersCollections() + { + var items = new List { ComponentValue.S32(1) }; + var fields = new List> { new("a", ComponentValue.S32(1)) }; + + var list = ComponentValue.List(items); + var tuple = ComponentValue.Tuple(items); + var record = ComponentValue.Record(fields); + + items.Add(ComponentValue.S32(2)); + fields.Add(new("b", ComponentValue.S32(2))); + + list.AsList().Should().ContainSingle(); + tuple.AsTuple().Should().ContainSingle(); + record.AsRecord().Should().ContainSingle(); + } + + [Fact] + public void ItThrowsForAMissingRecordField() + { + var value = ComponentValue.Record(new[] + { + new KeyValuePair("present", ComponentValue.S32(1)), + }); + + value.TryGetField("present", out var found).Should().BeTrue(); + found!.AsS32().Should().Be(1); + + value.TryGetField("absent", out var missing).Should().BeFalse(); + missing.Should().BeNull(); + + value.Invoking(v => v.Field("absent")) + .Should().Throw() + .WithMessage("*absent*"); + } + + [Fact] + public void ItRejectsNullArguments() + { + FluentActions.Invoking(() => ComponentValue.String(null!)).Should().Throw(); + FluentActions.Invoking(() => ComponentValue.Enum(null!)).Should().Throw(); + FluentActions.Invoking(() => ComponentValue.List(null!)).Should().Throw(); + FluentActions.Invoking(() => ComponentValue.Tuple(null!)).Should().Throw(); + FluentActions.Invoking(() => ComponentValue.Record(null!)).Should().Throw(); + FluentActions.Invoking(() => ComponentValue.Some(null!)).Should().Throw(); + } + } +}