From 80a7822f0c204156a0ee7e28d79d4d95cd549647 Mon Sep 17 00:00:00 2001 From: Tomohisa Takaoka Date: Fri, 30 Jan 2026 09:39:07 -0800 Subject: [PATCH 1/3] Add preview2 shim and core module extractor --- docs/articles/toc.yml | 2 + docs/articles/wasi-preview2-shim.md | 80 ++++++ native/wasmtime-preview2-shim/.gitignore | 1 + native/wasmtime-preview2-shim/Cargo.toml | 15 + native/wasmtime-preview2-shim/README.md | 45 +++ native/wasmtime-preview2-shim/src/lib.rs | 343 +++++++++++++++++++++++ src/ComponentCoreExtractor.cs | 70 +++++ src/Preview2ComponentRunner.cs | 213 ++++++++++++++ 8 files changed, 769 insertions(+) create mode 100644 docs/articles/wasi-preview2-shim.md create mode 100644 native/wasmtime-preview2-shim/.gitignore create mode 100644 native/wasmtime-preview2-shim/Cargo.toml create mode 100644 native/wasmtime-preview2-shim/README.md create mode 100644 native/wasmtime-preview2-shim/src/lib.rs create mode 100644 src/ComponentCoreExtractor.cs create mode 100644 src/Preview2ComponentRunner.cs diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 4529c030..51e2fbd1 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -1,2 +1,4 @@ - name: Introduction to the .NET embedding of Wasmtime href: intro.md +- name: WASI Preview2 Component Runner (Shim) + href: wasi-preview2-shim.md diff --git a/docs/articles/wasi-preview2-shim.md b/docs/articles/wasi-preview2-shim.md new file mode 100644 index 00000000..e24cc052 --- /dev/null +++ b/docs/articles/wasi-preview2-shim.md @@ -0,0 +1,80 @@ +# WASI Preview2 Component Runner (Shim) + +This repository includes an optional native shim for running WASI 0.2 components +in-process. It pairs a small Rust `cdylib` with a managed wrapper: + +- Rust shim: `native/wasmtime-preview2-shim` +- C# wrapper: `src/Preview2ComponentRunner.cs` + +## Build the native shim + +The shim uses Wasmtime 35 and requires Rust 1.86+. + +``` +cd native/wasmtime-preview2-shim +cargo build --release +``` + +Add the resulting library to the runtime loader path: + +- macOS: `libwasmtime_preview2_shim.dylib` +- Linux: `libwasmtime_preview2_shim.so` + +## Run a component + +``` +using Wasmtime; + +var exitCode = Preview2ComponentRunner.Run( + componentPath: "dotnet.wasm", + args: new[] { "arg1", "arg2" }, + environment: new Dictionary + { + ["DOTNET_EnableDiagnostics"] = "0" + }, + preopens: new[] + { + new PreopenDirectory(".", ".") + }, + inheritStdio: true, + inheritEnvironment: true, + inheritNetwork: true +); +``` + +The runner wires `wasi:cli/run` and returns the component exit code. + +## Library components (no `wasi:cli/run`) + +Library-style .NET components do not export `wasi:cli/run`. To invoke their +exported C-ABI functions in-process, extract the main core module and use the +existing Wasmtime .NET core APIs: + +``` +using Wasmtime; + +ComponentCoreExtractor.ExtractMainModule( + componentPath: "dotnet.wasm", + outputPath: "core-module.wasm"); + +using var config = new Config().WithWasmThreads(true); +using var engine = new Engine(config); +using var module = Module.FromFile(engine, "core-module.wasm"); +using var store = new Store(engine); + +store.SetWasiConfiguration( + new WasiConfiguration() + .WithInheritedStandardOutput() + .WithInheritedStandardError() + .WithPreopenedDirectory(".", ".")); + +using var linker = new Linker(engine); +linker.DefineWasi(); + +using var instance = linker.Instantiate(store, module); +var alloc = instance.GetFunction("alloc"); +var dealloc = instance.GetAction("dealloc"); +``` + +The extracted core module imports `wasi_snapshot_preview1`, so the core WASI +linker (`DefineWasi`) is required. diff --git a/native/wasmtime-preview2-shim/.gitignore b/native/wasmtime-preview2-shim/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/native/wasmtime-preview2-shim/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/native/wasmtime-preview2-shim/Cargo.toml b/native/wasmtime-preview2-shim/Cargo.toml new file mode 100644 index 00000000..c63038f9 --- /dev/null +++ b/native/wasmtime-preview2-shim/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wasmtime-preview2-shim" +version = "0.1.0" +edition = "2021" +rust-version = "1.86" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1.0" +wasmtime = "35.0.0" +wasmtime-wasi = "35.0.0" +wasmtime-wasi-http = "35.0.0" +wasmparser = "0.244.0" diff --git a/native/wasmtime-preview2-shim/README.md b/native/wasmtime-preview2-shim/README.md new file mode 100644 index 00000000..56083033 --- /dev/null +++ b/native/wasmtime-preview2-shim/README.md @@ -0,0 +1,45 @@ +# WASI Preview2 Shim + +This crate builds a small native shim that runs WASI 0.2 (preview2) components +in-process using Wasmtime. The shim is invoked from .NET via P/Invoke. + +## Requirements + +- Rust 1.86+ (Wasmtime 35 requires a newer toolchain) + +## Build + +``` +cd native/wasmtime-preview2-shim +cargo build --release +``` + +Artifacts: + +- macOS: `target/release/libwasmtime_preview2_shim.dylib` +- Linux: `target/release/libwasmtime_preview2_shim.so` + +## Loading from .NET + +The managed wrapper expects the library name `wasmtime_preview2_shim`. +Ensure the compiled library is on the dynamic loader search path or copied +next to the .NET application. + +Example (macOS): + +``` +export DYLD_LIBRARY_PATH=/path/to/native/wasmtime-preview2-shim/target/release:$DYLD_LIBRARY_PATH +``` + +Example (Linux): + +``` +export LD_LIBRARY_PATH=/path/to/native/wasmtime-preview2-shim/target/release:$LD_LIBRARY_PATH +``` + +## Core module extraction (library components) + +Library components that do not export `wasi:cli/run` can be handled by extracting +their main core module and using the existing Wasmtime .NET core APIs. + +The shim exposes `wasmtime_preview2_extract_core_module` for this purpose. diff --git a/native/wasmtime-preview2-shim/src/lib.rs b/native/wasmtime-preview2-shim/src/lib.rs new file mode 100644 index 00000000..b2452b5d --- /dev/null +++ b/native/wasmtime-preview2-shim/src/lib.rs @@ -0,0 +1,343 @@ +use anyhow::{bail, Context, Result}; +use std::ffi::{CStr, CString}; +use std::ops::Range; +use std::os::raw::{c_char, c_int}; +use wasmtime::{Engine, Store}; +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime_wasi::{DirPerms, FilePerms}; +use wasmtime_wasi::p2::{IoView, WasiCtx, WasiCtxBuilder, WasiView}; +use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; +use wasmparser::{Parser, Payload}; + +#[repr(C)] +pub struct Preview2Result { + pub exit_code: c_int, + pub error_message: *mut c_char, +} + +struct Preview2State { + table: ResourceTable, + wasi: WasiCtx, + http: WasiHttpCtx, +} + +impl IoView for Preview2State { + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +impl WasiView for Preview2State { + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } +} + +impl WasiHttpView for Preview2State { + fn ctx(&mut self) -> &mut WasiHttpCtx { + &mut self.http + } +} + +#[no_mangle] +pub unsafe extern "C" fn wasmtime_preview2_run_component( + component_path: *const c_char, + argv: *const *const c_char, + argc: usize, + env: *const *const c_char, + envc: usize, + preopens: *const *const c_char, + preopen_count: usize, + inherit_stdio: bool, + inherit_env: bool, + inherit_network: bool, + exit_code_out: *mut c_int, + error_message_out: *mut *mut c_char, +) -> c_int { + let result = std::panic::catch_unwind(|| { + run_component_inner( + component_path, + argv, + argc, + env, + envc, + preopens, + preopen_count, + inherit_stdio, + inherit_env, + inherit_network, + ) + }); + + match result { + Ok(Ok(exit_code)) => { + if !exit_code_out.is_null() { + unsafe { *exit_code_out = exit_code }; + } + if !error_message_out.is_null() { + unsafe { *error_message_out = std::ptr::null_mut() }; + } + 0 + } + Ok(Err(err)) => { + write_error(err, error_message_out); + 1 + } + Err(_) => { + write_error(anyhow::anyhow!("panic while executing component"), error_message_out); + 2 + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn wasmtime_preview2_free_error(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + unsafe { + let _ = CString::from_raw(ptr); + } +} + +#[no_mangle] +pub unsafe extern "C" fn wasmtime_preview2_extract_core_module( + component_path: *const c_char, + output_path: *const c_char, + error_message_out: *mut *mut c_char, +) -> c_int { + let result = std::panic::catch_unwind(|| { + extract_core_module_inner(component_path, output_path) + }); + + match result { + Ok(Ok(())) => { + if !error_message_out.is_null() { + unsafe { *error_message_out = std::ptr::null_mut() }; + } + 0 + } + Ok(Err(err)) => { + write_error(err, error_message_out); + 1 + } + Err(_) => { + write_error(anyhow::anyhow!("panic while extracting core module"), error_message_out); + 2 + } + } +} + +fn write_error(err: anyhow::Error, error_message_out: *mut *mut c_char) { + if error_message_out.is_null() { + return; + } + let msg = format!("{err:#}"); + match CString::new(msg) { + Ok(cstr) => unsafe { + *error_message_out = cstr.into_raw(); + }, + Err(_) => unsafe { + *error_message_out = std::ptr::null_mut(); + }, + } +} + +unsafe fn run_component_inner( + component_path: *const c_char, + argv: *const *const c_char, + argc: usize, + env: *const *const c_char, + envc: usize, + preopens: *const *const c_char, + preopen_count: usize, + inherit_stdio: bool, + inherit_env: bool, + inherit_network: bool, +) -> Result { + if component_path.is_null() { + bail!("component path is null"); + } + + let component_path = unsafe { CStr::from_ptr(component_path) } + .to_str() + .context("component path is not valid UTF-8")?; + + let args = unsafe { read_cstr_array(argv, argc)? }; + let envs = unsafe { read_cstr_array(env, envc)? }; + let preopen_specs = unsafe { read_cstr_array(preopens, preopen_count)? }; + + let mut builder = WasiCtxBuilder::new(); + if inherit_stdio { + builder.inherit_stdio(); + } + if inherit_env { + builder.inherit_env(); + } + if inherit_network { + builder.inherit_network(); + } + + if !args.is_empty() { + builder.args(&args); + } + + for env in envs { + let (key, value) = split_env(&env); + builder.env(key, value); + } + + for spec in preopen_specs { + let (host, guest) = split_preopen(&spec)?; + builder.preopened_dir(host, guest, DirPerms::all(), FilePerms::all())?; + } + + let engine = Engine::default(); + let component = Component::from_file(&engine, component_path) + .with_context(|| format!("failed to load component: {component_path}"))?; + + let mut linker = Linker::::new(&engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker) + .context("failed to add WASI preview2 to linker")?; + wasmtime_wasi_http::add_only_http_to_linker_sync(&mut linker) + .context("failed to add WASI HTTP to linker")?; + + let state = Preview2State { + table: ResourceTable::new(), + wasi: builder.build(), + http: WasiHttpCtx::new(), + }; + + let mut store = Store::new(&engine, state); + + let command = wasmtime_wasi::p2::bindings::sync::Command::instantiate(&mut store, &component, &linker) + .context("failed to instantiate component")?; + + let result = command + .wasi_cli_run() + .call_run(&mut store) + .context("failed to call wasi:cli/run")?; + + Ok(match result { + Ok(()) => 0, + Err(()) => 1, + }) +} + +unsafe fn extract_core_module_inner( + component_path: *const c_char, + output_path: *const c_char, +) -> Result<()> { + if component_path.is_null() { + bail!("component path is null"); + } + if output_path.is_null() { + bail!("output path is null"); + } + + let component_path = unsafe { CStr::from_ptr(component_path) } + .to_str() + .context("component path is not valid UTF-8")?; + let output_path = unsafe { CStr::from_ptr(output_path) } + .to_str() + .context("output path is not valid UTF-8")?; + + let bytes = std::fs::read(component_path) + .with_context(|| format!("failed to read component: {component_path}"))?; + + let mut ranges = Vec::new(); + collect_module_ranges(&bytes, 0, &mut ranges) + .context("failed to parse component for core modules")?; + + let range = ranges + .into_iter() + .max_by_key(|r| r.end.saturating_sub(r.start)) + .ok_or_else(|| anyhow::anyhow!("no core modules found in component"))?; + + if range.end > bytes.len() || range.start >= range.end { + bail!("core module range is out of bounds"); + } + + std::fs::write(output_path, &bytes[range]) + .with_context(|| format!("failed to write core module to {output_path}"))?; + + Ok(()) +} + +fn collect_module_ranges( + bytes: &[u8], + base_offset: usize, + ranges: &mut Vec>, +) -> Result<()> { + for payload in Parser::new(0).parse_all(bytes) { + match payload.context("failed to parse component payload")? { + Payload::ModuleSection { unchecked_range, .. } => { + let start = base_offset + unchecked_range.start; + let end = base_offset + unchecked_range.end; + if end > base_offset + bytes.len() { + bail!("module range is out of bounds"); + } + ranges.push(start..end); + } + Payload::ComponentSection { unchecked_range, .. } => { + let start = unchecked_range.start; + let end = unchecked_range.end; + if end > bytes.len() || start >= end { + bail!("nested component range is out of bounds"); + } + collect_module_ranges(&bytes[start..end], base_offset + start, ranges)?; + } + _ => {} + } + } + + Ok(()) +} + +unsafe fn read_cstr_array(ptr: *const *const c_char, len: usize) -> Result> { + if len == 0 { + return Ok(Vec::new()); + } + if ptr.is_null() { + bail!("string array pointer is null but length is non-zero"); + } + + let mut values = Vec::with_capacity(len); + for i in 0..len { + let item = unsafe { *ptr.add(i) }; + if item.is_null() { + bail!("string array entry {i} is null"); + } + let value = unsafe { CStr::from_ptr(item) } + .to_str() + .context("string array entry is not valid UTF-8")?; + values.push(value.to_owned()); + } + Ok(values) +} + +fn split_env(env: &str) -> (&str, &str) { + match env.split_once('=') { + Some((k, v)) => (k, v), + None => (env, ""), + } +} + +fn split_preopen(spec: &str) -> Result<(&str, &str)> { + if let Some((host, guest)) = spec.split_once("::") { + if host.is_empty() || guest.is_empty() { + bail!("invalid preopen mapping '{spec}'"); + } + return Ok((host, guest)); + } + if let Some((host, guest)) = spec.split_once('=') { + if host.is_empty() || guest.is_empty() { + bail!("invalid preopen mapping '{spec}'"); + } + return Ok((host, guest)); + } + if spec.is_empty() { + bail!("invalid preopen mapping '{spec}'"); + } + Ok((spec, spec)) +} diff --git a/src/ComponentCoreExtractor.cs b/src/ComponentCoreExtractor.cs new file mode 100644 index 00000000..8af46a01 --- /dev/null +++ b/src/ComponentCoreExtractor.cs @@ -0,0 +1,70 @@ +using System; +using System.Runtime.InteropServices; + +namespace Wasmtime +{ + /// + /// Extracts the main core module from a WASI 0.2 component for core-module execution. + /// + public static class ComponentCoreExtractor + { + private const string ShimLibraryName = "wasmtime_preview2_shim"; + + public static void ExtractMainModule(string componentPath, string outputPath) + { + if (componentPath is null) + { + throw new ArgumentNullException(nameof(componentPath)); + } + + if (outputPath is null) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var result = Native.wasmtime_preview2_extract_core_module(componentPath, outputPath, out var errorPtr); + if (result != 0) + { + var message = errorPtr == IntPtr.Zero + ? "Failed to extract core module from component." + : PtrToStringUtf8NullTerminated(errorPtr); + + if (errorPtr != IntPtr.Zero) + { + Native.wasmtime_preview2_free_error(errorPtr); + } + + throw new WasmtimeException(message); + } + } + + private static class Native + { + [DllImport(ShimLibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern int wasmtime_preview2_extract_core_module( + [MarshalAs(Extensions.LPUTF8Str)] string componentPath, + [MarshalAs(Extensions.LPUTF8Str)] string outputPath, + out IntPtr errorMessage); + + [DllImport(ShimLibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void wasmtime_preview2_free_error(IntPtr error); + } + + private static unsafe string PtrToStringUtf8NullTerminated(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + { + return string.Empty; + } + + var length = 0; + var bytes = (byte*)ptr; + while (bytes[length] != 0) + { + length++; + } + + return Extensions.PtrToStringUTF8(ptr, length); + } + } +} diff --git a/src/Preview2ComponentRunner.cs b/src/Preview2ComponentRunner.cs new file mode 100644 index 00000000..bbbd7dae --- /dev/null +++ b/src/Preview2ComponentRunner.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace Wasmtime +{ + /// + /// Represents a preopened directory mapping for WASI preview2. + /// + public readonly struct PreopenDirectory + { + public PreopenDirectory(string hostPath, string guestPath) + { + HostPath = hostPath ?? throw new ArgumentNullException(nameof(hostPath)); + GuestPath = guestPath ?? throw new ArgumentNullException(nameof(guestPath)); + } + + public string HostPath { get; } + public string GuestPath { get; } + } + + /// + /// Runs WASI preview2 (WASI 0.2) components through the native preview2 shim. + /// + public static class Preview2ComponentRunner + { + private const string ShimLibraryName = "wasmtime_preview2_shim"; + + public static int Run( + string componentPath, + IReadOnlyList? args = null, + IReadOnlyDictionary? environment = null, + IReadOnlyList? preopens = null, + bool inheritStdio = true, + bool inheritEnvironment = true, + bool inheritNetwork = true) + { + if (componentPath is null) + { + throw new ArgumentNullException(nameof(componentPath)); + } + + var argList = new List(args ?? Array.Empty()); + var envList = new List(); + if (environment is not null) + { + foreach (var kvp in environment) + { + envList.Add($"{kvp.Key}={kvp.Value}"); + } + } + + var preopenList = new List(); + if (preopens is not null) + { + foreach (var preopen in preopens) + { + if (string.IsNullOrEmpty(preopen.HostPath)) + { + throw new ArgumentException("Preopen host path must not be empty.", nameof(preopens)); + } + if (string.IsNullOrEmpty(preopen.GuestPath)) + { + throw new ArgumentException("Preopen guest path must not be empty.", nameof(preopens)); + } + preopenList.Add($"{preopen.HostPath}::{preopen.GuestPath}"); + } + } + + return RunCore( + componentPath, + argList, + envList, + preopenList, + inheritStdio, + inheritEnvironment, + inheritNetwork); + } + + private static unsafe int RunCore( + string componentPath, + IReadOnlyList args, + IReadOnlyList env, + IReadOnlyList preopens, + bool inheritStdio, + bool inheritEnvironment, + bool inheritNetwork) + { + var (argPtrs, argHandles) = ToUtf8PtrArray(args); + var (envPtrs, envHandles) = ToUtf8PtrArray(env); + var (preopenPtrs, preopenHandles) = ToUtf8PtrArray(preopens); + + try + { + fixed (byte** argPtr = argPtrs) + fixed (byte** envPtr = envPtrs) + fixed (byte** preopenPtr = preopenPtrs) + { + byte** argUse = argPtrs.Length == 0 ? null : argPtr; + byte** envUse = envPtrs.Length == 0 ? null : envPtr; + byte** preopenUse = preopenPtrs.Length == 0 ? null : preopenPtr; + + var result = Native.wasmtime_preview2_run_component( + componentPath, + argUse, + (nuint)argPtrs.Length, + envUse, + (nuint)envPtrs.Length, + preopenUse, + (nuint)preopenPtrs.Length, + inheritStdio, + inheritEnvironment, + inheritNetwork, + out var exitCode, + out var errorPtr); + + if (result != 0) + { + var message = errorPtr == IntPtr.Zero + ? "Failed to run WASI preview2 component." + : PtrToStringUtf8NullTerminated(errorPtr); + + if (errorPtr != IntPtr.Zero) + { + Native.wasmtime_preview2_free_error(errorPtr); + } + + throw new WasmtimeException(message); + } + + return exitCode; + } + } + finally + { + FreeHandles(argHandles); + FreeHandles(envHandles); + FreeHandles(preopenHandles); + } + } + + private static void FreeHandles(GCHandle[] handles) + { + for (int i = 0; i < handles.Length; i++) + { + if (handles[i].IsAllocated) + { + handles[i].Free(); + } + } + } + + private static unsafe string PtrToStringUtf8NullTerminated(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + { + return string.Empty; + } + + var length = 0; + var bytes = (byte*)ptr; + while (bytes[length] != 0) + { + length++; + } + + return Extensions.PtrToStringUTF8(ptr, length); + } + + private static unsafe (byte*[] ptrs, GCHandle[] handles) ToUtf8PtrArray(IReadOnlyList strings) + { + if (strings.Count == 0) + { + return (new byte*[0], Array.Empty()); + } + + var handles = new GCHandle[strings.Count]; + var ptrs = new byte*[strings.Count]; + for (int i = 0; i < strings.Count; ++i) + { + handles[i] = GCHandle.Alloc( + Encoding.UTF8.GetBytes(strings[i] + '\0'), + GCHandleType.Pinned + ); + ptrs[i] = (byte*)handles[i].AddrOfPinnedObject(); + } + + return (ptrs, handles); + } + + private static unsafe class Native + { + [DllImport(ShimLibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern int wasmtime_preview2_run_component( + [MarshalAs(Extensions.LPUTF8Str)] string componentPath, + byte** argv, + nuint argc, + byte** env, + nuint envc, + byte** preopens, + nuint preopenCount, + [MarshalAs(UnmanagedType.I1)] bool inheritStdio, + [MarshalAs(UnmanagedType.I1)] bool inheritEnvironment, + [MarshalAs(UnmanagedType.I1)] bool inheritNetwork, + out int exitCode, + out IntPtr errorMessage); + + [DllImport(ShimLibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void wasmtime_preview2_free_error(IntPtr error); + } + } +} From 292a42d8c1cbccc8b2042d1f324b7a7c5ca92109 Mon Sep 17 00:00:00 2001 From: Tomohisa Takaoka Date: Fri, 30 Jan 2026 10:26:52 -0800 Subject: [PATCH 2/3] Add WASI preview2 stub helpers --- docs/articles/wasi-preview2-shim.md | 9 +++- src/Linker.cs | 66 ++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/articles/wasi-preview2-shim.md b/docs/articles/wasi-preview2-shim.md index e24cc052..a07f5a0a 100644 --- a/docs/articles/wasi-preview2-shim.md +++ b/docs/articles/wasi-preview2-shim.md @@ -70,6 +70,7 @@ store.SetWasiConfiguration( using var linker = new Linker(engine); linker.DefineWasi(); +linker.DefineWasiPreview2Stubs(); using var instance = linker.Instantiate(store, module); var alloc = instance.GetFunction("alloc"); @@ -77,4 +78,10 @@ var dealloc = instance.GetAction("dealloc"); ``` The extracted core module imports `wasi_snapshot_preview1`, so the core WASI -linker (`DefineWasi`) is required. +linker (`DefineWasi`) is required. Some extracted modules also import: + +- `wasi_snapshot_preview1.adapter_close_badfd` +- `wasi_snapshot_preview1.adapter_open_badfd` +- WASI preview2 `[resource-drop]` functions (no-op stubs are sufficient) + +`DefineWasiPreview2Stubs` provides these stubs for common preview2 interfaces. diff --git a/src/Linker.cs b/src/Linker.cs index 6556cdb3..0363faf6 100644 --- a/src/Linker.cs +++ b/src/Linker.cs @@ -150,6 +150,70 @@ public void DefineWasi() } } + /// + /// Defines preview1 adapter stubs expected by some extracted core modules. + /// + /// + /// These stubs return EBADF for invalid file descriptors. + /// + public void DefineWasiPreview1AdapterStubs() + { + DefineFunction( + "wasi_snapshot_preview1", + "adapter_close_badfd", + (Caller caller, int fd) => 8); + + DefineFunction( + "wasi_snapshot_preview1", + "adapter_open_badfd", + (Caller caller, int fd) => 8); + } + + /// + /// Defines common WASI preview2 resource-drop stubs as no-ops. + /// + /// + /// These are useful when running extracted core modules from components. + /// + public void DefineWasiPreview2ResourceDropStubs() + { + DefineResourceDropStub("wasi:io/error@0.2.0", "error"); + DefineResourceDropStub("wasi:io/poll@0.2.0", "pollable"); + DefineResourceDropStub("wasi:io/streams@0.2.0", "input-stream"); + DefineResourceDropStub("wasi:io/streams@0.2.0", "output-stream"); + DefineResourceDropStub("wasi:sockets/udp@0.2.0", "udp-socket"); + DefineResourceDropStub("wasi:sockets/udp@0.2.0", "incoming-datagram-stream"); + DefineResourceDropStub("wasi:sockets/udp@0.2.0", "outgoing-datagram-stream"); + DefineResourceDropStub("wasi:sockets/tcp@0.2.0", "tcp-socket"); + } + + /// + /// Defines both preview1 adapter stubs and preview2 resource-drop stubs. + /// + public void DefineWasiPreview2Stubs() + { + DefineWasiPreview1AdapterStubs(); + DefineWasiPreview2ResourceDropStubs(); + } + + private void DefineResourceDropStub(string module, string resourceName) + { + if (module is null) + { + throw new ArgumentNullException(nameof(module)); + } + + if (resourceName is null) + { + throw new ArgumentNullException(nameof(resourceName)); + } + + DefineFunction( + module, + $"[resource-drop]{resourceName}", + (Caller caller, int handle) => { }); + } + /// /// Defines an instance with the specified name in the linker. /// @@ -540,4 +604,4 @@ internal static class Native private readonly Handle handle; } -} \ No newline at end of file +} From a096277d6291bf5b858ffd700f4b3bc075936f2f Mon Sep 17 00:00:00 2001 From: Tomohisa Takaoka Date: Fri, 30 Jan 2026 14:48:37 -0800 Subject: [PATCH 3/3] Add tests for preview2 stub helpers --- tests/WasiPreview2StubTests.cs | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/WasiPreview2StubTests.cs diff --git a/tests/WasiPreview2StubTests.cs b/tests/WasiPreview2StubTests.cs new file mode 100644 index 00000000..f174f4b5 --- /dev/null +++ b/tests/WasiPreview2StubTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using Xunit; + +namespace Wasmtime.Tests +{ + public class WasiPreview2StubTests + { + [Fact] + public void DefineWasiPreview1AdapterStubs_SatisfiesAdapterImports() + { + using var engine = new Engine(); + using var store = new Store(engine); + using var linker = new Linker(engine); + linker.DefineWasiPreview1AdapterStubs(); + + const string wat = "(module\n" + + " (import \"wasi_snapshot_preview1\" \"adapter_close_badfd\" (func $f (param i32) (result i32)))\n" + + " (func (export \"call\") (param i32) (result i32)\n" + + " (call $f (local.get 0))))"; + + using var module = Module.FromText(engine, "adapter", wat); + var instance = linker.Instantiate(store, module); + + var call = instance.GetFunction("call"); + call.Should().NotBeNull(); + call!(123).Should().Be(8); + } + + [Fact] + public void DefineWasiPreview2ResourceDropStubs_SatisfiesResourceDropImports() + { + using var engine = new Engine(); + using var store = new Store(engine); + using var linker = new Linker(engine); + linker.DefineWasiPreview2ResourceDropStubs(); + + const string wat = "(module\n" + + " (import \"wasi:io/poll@0.2.0\" \"[resource-drop]pollable\" (func $drop (param i32)))\n" + + " (func (export \"drop\") (param i32)\n" + + " (call $drop (local.get 0))))"; + + using var module = Module.FromText(engine, "resource_drop", wat); + var instance = linker.Instantiate(store, module); + + var drop = instance.GetAction("drop"); + drop.Should().NotBeNull(); + drop!(0); + } + + [Fact] + public void DefineWasiPreview2Stubs_SatisfiesAdapterAndResourceDropImports() + { + using var engine = new Engine(); + using var store = new Store(engine); + using var linker = new Linker(engine); + linker.DefineWasiPreview2Stubs(); + + const string wat = "(module\n" + + " (import \"wasi_snapshot_preview1\" \"adapter_open_badfd\" (func $open (param i32) (result i32)))\n" + + " (import \"wasi:sockets/tcp@0.2.0\" \"[resource-drop]tcp-socket\" (func $drop (param i32)))\n" + + " (func (export \"call\") (param i32) (result i32)\n" + + " (call $drop (local.get 0))\n" + + " (call $open (local.get 0))))"; + + using var module = Module.FromText(engine, "combined", wat); + var instance = linker.Instantiate(store, module); + + var call = instance.GetFunction("call"); + call.Should().NotBeNull(); + call!(9).Should().Be(8); + } + } +}