diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965d191..4291f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,10 @@ jobs: - name: Install Extism CLI run: | - go install github.com/extism/cli/extism@latest + go install github.com/extism/cli/extism@latest + extism lib install --prefix ~/extism --version git + mkdir -p ./tests/Extism.Pdk.WasmTests/bin/Debug/net8.0 + cp ~/extism/lib/libextism.so ./tests/Extism.Pdk.WasmTests/bin/Debug/net8.0 - name: Setup .NET Core SDK uses: actions/setup-dotnet@v3.0.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0cb704..9fcb3c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,10 @@ jobs: - name: Install Extism CLI run: | - go install github.com/extism/cli/extism@latest + go install github.com/extism/cli/extism@latest + extism lib install --prefix ~/extism --version git + mkdir -p ./tests/Extism.Pdk.WasmTests/bin/Debug/net8.0 + cp ~/extism/lib/libextism.so ./tests/Extism.Pdk.WasmTests/bin/Debug/net8.0 - name: Setup .NET Core SDK uses: actions/setup-dotnet@v3.0.3 diff --git a/Extism.Pdk.sln b/Extism.Pdk.sln index d0dc383..d6625d3 100644 --- a/Extism.Pdk.sln +++ b/Extism.Pdk.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KitchenSink", "samples\Kitc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Extism.Pdk.WasmTests", "tests\Extism.Pdk.WasmTests\Extism.Pdk.WasmTests.csproj", "{F603C66E-1821-4E5F-94EF-09D5BB9A04A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleLib", "samples\SampleLib\SampleLib.csproj", "{FD3EBC89-BE62-402F-A5E4-D7298134658E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,10 @@ Global {F603C66E-1821-4E5F-94EF-09D5BB9A04A3}.Debug|Any CPU.Build.0 = Debug|Any CPU {F603C66E-1821-4E5F-94EF-09D5BB9A04A3}.Release|Any CPU.ActiveCfg = Release|Any CPU {F603C66E-1821-4E5F-94EF-09D5BB9A04A3}.Release|Any CPU.Build.0 = Release|Any CPU + {FD3EBC89-BE62-402F-A5E4-D7298134658E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD3EBC89-BE62-402F-A5E4-D7298134658E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD3EBC89-BE62-402F-A5E4-D7298134658E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD3EBC89-BE62-402F-A5E4-D7298134658E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -72,6 +78,7 @@ Global {21861C9B-3C91-46BA-A4D1-5A919FF0113F} = {604F9655-E3D7-4E42-9D5D-91FBCACCD565} {F045BCA5-71DC-483F-8D9F-D1416F6AB588} = {E54FB503-86BE-459F-A7B4-DF2AA9094CEE} {F603C66E-1821-4E5F-94EF-09D5BB9A04A3} = {604F9655-E3D7-4E42-9D5D-91FBCACCD565} + {FD3EBC89-BE62-402F-A5E4-D7298134658E} = {E54FB503-86BE-459F-A7B4-DF2AA9094CEE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F37E205A-FB9F-4C44-B098-ACBF87CF9FF5} diff --git a/Makefile b/Makefile index 2b54411..8ba02f4 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,11 @@ NUGET_API_KEY ?= $(shell env | grep NUGET_API_KEY) prepare: dotnet build -test: prepare - dotnet test +test: + dotnet build ./src/Extism.Pdk.MSBuild + dotnet publish -c Release ./samples/KitchenSink + dotnet test ./tests/Extism.Pdk.MsBuild.Tests + dotnet test ./tests/Extism.Pdk.WasmTests clean: dotnet clean diff --git a/README.md b/README.md index a169450..687db10 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,6 @@ module MyPlugin open System open System.Runtime.InteropServices -open System.Text.Json open Extism [] @@ -179,39 +178,38 @@ Extism export functions simply take bytes in and bytes out. Those can be whateve C#: ```csharp -public record Add(int A, int B); +[JsonSerializable(typeof(Add))] +[JsonSerializable(typeof(Sum))] +public partial class SourceGenerationContext : JsonSerializerContext {} + +public record Add(int a, int b); public record Sum(int Result); -[UnmanagedCallersOnly] -public static int add() +public static class Functions { - var inputJson = Pdk.GetInputString(); - var options = new JsonSerializerOptions + [UnmanagedCallersOnly] + public static int add() { - PropertyNameCaseInsensitive = true - }; - - var parameters = JsonSerializer.Deserialize(inputJson, options); - var sum = new Sum(parameters.A + parameters.B); - var outputJson = JsonSerializer.Serialize(sum, options); - Pdk.SetOutput(outputJson); - return 0; + var inputJson = Pdk.GetInputString(); + var parameters = JsonSerializer.Deserialize(inputJson, SourceGenerationContext.Defaul + var sum = new Sum(parameters.a + parameters.b); + var outputJson = JsonSerializer.Serialize(sum, SourceGenerationContext.Default.Sum); + Pdk.SetOutput(outputJson); + return 0; + } } ``` F#: ```fsharp -type Add = { A: int; B: int } -type Sum = { Result: int } - [] let add () = let inputJson = Pdk.GetInputString() - let options = JsonSerializerOptions(PropertyNameCaseInsensitive = true) - let parameters = JsonSerializer.Deserialize(inputJson, options) - - let sum = { Result = parameters.A + parameters.B } - let outputJson = JsonSerializer.Serialize(sum, options) + let jsonData = JsonDocument.Parse(inputJson).RootElement + let a = jsonData.GetProperty("a").GetInt32() + let b = jsonData.GetProperty("b").GetInt32() + let result = a + b + let outputJson = $"{{ \"Result\": {result} }}" Pdk.SetOutput(outputJson) 0 @@ -222,6 +220,8 @@ extism call .\bin\Debug\net8.0\wasi-wasm\AppBundle\readmeapp.wasm --wasi add --i # => {"Result":41} ``` +**Note:** When enabling trimming, make sure you use the [source generation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation) as reflection is disabled in that mode. + ## Configs Configs are key-value pairs that can be passed in by the host when creating a plug-in. These can be useful to statically configure the plug-in with some data that exists across every function call. Here is a trivial example using Pdk.TryGetConfig: @@ -299,6 +299,7 @@ let count () = Pdk.SetOutput(count.ToString()) 0 +``` From [Extism CLI](https://github.com/extism/cli): ``` @@ -459,11 +460,84 @@ go run . # => Hello from Go! # => An argument to send to Go! ``` + +### Referenced Assemblies + +Methods in referenced assemblies that are decorated with `[DllImport]` and `[UnmanagedCallersOnly]` are imported and exported respectively. + +**Note:** The library imports/exports are ignored if the app doesn't call at least one method from the library. + +For example, if we have a library that contains this class: +```csharp +namespace `MessagingBot.Pdk`; +public class Events +{ + // This function will be imported by all WASI apps that reference this library + [DllImport("env", EntryPoint = "send_message")] + public static extern void SendMessage(ulong offset); + + // You can wrap the imports in your own functions to make them easier to use + public static void SendMessage(string message) + { + using var block = Pdk.Allocate(message); + SendMessage(block.Offset); + } + + // This function will be exported by all WASI apps that reference this library + [UnmanagedCallersOnly] + public static extern void message_received(long offset); +} +``` + +Then, we can reference the library in a WASI app and use the functions: + +```csharp +using MessagingBot.Pdk; + +Events.SendMessage("Hello World!"); +``` + +This is useful when you want to provide a common set of imports and exports that are specific to your use case. + ### Optimize Size -Normally, the .NET runtime is very conservative when trimming. This makes sure code doesn't break (when using reflection for example) but it also means large binary sizes. A hello world sample is about 20mb. To instruct the .NET compiler to be aggresive about trimming, you can try out these options: + +Normally, the .NET runtime is very conservative when trimming and includes a lot of metadata for debugging and exception purposes. This makes sure code doesn't break (when using reflection for example) but it also means large binary sizes. A hello world sample is about 20mb. To instruct the .NET compiler to be aggresive about trimming, you can try out these options: ```xml + + + net8.0 + wasi-wasm + Exe + true + true + true + + + partial + false + false + true + false + + +``` + +If you have imports in referenced assemblies, make sure [you mark them as roots](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options?pivots=dotnet-7-0#root-assemblies) so that they don't get trimmed: +```xml + + + +``` + +And then, run: ``` +dotnet publish -c Release +``` + +Now, you'll have a significantly smaller `.wasm` file in `bin\Release\net8.0\wasi-wasm\AppBundle`. + +For more details, refer to [the official documentation](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options?pivots=dotnet-7-0#trimming-framework-library-features). ### Reach Out! -Have a question or just want to drop in and say hi? [Hop on the Discord](https://extism.org/discord)! \ No newline at end of file +Have a question or just want to drop in and say hi? [Hop on the Discord](https://extism.org/discord)! diff --git a/samples/KitchenSink/KitchenSink.csproj b/samples/KitchenSink/KitchenSink.csproj index 2f06f88..aacf35c 100644 --- a/samples/KitchenSink/KitchenSink.csproj +++ b/samples/KitchenSink/KitchenSink.csproj @@ -10,10 +10,19 @@ enable true false + + + partial + false + false + true + false + + diff --git a/samples/KitchenSink/Program.cs b/samples/KitchenSink/Program.cs index 165a956..d80155c 100644 --- a/samples/KitchenSink/Program.cs +++ b/samples/KitchenSink/Program.cs @@ -2,7 +2,10 @@ using System; using Extism; using System.Text.Json; +using SampleLib; +using System.Text.Json.Serialization; +Class1.noop(); // Import Class1 from SampleLib so that it's included during compilation Console.WriteLine("Hello world!"); namespace Functions @@ -22,7 +25,7 @@ public static int Length() public static int Concat() { var json = Pdk.GetInput(); - var payload = JsonSerializer.Deserialize(json); + var payload = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.ConcatInput); if (payload is null) { @@ -87,6 +90,9 @@ public static int Throw() } } + [JsonSerializable(typeof(ConcatInput))] + public partial class SourceGenerationContext : JsonSerializerContext {} + public class ConcatInput { public string[] Parts { get; set; } diff --git a/samples/SampleLib/Class1.cs b/samples/SampleLib/Class1.cs new file mode 100644 index 0000000..796796d --- /dev/null +++ b/samples/SampleLib/Class1.cs @@ -0,0 +1,19 @@ +using System.Runtime.InteropServices; + +namespace SampleLib; +public class Class1 +{ + [DllImport("env", EntryPoint = "samplelib_import")] + public static extern void samplelib_import(); + + [UnmanagedCallersOnly(EntryPoint = "samplelib_export")] + public static void samplelib_export() + { + samplelib_import(); + } + + public static void noop() + { + + } +} diff --git a/samples/SampleLib/SampleLib.csproj b/samples/SampleLib/SampleLib.csproj new file mode 100644 index 0000000..70ab331 --- /dev/null +++ b/samples/SampleLib/SampleLib.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/Extism.Pdk.MSBuild/FFIGenerator.cs b/src/Extism.Pdk.MSBuild/FFIGenerator.cs index 4906c71..9674df1 100644 --- a/src/Extism.Pdk.MSBuild/FFIGenerator.cs +++ b/src/Extism.Pdk.MSBuild/FFIGenerator.cs @@ -15,21 +15,30 @@ public FFIGenerator(string extism, Action logError) _extism = extism; } - public IEnumerable GenerateGlueCode(AssemblyDefinition assembly) + public IEnumerable GenerateGlueCode(AssemblyDefinition assembly, string directory) { - var exportedMethods = assembly.MainModule.Types - .SelectMany(t => t.Methods) - .Where(m => m.IsStatic && m.CustomAttributes.Any(a => a.AttributeType.FullName == "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute")) + var assemblies = assembly.MainModule.AssemblyReferences + .Where(r => !r.Name.StartsWith("System") && !r.Name.StartsWith("Microsoft") && r.Name != "Extism.Pdk") + .Select(r => AssemblyDefinition.ReadAssembly(Path.Combine(directory, r.Name + ".dll"))) + .ToList(); + + assemblies.Add(assembly); + + var types = assemblies.SelectMany(a => a.MainModule.Types).ToArray(); + + var exportedMethods = types + .SelectMany(t => t.Methods) + .Where(m => m.IsStatic && m.CustomAttributes.Any(a => a.AttributeType.FullName == "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute")) .ToArray(); // TODO: also find F# module functions - var importedMethods = assembly.MainModule.Types + var importedMethods = types .SelectMany(t => t.Methods) .Where(m => m.HasPInvokeInfo) .ToArray(); var files = GenerateImports(importedMethods, _extism); - files.Add(GenerateExports(assembly.Name.Name + ".dll", exportedMethods)); + files.Add(GenerateExports(exportedMethods)); return files; } @@ -80,7 +89,7 @@ private List GenerateImports(MethodDefinition[] importedMethods, stri return files; } - private FileEntry GenerateExports(string assemblyFileName, MethodDefinition[] exportedMethods) + private FileEntry GenerateExports(MethodDefinition[] exportedMethods) { var sb = new StringBuilder(); @@ -140,6 +149,7 @@ void extism_print_exception(MonoObject* exc) foreach (var method in exportedMethods) { + var assemblyFileName = method.Module.Assembly.Name.Name + ".dll"; var attribute = method.CustomAttributes.First(a => a.AttributeType.Name == "UnmanagedCallersOnlyAttribute"); var exportName = attribute.Fields.FirstOrDefault(p => p.Name == "EntryPoint").Argument.Value?.ToString() ?? method.Name; @@ -199,7 +209,7 @@ private string ToImportStatement(MethodDefinition method) moduleName = "extism:host/user"; } - var functionName = method.PInvokeInfo.EntryPoint ?? method.Name; + var functionName = string.IsNullOrEmpty(method.PInvokeInfo.EntryPoint) ? method.Name : method.PInvokeInfo.EntryPoint; if (!_types.ContainsKey(method.ReturnType.Name)) { diff --git a/src/Extism.Pdk.MSBuild/GenerateFFITask.cs b/src/Extism.Pdk.MSBuild/GenerateFFITask.cs index 7a5ae84..9d88e99 100644 --- a/src/Extism.Pdk.MSBuild/GenerateFFITask.cs +++ b/src/Extism.Pdk.MSBuild/GenerateFFITask.cs @@ -38,7 +38,7 @@ public override bool Execute() var generator = new FFIGenerator(File.ReadAllText(ExtismPath), (string message) => Log.LogError(message)); - foreach (var file in generator.GenerateGlueCode(assembly)) + foreach (var file in generator.GenerateGlueCode(assembly, Path.GetDirectoryName(AssemblyPath))) { File.WriteAllText(Path.Combine(OutputPath, file.Name), file.Content); } diff --git a/src/Extism.Pdk/build/Extism.Pdk.targets b/src/Extism.Pdk/build/Extism.Pdk.targets index 6751487..d88cb4a 100644 --- a/src/Extism.Pdk/build/Extism.Pdk.targets +++ b/src/Extism.Pdk/build/Extism.Pdk.targets @@ -1,4 +1,8 @@ + + + + diff --git a/tests/Extism.Pdk.MsBuild.Tests/ExtismFFIGeneratorTests.cs b/tests/Extism.Pdk.MsBuild.Tests/ExtismFFIGeneratorTests.cs index 6741b5e..bbad8ff 100644 --- a/tests/Extism.Pdk.MsBuild.Tests/ExtismFFIGeneratorTests.cs +++ b/tests/Extism.Pdk.MsBuild.Tests/ExtismFFIGeneratorTests.cs @@ -18,7 +18,7 @@ public void CanHandleEmptyAssemblies() var assembly = CecilExtensions.CreateSampleAssembly("SampleApp"); - var files = generator.GenerateGlueCode(assembly); + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); } [Fact] @@ -34,7 +34,7 @@ public void CanImportFromExtism() _ = type.CreateMethod("DoSomething", typeof(void), ("p1", typeof(int)), ("p2", typeof(byte)), ("p3", typeof(long))) .AddImport("extism", "do_something"); - var files = generator.GenerateGlueCode(assembly); + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); var extismFile = files.Single(f => f.Name == "extism.c"); extismFile.Content.Trim().ShouldBe( @@ -66,7 +66,7 @@ public void CanImportFromCustomModules() _ = type.CreateMethod("GetLength", typeof(int), ("p1", typeof(float))) .AddImport("env", null); - var files = generator.GenerateGlueCode(assembly); + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); var envFile = files.Single(f => f.Name == "env.c"); var expected = File.ReadAllText("snapshots/import-custom-module.txt"); @@ -98,7 +98,7 @@ public void CanExportMethods() _ = type.CreateMethod("DoSomeOtherStuff", typeof(int), ("longParameterNameHere", typeof(double))) .AddExport("fancy_name"); - var files = generator.GenerateGlueCode(assembly); + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); var file = files.Single(f => f.Name == "exports.c"); var expected = File.ReadAllText("snapshots/exports.txt"); @@ -106,6 +106,66 @@ public void CanExportMethods() AssertContent(extism, files, "extism.c"); } + + [Fact] + public void CanExportMethodFromReferences() + { + var env = "// env stuff"; + var generator = new FFIGenerator(env, (m) => { }); + + var lib = CecilExtensions.CreateSampleAssembly("SampleLib"); + + var type = lib.MainModule.CreateType("MyNamespace", "MyClass"); + + _ = type.CreateMethod("DoSomething", typeof(void), ("p1", typeof(int)), ("p2", typeof(byte)), ("p3", typeof(long))) + .AddExport(); + + _ = type.CreateMethod("DoSomeOtherStuff", typeof(int), ("longParameterNameHere", typeof(double))) + .AddExport("fancy_name"); + + lib.Write("SampleLib.dll"); + + var assembly = CecilExtensions.CreateSampleAssembly("SampleApp") + .WithReferenceTo(lib); + + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); + + var file = files.Single(f => f.Name == "exports.c"); + var expected = File.ReadAllText("snapshots/reference-exports.txt"); + file.Content.Trim().ShouldBe(expected, StringCompareShould.IgnoreLineEndings); + + AssertContent(env, files, "extism.c"); + } + + [Fact] + public void CanImportFromReferences() + { + var env = "// env stuff"; + var generator = new FFIGenerator(env, (m) => { }); + + var lib = CecilExtensions.CreateSampleAssembly("SampleLib2"); + var type = lib.MainModule.CreateType("MyNamespace", "MyClass"); + + var m1 = type.CreateMethod("DoSomething", typeof(void), ("p1", typeof(int)), ("p2", typeof(byte)), ("p3", typeof(long))) + .AddImport("host", "do_something"); + + var m2 = type.CreateMethod("GetLength", typeof(int), ("p1", typeof(float))) + .AddImport("host", null); + + lib.Write("SampleLib2.dll"); + + var assembly = CecilExtensions.CreateSampleAssembly("SampleApp") + .WithReferenceTo(lib); + + var files = generator.GenerateGlueCode(assembly, Directory.GetCurrentDirectory()); + + var hostFile = files.Single(f => f.Name == "host.c"); + var expected = File.ReadAllText("snapshots/import-references.txt"); + hostFile.Content.Trim().ShouldBe(expected, StringCompareShould.IgnoreLineEndings); + + AssertContent(env, files, "extism.c"); + files.ShouldNotContain(f => f.Name == "export.c"); + } } public static class CecilExtensions @@ -121,6 +181,12 @@ public static AssemblyDefinition CreateSampleAssembly(string name) return assembly; } + public static AssemblyDefinition WithReferenceTo(this AssemblyDefinition main, AssemblyDefinition reference) + { + main.MainModule.AssemblyReferences.Add(reference.Name); + return main; + } + public static TypeDefinition CreateType(this ModuleDefinition module, string ns, string name) { var type = new TypeDefinition( @@ -152,11 +218,17 @@ public static MethodDefinition CreateMethod(this TypeDefinition type, string nam public static MethodDefinition AddImport(this MethodDefinition method, string moduleName, string? entryPoint = null) { - var pinvokeInfo = new PInvokeInfo(PInvokeAttributes.CallConvCdecl, entryPoint, new ModuleReference(moduleName)); + var moduleReference = new ModuleReference(moduleName); + var pinvokeInfo = new PInvokeInfo(PInvokeAttributes.CallConvCdecl, entryPoint, moduleReference); method.PInvokeInfo = pinvokeInfo; method.Attributes |= MethodAttributes.PInvokeImpl; method.ImplAttributes |= MethodImplAttributes.PreserveSig | (MethodImplAttributes)pinvokeInfo.Attributes; + method.IsPInvokeImpl = true; + method.IsPreserveSig = true; + + method.Module.Assembly.MainModule.ModuleReferences.Add(moduleReference); + return method; } diff --git a/tests/Extism.Pdk.MsBuild.Tests/snapshots/import-references.txt b/tests/Extism.Pdk.MsBuild.Tests/snapshots/import-references.txt new file mode 100644 index 0000000..3dcb814 --- /dev/null +++ b/tests/Extism.Pdk.MsBuild.Tests/snapshots/import-references.txt @@ -0,0 +1,28 @@ +#include +#include +#include + +// https://github.com/dotnet/runtime/blob/v7.0.0/src/mono/wasi/mono-wasi-driver/driver.c +#include + +#include "driver.h" + +#include +#include +#include +#include +#include + +#define IMPORT(a, b) __attribute__((import_module(a), import_name(b))) + +typedef uint64_t ExtismPointer; +IMPORT("host", "do_something") extern void do_something_import(int32_t p1, uint8_t p2, int64_t p3); + +void do_something(int32_t p1, uint8_t p2, int64_t p3) { + do_something_import(p1, p2, p3); +} +IMPORT("host", "GetLength") extern int32_t GetLength_import(float p1); + +int32_t GetLength(float p1) { + return GetLength_import(p1); +} \ No newline at end of file diff --git a/tests/Extism.Pdk.MsBuild.Tests/snapshots/reference-exports.txt b/tests/Extism.Pdk.MsBuild.Tests/snapshots/reference-exports.txt new file mode 100644 index 0000000..e7d0e66 --- /dev/null +++ b/tests/Extism.Pdk.MsBuild.Tests/snapshots/reference-exports.txt @@ -0,0 +1,125 @@ +#include +#include +#include + +// https://github.com/dotnet/runtime/blob/v7.0.0/src/mono/wasi/mono-wasi-driver/driver.c +#include + +#include "driver.h" + +#include +#include +#include +#include +#include + +#define IMPORT(a, b) __attribute__((import_module(a), import_name(b))) + +typedef uint64_t ExtismPointer; +// _initialize +void mono_wasm_load_runtime(const char* unused, int debug_level); + +#ifdef WASI_AFTER_RUNTIME_LOADED_DECLARATIONS +// This is supplied from the MSBuild itemgroup @(WasiAfterRuntimeLoaded) +WASI_AFTER_RUNTIME_LOADED_DECLARATIONS +#endif + +void initialize_runtime() { + mono_wasm_load_runtime("", 0); +} + +// end of _initialize + +void mono_wasm_invoke_method_ref(MonoMethod* method, MonoObject** this_arg_in, void* params[], MonoObject** _out_exc, MonoObject** out_result); +MonoString* mono_object_try_to_string (MonoObject *obj, MonoObject **exc, MonoError *error); +void mono_print_unhandled_exception(MonoObject *exc); + +MonoMethod* method_extism_print_exception; +void extism_print_exception(MonoObject* exc) +{ + if (!method_extism_print_exception) + { + method_extism_print_exception = lookup_dotnet_method("Extism.Pdk.dll", "Extism", "Native", "PrintException", -1); + + if (method_extism_print_exception == NULL) { + printf("Fatal: Failed to find Extism.Native.PrintException"); + } + + assert(method_extism_print_exception); + } + + void* method_params[] = { exc }; + MonoObject* exception = NULL; + MonoObject* result = NULL; + mono_wasm_invoke_method_ref(method_extism_print_exception, NULL, method_params, &exception, &result); + + if (exception != NULL) { + const char* message = "An exception was thrown while trying to print the previous exception. Please check stderr for details."; + mono_print_unhandled_exception(exception); + } +} + +MonoMethod* method_DoSomething; +__attribute__((export_name("DoSomething"))) int DoSomething() +{ + initialize_runtime(); + + if (!method_DoSomething) + { + method_DoSomething = lookup_dotnet_method("SampleLib.dll", "SampleNamespace", "SampleType", "DoSomething", -1); + assert(method_DoSomething); + } + + void* method_params[] = { }; + MonoObject* exception = NULL; + MonoObject* result = NULL; + mono_wasm_invoke_method_ref(method_DoSomething, NULL, method_params, &exception, &result); + + if (exception != NULL) { + const char* message = "An exception was thrown when calling DoSomething. Please check stderr for details."; + mono_print_unhandled_exception(exception); + + extism_print_exception(exception); + return 1; + } + + int int_result = 0; // Default value + + if (result != NULL) { + int_result = *(int*)mono_object_unbox(result); + } + + return int_result; +} +MonoMethod* method_fancy_name; +__attribute__((export_name("fancy_name"))) int fancy_name() +{ + initialize_runtime(); + + if (!method_fancy_name) + { + method_fancy_name = lookup_dotnet_method("SampleLib.dll", "SampleNamespace", "SampleType", "DoSomeOtherStuff", -1); + assert(method_fancy_name); + } + + void* method_params[] = { }; + MonoObject* exception = NULL; + MonoObject* result = NULL; + mono_wasm_invoke_method_ref(method_fancy_name, NULL, method_params, &exception, &result); + + if (exception != NULL) { + const char* message = "An exception was thrown when calling fancy_name. Please check stderr for details."; + mono_print_unhandled_exception(exception); + + extism_print_exception(exception); + return 1; + } + + int int_result = 0; // Default value + + if (result != NULL) { + int_result = *(int*)mono_object_unbox(result); + } + + return int_result; +} \ No newline at end of file diff --git a/tests/Extism.Pdk.WasmTests/Extism.Pdk.WasmTests.csproj b/tests/Extism.Pdk.WasmTests/Extism.Pdk.WasmTests.csproj index c3f1ffb..c1efaa0 100644 --- a/tests/Extism.Pdk.WasmTests/Extism.Pdk.WasmTests.csproj +++ b/tests/Extism.Pdk.WasmTests/Extism.Pdk.WasmTests.csproj @@ -1,16 +1,16 @@ - - net8.0 - enable - enable - - false - true - + + net8.0 + enable + enable + false + true + + diff --git a/tests/Extism.Pdk.WasmTests/KitchenSinkTests.cs b/tests/Extism.Pdk.WasmTests/KitchenSinkTests.cs index 1e64f79..645ad34 100644 --- a/tests/Extism.Pdk.WasmTests/KitchenSinkTests.cs +++ b/tests/Extism.Pdk.WasmTests/KitchenSinkTests.cs @@ -1,124 +1,154 @@ using CliWrap; using CliWrap.Buffered; +using Extism.Sdk.Native; using Shouldly; +using Extism.Sdk; +using System.Text; namespace Extism.Pdk.WasmTests; public class KitchenSinkTests { [Fact] - public async void TestLen() + public void TestLen() { - var path = GetWasmPath("KitchenSink"); - var (stdout, exit, stderr) = await Call(path, "len", new ExtismOptions - { - Loop = 3, - Input = "Hello World!" - }); + using var plugin = CreatePlugin("KitchenSink"); - stderr.ShouldBe(""); - exit.ShouldBe(0); - stdout.ShouldBe("12\n12\n12\n"); + for (var i = 0; i < 3; i++) + { + var result = plugin.Call("len", Encoding.UTF8.GetBytes("Hello World!")); + var stdout = Encoding.UTF8.GetString(result); + stdout.ShouldBe("12"); + } } [Fact] - public async void TestConcat() + public void TestConcat() { - var path = GetWasmPath("KitchenSink"); - var (stdout, exit, stderr) = await Call(path, "concat", new ExtismOptions - { - Loop = 3, - Input = $$"""{ "Separator": ",", "Parts": ["hello", "world!"]}""" - }); + using var plugin = CreatePlugin("KitchenSink"); + + var input = Encoding.UTF8.GetBytes("""{ "Separator": ",", "Parts": ["hello", "world!"]}"""); - stderr.ShouldBe(""); - exit.ShouldBe(0); - stdout.ShouldBe("hello,world!\nhello,world!\nhello,world!\n"); + for (var i = 0; i < 3; i++) + { + var result = plugin.Call("concat", input); + var stdout = Encoding.UTF8.GetString(result); + stdout.ShouldBe("hello,world!"); + } } [Fact] - public async void TestCount() + public void TestCount() { - var path = GetWasmPath("KitchenSink"); - var (stdout, exit, stderr) = await Call(path, "counter", new ExtismOptions - { - Loop = 3, - Input = "" - }); + using var plugin = CreatePlugin("KitchenSink"); - stderr.ShouldBe(""); - exit.ShouldBe(0); - stdout.ShouldBe("1\n2\n3\n"); + for (var i = 1; i <= 3; i++) + { + var result = plugin.Call("counter", []); + var stdout = Encoding.UTF8.GetString(result); + stdout.ShouldBe(i.ToString()); + } } [Theory] [InlineData("", "Greetings, Anonymous!")] [InlineData("John", "Greetings, John!")] - public async void TestConfig(string name, string expected) + public void TestConfig(string name, string expected) { - var path = GetWasmPath("KitchenSink"); - var (stdout, exit, stderr) = await Call(path, "greeter", new ExtismOptions + using var plugin = CreatePlugin("KitchenSink", manifest => { - Loop = 3, - Input = "", - Config = new Dictionary - { - { "name", name } - } + manifest.Config["name"] = name; }); - stderr.ShouldBe(""); - exit.ShouldBe(0); - stdout.ShouldBe($"{expected}\n{expected}\n{expected}\n"); + for (var i = 0; i < 3; i++) + { + var result = plugin.Call("greeter", []); + var stdout = Encoding.UTF8.GetString(result); + stdout.ShouldBe(expected); + } } [Theory] - [InlineData("123", "jsonplaceholder.*.com", true)] + [InlineData("123", "jsonplaceholder.typicode.com", true)] [InlineData("123", "", false)] [InlineData("", "jsonplaceholder.typicode.com", false)] - public async void TestHttp(string token, string allowedHost, bool expected) + public void TestHttp(string token, string allowedHost, bool expected) { - var path = GetWasmPath("KitchenSink"); - var (stdout, exit, stderr) = await Call(path, "get_todo", new ExtismOptions + using var plugin = CreatePlugin("KitchenSink", manifest => { - Loop = 3, - Input = "1", - Config = new Dictionary - { - { "api-token", token } - }, - AllowedHosts = [allowedHost] + manifest.Config["api-token"] = token; + + manifest.AllowedHosts.Add(allowedHost); }); + var input = Encoding.UTF8.GetBytes("1"); + if (expected) { - exit.ShouldBe(0); + var result = plugin.Call("get_todo", input); + var stdout = Encoding.UTF8.GetString(result); + stdout.ShouldNotContain("error"); } else { - exit.ShouldNotBe(0); + Should.Throw(() => plugin.Call("get_todo", input)); } } [Fact] - public async void TestThrow() + public void TestThrow() { - var path = GetWasmPath("KitchenSink"); - var (_, exit, stderr) = await Call(path, "throw", new ExtismOptions + using var plugin = CreatePlugin("KitchenSink"); + + for (var i = 0; i < 3; i++) { - Loop = 3, - Input = "Hello World!" - }); + Should.Throw(() => plugin.Call("throw", [])) + .Message.ShouldContain("Something bad happened."); + } + } + + [Fact] + public void TestReferencedExport() + { + using var plugin = CreatePlugin("KitchenSink"); - stderr.ShouldContain("Something bad happened."); - exit.ShouldBe(1); + for (var i = 0; i < 3; i++) + { + var result = plugin.Call("samplelib_export", []); + } } private string GetWasmPath(string name) { return Path.Combine( Environment.CurrentDirectory, - $"../../../../../samples/{name}/bin/Debug/net8.0/wasi-wasm/AppBundle/{name}.wasm"); + $"../../../../../samples/{name}/bin/Release/net8.0/wasi-wasm/AppBundle/{name}.wasm"); + } + + private Plugin CreatePlugin( + string name, + Action? config = null, + HostFunction[]? hostFunctions = null) + { + var source = new PathWasmSource(GetWasmPath(name)); + var manifest = new Manifest(source); + + if (config is not null) + { + config(manifest); + } + + HostFunction[] functions = [ + HostFunction.FromMethod("samplelib_import", 0, (cp) => { }), + .. (hostFunctions ?? []) + ]; + + foreach (var function in functions) + { + function.SetNamespace("env"); + } + + return new Plugin(manifest, functions, withWasi: true); } private async Task<(string, int, string)> Call(string wasmPath, string functionName, ExtismOptions options)