diff --git a/README.md b/README.md index 45d3694f..8f16c13d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,15 @@ zwasm_module_invoke(mod, "f", NULL, 0, results, 1); zwasm_module_delete(mod); ``` +For configured execution limits and behavior, use `zwasm_config_t`: + +- `zwasm_config_set_fuel`, `zwasm_config_set_timeout`, `zwasm_config_set_max_memory` +- `zwasm_config_set_force_interpreter` +- `zwasm_config_set_cancellable` (default: `true`) + +When fuel is configured, it applies to module startup (`start`/`_start`) as well as +subsequent invocations. Fuel consumed during startup reduces the remaining budget. + See the [C API chapter](https://clojurewasm.github.io/zwasm/en/c-api.html) in the book for the full API reference. ## Examples diff --git a/book/en/src/c-api.md b/book/en/src/c-api.md index 64e1e040..13d16c2b 100644 --- a/book/en/src/c-api.md +++ b/book/en/src/c-api.md @@ -152,6 +152,19 @@ Functions are grouped by domain. All signatures live in `include/zwasm.h`. |----------|-------------| | `zwasm_last_error_message()` | Last error as a null-terminated string. Returns `""` if no error. Thread-local. | +### Runtime configuration + +| Function | Description | +|----------|-------------| +| `zwasm_config_new()` | Create a runtime config handle. | +| `zwasm_config_delete(config)` | Free a runtime config handle. | +| `zwasm_config_set_allocator(config, alloc_fn, free_fn, ctx)` | Set custom allocator callbacks for runtime bookkeeping memory. | +| `zwasm_config_set_fuel(config, fuel)` | Set instruction fuel limit. | +| `zwasm_config_set_timeout(config, timeout_ms)` | Set wall-clock timeout in milliseconds. | +| `zwasm_config_set_max_memory(config, max_memory_bytes)` | Set linear-memory growth ceiling in bytes. | +| `zwasm_config_set_force_interpreter(config, force_interpreter)` | Disable RegIR/JIT and force interpreter-only execution. | +| `zwasm_config_set_cancellable(config, enabled)` | Enable/disable periodic JIT cancellation checks. | + ### Module lifecycle | Function | Description | @@ -160,6 +173,8 @@ Functions are grouped by domain. All signatures live in `include/zwasm.h`. | `zwasm_module_new_wasi(wasm_ptr, len)` | Create WASI module with default capabilities. | | `zwasm_module_new_wasi_configured(wasm_ptr, len, config)` | Create WASI module with custom config. | | `zwasm_module_new_with_imports(wasm_ptr, len, imports)` | Create module with host function imports. | +| `zwasm_module_new_configured(wasm_ptr, len, config)` | Create module with optional runtime config. | +| `zwasm_module_new_wasi_configured2(wasm_ptr, len, wasi_config, config)` | Create WASI module with both WASI config and runtime config. | | `zwasm_module_delete(module)` | Free all module resources. | | `zwasm_module_validate(wasm_ptr, len)` | Validate binary without instantiation. | @@ -169,6 +184,7 @@ Functions are grouped by domain. All signatures live in `include/zwasm.h`. |----------|-------------| | `zwasm_module_invoke(module, name, args, nargs, results, nresults)` | Invoke an exported function by name. | | `zwasm_module_invoke_start(module)` | Invoke `_start` (WASI entry point). | +| `zwasm_module_cancel(module)` | Request cancellation of a currently running invocation (thread-safe). | ### Export introspection @@ -273,26 +289,35 @@ The `env` pointer lets you pass arbitrary context (a struct, file handle, etc.) ## WASI programs -Use the config builder pattern to run WASI programs with custom settings: +Use a `zwasm_wasi_config_t` for argv/env/preopens, and optionally combine it with `zwasm_config_t` for fuel/timeout/memory limits: ```c /* Create and configure WASI */ -zwasm_wasi_config_t *config = zwasm_wasi_config_new(); +zwasm_wasi_config_t *wasi_config = zwasm_wasi_config_new(); const char *argv[] = {"myapp", "--verbose"}; -zwasm_wasi_config_set_argv(config, 2, argv); +zwasm_wasi_config_set_argv(wasi_config, 2, argv); + +zwasm_wasi_config_preopen_dir(wasi_config, "/tmp/data", 9, "/data", 5); -zwasm_wasi_config_preopen_dir(config, "/tmp/data", 9, "/data", 5); +/* Optional runtime config */ +zwasm_config_t *config = zwasm_config_new(); +zwasm_config_set_fuel(config, 1000000); +zwasm_config_set_timeout(config, 1000); +zwasm_config_set_max_memory(config, 256 * 1024 * 1024); -/* Create module with WASI config */ -zwasm_module_t *mod = zwasm_module_new_wasi_configured(wasm_bytes, wasm_len, config); +/* Create module with both configs */ +zwasm_module_t *mod = zwasm_module_new_wasi_configured2( + wasm_bytes, wasm_len, wasi_config, config +); /* Run the program */ zwasm_module_invoke_start(mod); /* Cleanup */ zwasm_module_delete(mod); -zwasm_wasi_config_delete(config); +zwasm_config_delete(config); +zwasm_wasi_config_delete(wasi_config); ``` For simple WASI programs that only need default capabilities (stdio, clock, random): @@ -307,6 +332,7 @@ zwasm_module_delete(mod); - **Error buffer**: `zwasm_last_error_message()` returns a thread-local buffer. Safe to call from multiple threads. - **Modules**: A `zwasm_module_t` is **not** thread-safe. Do not invoke functions on the same module from multiple threads concurrently. Create separate module instances per thread instead. +- **Cancellation**: `zwasm_module_cancel()` is the only thread-safe operation and may be called from another thread to interrupt a running invocation. ## Next steps diff --git a/book/en/src/embedding-guide.md b/book/en/src/embedding-guide.md index 3b55e7c0..c430ecd2 100644 --- a/book/en/src/embedding-guide.md +++ b/book/en/src/embedding-guide.md @@ -148,17 +148,40 @@ for (import_infos) |info| { } ``` -## Resource limits -Control resource usage: +## Resource limits and Config options + +In Zig, resource and execution options are grouped in `WasmModule.Config` and passed to `loadWithOptions`. +This allows you to control: + +- **fuel**: Instruction count limit (prevents infinite loops) +- **timeout_ms**: Wall-clock timeout (milliseconds) +- **max_memory_bytes**: Maximum linear memory size +- **force_interpreter**: Disable JIT, always use interpreter + +Example (Zig): ```zig -// Fuel limit: traps after N instructions -const mod = try WasmModule.loadWithFuel(allocator, wasm_bytes, 1_000_000); +const zwasm = @import("zwasm"); +const Config = zwasm.WasmModule.Config; -// Memory limit: via WASI options or direct Vm access +var config = Config{ + .fuel = 1_000_000, // Trap after 1M instructions + .timeout_ms = 1000, // 1 second wall-clock timeout + .max_memory_bytes = 16 * 1024 * 1024, // 16MB + .force_interpreter = false, +}; +const mod = try WasmModule.loadWithOptions(allocator, wasm_bytes, config); ``` +**fuel**: If set, the module will trap with `error.FuelExhausted` after the specified number of instructions. Use this for untrusted or potentially infinite-looping code. + +**cancellation**: `mod.cancel()` can be called from another thread to interrupt an in-progress invocation. + +**timeout_ms**: If set, execution will be interrupted after the given wall-clock time. + +All options are optional; defaults are safe for most use cases. See the C API section for equivalent `zwasm_config_t` usage. + ## Error handling All loading and execution methods return error unions. Key error types: @@ -170,6 +193,8 @@ All loading and execution methods return error unions. Key error types: - **`error.OutOfBoundsMemoryAccess`** — Memory access out of bounds - **`error.OutOfMemory`** — Allocator failed - **`error.FuelExhausted`** — Instruction fuel limit hit +- **`error.Canceled`** — Execution canceled by host via `cancel()` +- **`error.TimeoutExceeded`** — Execution interrupted by wall-clock timeout See [Error Reference](../docs/errors.md) for the complete list. diff --git a/book/ja/src/c-api.md b/book/ja/src/c-api.md index 3175b7b0..f20c2a63 100644 --- a/book/ja/src/c-api.md +++ b/book/ja/src/c-api.md @@ -152,6 +152,19 @@ cd examples/rust && cargo run |------|------| | `zwasm_last_error_message()` | 最後のエラーを null 終端文字列で返す。エラーなしの場合は `""` を返す。スレッドローカル。 | +### ランタイム設定 + +| 関数 | 説明 | +|------|------| +| `zwasm_config_new()` | ランタイム設定ハンドルを作成。 | +| `zwasm_config_delete(config)` | ランタイム設定ハンドルを解放。 | +| `zwasm_config_set_allocator(config, alloc_fn, free_fn, ctx)` | ランタイム内部管理メモリ向けのカスタムアロケータを設定。 | +| `zwasm_config_set_fuel(config, fuel)` | 命令fuel上限を設定。 | +| `zwasm_config_set_timeout(config, timeout_ms)` | 実時間タイムアウト(ミリ秒)を設定。 | +| `zwasm_config_set_max_memory(config, max_memory_bytes)` | 線形メモリ `memory.grow` の上限(バイト)を設定。 | +| `zwasm_config_set_force_interpreter(config, force_interpreter)` | RegIR/JITを無効化し、インタプリタ実行を強制。 | +| `zwasm_config_set_cancellable(config, enabled)` | JIT実行中のキャンセルチェック有効/無効を設定。 | + ### モジュールのライフサイクル | 関数 | 説明 | @@ -160,6 +173,8 @@ cd examples/rust && cargo run | `zwasm_module_new_wasi(wasm_ptr, len)` | デフォルトケーパビリティで WASI モジュールを作成。 | | `zwasm_module_new_wasi_configured(wasm_ptr, len, config)` | カスタム設定で WASI モジュールを作成。 | | `zwasm_module_new_with_imports(wasm_ptr, len, imports)` | ホスト関数インポート付きでモジュールを作成。 | +| `zwasm_module_new_configured(wasm_ptr, len, config)` | ランタイム設定付きでモジュールを作成。 | +| `zwasm_module_new_wasi_configured2(wasm_ptr, len, wasi_config, config)` | WASI設定とランタイム設定の両方を指定してモジュールを作成。 | | `zwasm_module_delete(module)` | モジュールの全リソースを解放。 | | `zwasm_module_validate(wasm_ptr, len)` | インスタンス化せずにバイナリを検証。 | @@ -169,6 +184,7 @@ cd examples/rust && cargo run |------|------| | `zwasm_module_invoke(module, name, args, nargs, results, nresults)` | エクスポート関数を名前で呼び出す。 | | `zwasm_module_invoke_start(module)` | `_start`(WASI エントリポイント)を呼び出す。 | +| `zwasm_module_cancel(module)` | 実行中呼び出しのキャンセルを要求(スレッドセーフ)。 | ### エクスポートの検査 @@ -273,26 +289,35 @@ int main(void) { ## WASI プログラム -設定ビルダーパターンを使用して、カスタム設定で WASI プログラムを実行できます: +`zwasm_wasi_config_t` で argv/env/preopen を設定し、必要に応じて `zwasm_config_t` で fuel/timeout/メモリ上限を併用できます: ```c /* WASI の設定 */ -zwasm_wasi_config_t *config = zwasm_wasi_config_new(); +zwasm_wasi_config_t *wasi_config = zwasm_wasi_config_new(); const char *argv[] = {"myapp", "--verbose"}; -zwasm_wasi_config_set_argv(config, 2, argv); +zwasm_wasi_config_set_argv(wasi_config, 2, argv); + +zwasm_wasi_config_preopen_dir(wasi_config, "/tmp/data", 9, "/data", 5); -zwasm_wasi_config_preopen_dir(config, "/tmp/data", 9, "/data", 5); +/* 任意: ランタイム設定 */ +zwasm_config_t *config = zwasm_config_new(); +zwasm_config_set_fuel(config, 1000000); +zwasm_config_set_timeout(config, 1000); +zwasm_config_set_max_memory(config, 256 * 1024 * 1024); -/* WASI 設定付きでモジュールを作成 */ -zwasm_module_t *mod = zwasm_module_new_wasi_configured(wasm_bytes, wasm_len, config); +/* WASI設定 + ランタイム設定でモジュールを作成 */ +zwasm_module_t *mod = zwasm_module_new_wasi_configured2( + wasm_bytes, wasm_len, wasi_config, config +); /* プログラムを実行 */ zwasm_module_invoke_start(mod); /* クリーンアップ */ zwasm_module_delete(mod); -zwasm_wasi_config_delete(config); +zwasm_config_delete(config); +zwasm_wasi_config_delete(wasi_config); ``` デフォルトケーパビリティ (stdio, clock, random) のみの単純な WASI プログラムの場合: @@ -307,6 +332,7 @@ zwasm_module_delete(mod); - **エラーバッファ**: `zwasm_last_error_message()` はスレッドローカルバッファを返します。複数スレッドからの呼び出しは安全です。 - **モジュール**: `zwasm_module_t` はスレッドセーフ**ではありません**。同一モジュールに対して複数スレッドから同時に関数を呼び出さないでください。スレッドごとに個別のモジュールインスタンスを作成してください。 +- **キャンセル**: `zwasm_module_cancel()` は唯一のスレッドセーフな操作であり、他スレッドから実行中の呼び出しを中断できます。 ## 次のステップ diff --git a/book/ja/src/embedding-guide.md b/book/ja/src/embedding-guide.md index 45e15bc0..a4883cff 100644 --- a/book/ja/src/embedding-guide.md +++ b/book/ja/src/embedding-guide.md @@ -148,17 +148,39 @@ for (import_infos) |info| { } ``` -## リソース制限 -リソース使用量を制御できます: +## リソース制限とConfigオプション + +Zigでは、リソース・実行オプションは `WasmModule.Config` にまとまり、`loadWithOptions` で渡します。これにより、以下の制御が可能です: + +- **fuel**: 命令数上限(無限ループ防止) +- **timeout_ms**: 実時間タイムアウト(ミリ秒) +- **max_memory_bytes**: 線形メモリ最大サイズ +- **force_interpreter**: JIT無効化(常にインタプリタ) + +例(Zig): ```zig -// Fuel limit: traps after N instructions -const mod = try WasmModule.loadWithFuel(allocator, wasm_bytes, 1_000_000); +const zwasm = @import("zwasm"); +const Config = zwasm.WasmModule.Config; -// Memory limit: via WASI options or direct Vm access +var config = Config{ + .fuel = 1_000_000, // 100万命令でtrap + .timeout_ms = 1000, // 1秒タイムアウト + .max_memory_bytes = 16 * 1024 * 1024, // 16MB + .force_interpreter = false, +}; +const mod = try WasmModule.loadWithOptions(allocator, wasm_bytes, config); ``` +**fuel**: 設定時、指定命令数で`error.FuelExhausted`としてtrapします。信頼できない/無限ループの可能性があるコードに推奨。 + +**キャンセル**: 他スレッドから`mod.cancel()`を呼び出すことで、実行中の呼び出しを中断できます。 + +**timeout_ms**: 設定時、指定実時間経過で自動中断します。 + +全てのオプションは省略可能で、デフォルトは安全寄りです。C API側では `zwasm_config_t` で同等の設定ができます。 + ## エラーハンドリング すべてのロード・実行メソッドはエラーユニオンを返します。主要なエラー型は以下のとおりです: @@ -170,6 +192,8 @@ const mod = try WasmModule.loadWithFuel(allocator, wasm_bytes, 1_000_000); - **`error.OutOfBoundsMemoryAccess`** --- メモリアクセスが範囲外 - **`error.OutOfMemory`** --- アロケータが失敗 - **`error.FuelExhausted`** --- 命令フューエル制限に到達 +- **`error.Canceled`** --- ホストから `cancel()` で実行中断 +- **`error.TimeoutExceeded`** --- 実時間タイムアウトで中断 完全なリストは [エラーリファレンス](../docs/errors.md) を参照してください。 diff --git a/docs/api-boundary.md b/docs/api-boundary.md index cada02b7..61623dd0 100644 --- a/docs/api-boundary.md +++ b/docs/api-boundary.md @@ -12,6 +12,7 @@ Types and functions listed here are covered by SemVer guarantees. | `WasmValType` | Enum of Wasm value types (i32, i64, f32, f64, v128, funcref, externref) | v0.1.0 | | `ExportInfo` | Metadata for an exported function (name, param/result types) | v0.1.0 | | `ImportEntry` | Maps an import module name to a source | v0.2.0 | +| `WasmModule.Config`| Unified loading configuration | vNEXT | | `ImportSource` | Union: wasm_module or host_fns | v0.2.0 | | `HostFnEntry` | A single host function (name, callback, context) | v0.2.0 | | `HostFn` | Function type: `fn (*anyopaque, usize) anyerror!void` | v0.2.0 | @@ -26,15 +27,18 @@ Types and functions listed here are covered by SemVer guarantees. | Method | Signature | Since | |--------|-----------|-------| +| `loadWithOptions` | `(Allocator, []const u8, Config) !*WasmModule` | vNEXT | | `load` | `(Allocator, []const u8) !*WasmModule` | v0.1.0 | | `loadFromWat` | `(Allocator, []const u8) !*WasmModule` | v0.2.0 | | `loadWasi` | `(Allocator, []const u8) !*WasmModule` | v0.2.0 | | `loadWasiWithOptions` | `(Allocator, []const u8, WasiOptions) !*WasmModule` | v0.2.0 | -| `loadWithImports` | `(Allocator, []const u8, []const ImportEntry) !*WasmModule` | v0.2.0 | -| `loadWasiWithImports` | `(Allocator, []const u8, []const ImportEntry, WasiOptions) !*WasmModule` | v0.2.0 | +| `loadWithImports` | `(Allocator, []const u8, ?[]const ImportEntry) !*WasmModule` | v0.2.0 | +| `loadWasiWithImports` | `(Allocator, []const u8, ?[]const ImportEntry, WasiOptions) !*WasmModule` | v0.2.0 | | `loadWithFuel` | `(Allocator, []const u8, u64) !*WasmModule` | v0.3.0 | | `deinit` | `(*WasmModule) void` | v0.1.0 | | `invoke` | `(*WasmModule, []const u8, []u64, []u64) !void` | v0.1.0 | +| `cancel` | `(*WasmModule) void` | vNEXT | +| `invokeInterpreterOnly` | `(*WasmModule, []const u8, []u64, []u64) !void` | vNEXT | | `memoryRead` | `(*WasmModule, Allocator, u32, u32) ![]const u8` | v0.2.0 | | `memoryWrite` | `(*WasmModule, u32, []const u8) !void` | v0.2.0 | | `getExportInfo` | `(*WasmModule, []const u8) ?ExportInfo` | v0.2.0 | diff --git a/docs/embedding.md b/docs/embedding.md index 8a60f0d0..2b4d3eb4 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -136,6 +136,29 @@ The custom allocator controls **internal bookkeeping only** (module metadata, function tables, GC heap, VM state). Wasm linear memory (`memory.grow`) is separately managed per the Wasm spec. +### Execution Controls (Fuel, Timeout, Cancellation) + +`zwasm_config_t` also controls runtime limits and execution behavior: + +```c +zwasm_config_t *config = zwasm_config_new(); + +zwasm_config_set_fuel(config, 1000000); +zwasm_config_set_timeout(config, 5000); // milliseconds +zwasm_config_set_max_memory(config, 64 * 1024 * 1024); +zwasm_config_set_force_interpreter(config, false); + +// Default is true. Set false to remove periodic JIT cancel checks +// when you prioritize peak throughput over cancellability. +zwasm_config_set_cancellable(config, true); + +zwasm_module_t *mod = zwasm_module_new_configured(wasm_ptr, len, config); +``` + +Fuel applies to module startup and invocation. If a module has a start function, +it runs under the configured fuel budget, and the remaining fuel is carried into +subsequent invocations. + ### WASI + Custom Allocator ```c @@ -208,10 +231,10 @@ Key function groups: | Group | Functions | |-------|-----------| -| Config | `zwasm_config_new`, `zwasm_config_delete`, `zwasm_config_set_allocator` | +| Config | `zwasm_config_new`, `zwasm_config_delete`, `zwasm_config_set_allocator`, `zwasm_config_set_fuel`, `..._set_timeout`, `..._set_max_memory`, `..._set_force_interpreter`, `..._set_cancellable` | | Module | `zwasm_module_new`, `zwasm_module_new_configured`, `zwasm_module_delete` | | WASI | `zwasm_module_new_wasi`, `zwasm_module_new_wasi_configured2` | -| Invoke | `zwasm_module_invoke`, `zwasm_module_invoke_start` | +| Invoke | `zwasm_module_invoke`, `zwasm_module_invoke_start`, `zwasm_module_cancel` | | Memory | `zwasm_module_memory_data`, `zwasm_module_memory_size`, `_read`, `_write` | | Exports | `zwasm_module_export_count`, `_name`, `_param_count`, `_result_count` | | Imports | `zwasm_import_new`, `zwasm_import_add_fn`, `zwasm_import_delete` | diff --git a/docs/errors.md b/docs/errors.md index 5fdd4d52..e27fa89f 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -36,6 +36,8 @@ The primary runtime error type. Returned from `Vm.invoke()`, `Vm.callFunction()` | MemoryLimitExceeded | Memory grow exceeded limit | | TableLimitExceeded | Table grow exceeded limit | | FuelExhausted | Instruction fuel limit hit | +| Canceled | Execution canceled by host via `cancel()` | +| TimeoutExceeded | Execution interrupted by wall-clock timeout | ### Index errors | Variant | Meaning | diff --git a/docs/usage.md b/docs/usage.md index 83642c97..45c6dc8a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -64,6 +64,10 @@ zwasm module.wasm --max-memory 67108864 # 64MB ceiling zwasm module.wasm --fuel 1000000 ``` +`--fuel` applies to all execution, including module start (`_start`/start function) +and subsequent invoked exports. If startup code consumes fuel, less fuel remains +for later function calls. + ### Linking modules ```bash diff --git a/include/zwasm.h b/include/zwasm.h index c1ea5a32..280d23ca 100644 --- a/include/zwasm.h +++ b/include/zwasm.h @@ -93,6 +93,33 @@ void zwasm_config_set_allocator(zwasm_config_t *config, zwasm_alloc_fn_t alloc_fn, zwasm_free_fn_t free_fn, void *ctx); +/** + * Set the instruction fuel limit. Traps when exhausted. + */ +void zwasm_config_set_fuel(zwasm_config_t *config, uint64_t fuel); + +/** + * Set the execution timeout in milliseconds. + */ +void zwasm_config_set_timeout(zwasm_config_t *config, uint64_t timeout_ms); + +/** + * Set the memory ceiling in bytes (limits memory.grow). + */ +void zwasm_config_set_max_memory(zwasm_config_t *config, uint64_t max_memory_bytes); + +/** + * Force the use of the interpreter only (bypass RegIR and JIT). + */ +void zwasm_config_set_force_interpreter(zwasm_config_t *config, bool force_interpreter); + +/** + * Enable or disable periodic JIT cancellation checks (default: true). + * Disabling this improves performance but makes zwasm_module_cancel() + * ineffective for JIT-compiled code. + */ +void zwasm_config_set_cancellable(zwasm_config_t *config, bool enabled); + /* ================================================================ * Module lifecycle * ================================================================ */ @@ -202,6 +229,14 @@ uint32_t zwasm_module_export_param_count(zwasm_module_t *module, uint32_t idx); /** Return the result count of the idx-th exported function. */ uint32_t zwasm_module_export_result_count(zwasm_module_t *module, uint32_t idx); +/** + * Request cancellation of currently executing Wasm function. + * Thread-safe for concurrent access. Can be called from a different thread + * during invoke or invoke_start. Execution stops at the next checkpoint + * (~1024 instructions or JIT interval). Has no effect if module is idle. + */ +void zwasm_module_cancel(zwasm_module_t *module); + /* ================================================================ * Memory access * ================================================================ */ diff --git a/src/c_api.zig b/src/c_api.zig index 4beb261e..c1a00057 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -92,6 +92,12 @@ const CAllocatorWrapper = struct { /// Configuration handle for module creation. Optional custom allocator. const CApiConfig = struct { c_alloc: ?*CAllocatorWrapper = null, + cancellable: bool = true, + + fuel: ?u64 = null, + timeout_ms: ?u64 = null, + max_memory_bytes: ?u64 = null, + force_interpreter: bool = false, fn deinit(self: *CApiConfig) void { if (self.c_alloc) |ca| page_alloc.destroy(ca); @@ -102,6 +108,16 @@ const CApiConfig = struct { if (self.c_alloc) |ca| return ca.allocator(); return null; } + + /// Build a WasmModule.Config from this C API config. + fn toModuleConfig(self: *CApiConfig) types.WasmModule.Config { + return .{ + .fuel = self.fuel, + .timeout_ms = self.timeout_ms, + .max_memory_bytes = self.max_memory_bytes, + .force_interpreter = self.force_interpreter, + }; + } }; pub const zwasm_config_t = CApiConfig; @@ -125,41 +141,71 @@ const default_allocator = std.heap.c_allocator; const CApiModule = struct { module: *WasmModule, - fn create(wasm_bytes: []const u8, wasi: bool) !*CApiModule { - return createWithAllocator(wasm_bytes, wasi, null); + fn create(wasm_bytes: []const u8, wasi: bool, config: ?*CApiConfig) !*CApiModule { + return createConfigured(wasm_bytes, wasi, config); } - fn createWithAllocator(wasm_bytes: []const u8, wasi: bool, custom_alloc: ?std.mem.Allocator) !*CApiModule { + fn createConfigured(wasm_bytes: []const u8, wasi: bool, config: ?*CApiConfig) !*CApiModule { const self = try std.heap.page_allocator.create(CApiModule); errdefer std.heap.page_allocator.destroy(self); - const allocator = custom_alloc orelse default_allocator; + const allocator = if (config) |c| c.getAllocator() orelse default_allocator else default_allocator; + var mod_cfg = if (config) |c| c.toModuleConfig() else types.WasmModule.Config{}; + mod_cfg.wasi = wasi; - self.module = if (wasi) - try WasmModule.loadWasi(allocator, wasm_bytes) - else - try WasmModule.load(allocator, wasm_bytes); + self.module = try WasmModule.loadWithOptions(allocator, wasm_bytes, mod_cfg); + self.module.vm.cancellable = if (config) |c| c.cancellable else true; + return self; + } + + fn createWithAllocator(wasm_bytes: []const u8, wasi: bool, custom_alloc: ?std.mem.Allocator, config: ?*CApiConfig) !*CApiModule { + const self = try std.heap.page_allocator.create(CApiModule); + errdefer std.heap.page_allocator.destroy(self); + const allocator = custom_alloc orelse default_allocator; + var mod_cfg = if (config) |c| c.toModuleConfig() else types.WasmModule.Config{}; + mod_cfg.wasi = wasi; + self.module = try WasmModule.loadWithOptions(allocator, wasm_bytes, mod_cfg); + self.module.vm.cancellable = if (config) |c| c.cancellable else true; return self; } - fn createWasiConfigured(wasm_bytes: []const u8, opts: WasiOptions) !*CApiModule { - return createWasiConfiguredWithAllocator(wasm_bytes, opts, null); + fn createWasiConfigured(wasm_bytes: []const u8, opts: WasiOptions, config: ?*CApiConfig) !*CApiModule { + return createWasiConfiguredWithAllocator(wasm_bytes, opts, null, config); } - fn createWasiConfiguredWithAllocator(wasm_bytes: []const u8, opts: WasiOptions, custom_alloc: ?std.mem.Allocator) !*CApiModule { + fn createWasiConfiguredEx(wasm_bytes: []const u8, opts: WasiOptions, config: ?*CApiConfig) !*CApiModule { const self = try std.heap.page_allocator.create(CApiModule); errdefer std.heap.page_allocator.destroy(self); - const allocator = custom_alloc orelse default_allocator; + const allocator = if (config) |c| c.getAllocator() orelse default_allocator else default_allocator; + var mod_cfg = if (config) |c| c.toModuleConfig() else types.WasmModule.Config{}; + mod_cfg.wasi = true; + mod_cfg.wasi_options = opts; + + self.module = try WasmModule.loadWithOptions(allocator, wasm_bytes, mod_cfg); + self.module.vm.cancellable = if (config) |c| c.cancellable else true; + return self; + } - self.module = try WasmModule.loadWasiWithOptions(allocator, wasm_bytes, opts); + fn createWasiConfiguredWithAllocator(wasm_bytes: []const u8, opts: WasiOptions, custom_alloc: ?std.mem.Allocator, config: ?*CApiConfig) !*CApiModule { + const self = try std.heap.page_allocator.create(CApiModule); + errdefer std.heap.page_allocator.destroy(self); + const allocator = custom_alloc orelse default_allocator; + var mod_cfg = if (config) |c| c.toModuleConfig() else types.WasmModule.Config{}; + mod_cfg.wasi = true; + mod_cfg.wasi_options = opts; + self.module = try WasmModule.loadWithOptions(allocator, wasm_bytes, mod_cfg); + self.module.vm.cancellable = if (config) |c| c.cancellable else true; return self; } - fn createWithImports(wasm_bytes: []const u8, imports: []const types.ImportEntry) !*CApiModule { + fn createWithImports(wasm_bytes: []const u8, imports: []const types.ImportEntry, config: ?*CApiConfig) !*CApiModule { const self = try std.heap.page_allocator.create(CApiModule); errdefer std.heap.page_allocator.destroy(self); - self.module = try WasmModule.loadWithImports(default_allocator, wasm_bytes, imports); + var mod_cfg = if (config) |c| c.toModuleConfig() else types.WasmModule.Config{}; + mod_cfg.imports = imports; + self.module = try WasmModule.loadWithOptions(default_allocator, wasm_bytes, mod_cfg); + self.module.vm.cancellable = if (config) |c| c.cancellable else true; return self; } @@ -281,6 +327,28 @@ export fn zwasm_config_set_allocator( config.c_alloc = wrapper; } +export fn zwasm_config_set_fuel(config: *zwasm_config_t, fuel: u64) void { + config.fuel = fuel; +} + +export fn zwasm_config_set_timeout(config: *zwasm_config_t, timeout_ms: u64) void { + config.timeout_ms = timeout_ms; +} + +export fn zwasm_config_set_max_memory(config: *zwasm_config_t, max_memory_bytes: u64) void { + config.max_memory_bytes = max_memory_bytes; +} + +export fn zwasm_config_set_force_interpreter(config: *zwasm_config_t, force_interpreter: bool) void { + config.force_interpreter = force_interpreter; +} + +/// Enable or disable periodic JIT cancellation checks (default: true). +/// Disabling this improves performance but makes cancel() ineffective for JIT code. +export fn zwasm_config_set_cancellable(config: *zwasm_config_t, enabled: bool) void { + config.cancellable = enabled; +} + // ============================================================ // Module lifecycle // ============================================================ @@ -289,7 +357,7 @@ export fn zwasm_config_set_allocator( /// Returns null on error — call `zwasm_last_error_message()` for details. export fn zwasm_module_new(wasm_ptr: [*]const u8, len: usize) ?*zwasm_module_t { clearError(); - return CApiModule.create(wasm_ptr[0..len], false) catch |err| { + return CApiModule.create(wasm_ptr[0..len], false, null) catch |err| { setError(err); return null; }; @@ -299,7 +367,7 @@ export fn zwasm_module_new(wasm_ptr: [*]const u8, len: usize) ?*zwasm_module_t { /// Returns null on error — call `zwasm_last_error_message()` for details. export fn zwasm_module_new_wasi(wasm_ptr: [*]const u8, len: usize) ?*zwasm_module_t { clearError(); - return CApiModule.create(wasm_ptr[0..len], true) catch |err| { + return CApiModule.create(wasm_ptr[0..len], true, null) catch |err| { setError(err); return null; }; @@ -309,8 +377,7 @@ export fn zwasm_module_new_wasi(wasm_ptr: [*]const u8, len: usize) ?*zwasm_modul /// Pass null for config to use default allocator (same as zwasm_module_new). export fn zwasm_module_new_configured(wasm_ptr: [*]const u8, len: usize, config: ?*zwasm_config_t) ?*zwasm_module_t { clearError(); - const custom_alloc = if (config) |c| c.getAllocator() else null; - return CApiModule.createWithAllocator(wasm_ptr[0..len], false, custom_alloc) catch |err| { + return CApiModule.createConfigured(wasm_ptr[0..len], false, config) catch |err| { setError(err); return null; }; @@ -395,8 +462,7 @@ export fn zwasm_module_new_wasi_configured2( .stdio_ownership = stdio_ownership2, }; - const custom_alloc = if (config) |c| c.getAllocator() else null; - return CApiModule.createWasiConfiguredWithAllocator(wasm_ptr[0..len], opts, custom_alloc) catch |err| { + return CApiModule.createWasiConfiguredEx(wasm_ptr[0..len], opts, config) catch |err| { setError(err); return null; }; @@ -504,6 +570,14 @@ export fn zwasm_module_export_result_count(module: *zwasm_module_t, idx: u32) u3 return @intCast(module.module.export_fns[idx].result_types.len); } +/// Request cancellation of currently executing Wasm in this module. +/// Thread-safe. Can be called from a different thread during invoke/invoke_start. +/// Execution stops at the next checkpoint (~1024 instructions or JIT interval). +/// Has no effect if module is idle (not executing). +export fn zwasm_module_cancel(module: *zwasm_module_t) void { + module.module.cancel(); +} + // ============================================================ // WASI configuration // ============================================================ @@ -723,7 +797,7 @@ export fn zwasm_module_new_wasi_configured( .stdio_ownership = stdio_ownership, }; - return CApiModule.createWasiConfigured(wasm_ptr[0..len], opts) catch |err| { + return CApiModule.createWasiConfigured(wasm_ptr[0..len], opts, null) catch |err| { setError(err); return null; }; @@ -837,7 +911,7 @@ export fn zwasm_module_new_with_imports( }; } - const result = CApiModule.createWithImports(wasm_ptr[0..len], import_entries) catch |err| { + const result = CApiModule.createWithImports(wasm_ptr[0..len], import_entries, null) catch |err| { setError(err); return null; }; @@ -1172,3 +1246,23 @@ test "c_api: module_new_wasi_configured2 with null config" { try testing.expect(module != null); zwasm_module_delete(module.?); } + +test "c_api: config set vm limits" { + const config = zwasm_config_new().?; + defer zwasm_config_delete(config); + + zwasm_config_set_fuel(config, 9999); + zwasm_config_set_timeout(config, 1000); + zwasm_config_set_max_memory(config, 65536); + zwasm_config_set_force_interpreter(config, true); + + const module = zwasm_module_new_configured(MINIMAL_WASM.ptr, MINIMAL_WASM.len, config); + try testing.expect(module != null); + defer zwasm_module_delete(module.?); + + const mod = &module.?.module.*; + try testing.expectEqual(@as(?u64, 9999), mod.vm.fuel); + try testing.expectEqual(@as(?u64, 65536), mod.vm.max_memory_bytes); + try testing.expectEqual(true, mod.vm.force_interpreter); + try testing.expect(mod.vm.deadline_ns != null); +} diff --git a/src/cli.zig b/src/cli.zig index a58804c3..cdd7b939 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -309,6 +309,13 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer func_args_start = args.len; } + const base_cfg = types.WasmModule.Config{ + .fuel = fuel, + .timeout_ms = timeout_ms, + .max_memory_bytes = max_memory_bytes, + .force_interpreter = force_interpreter, + }; + const path = wasm_path orelse { try stderr.print("error: no wasm file specified\n", .{}); try stderr.flush(); @@ -349,22 +356,25 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer return false; }; // Load with already-loaded linked modules as imports (transitive chains) - const lm = if (import_entries.items.len > 0) - types.WasmModule.loadWithImports(allocator, link_bytes, import_entries.items) catch - // Retry without imports if the linked module doesn't need them - types.WasmModule.load(allocator, link_bytes) catch |err| { - allocator.free(link_bytes); - try stderr.print("error: failed to load linked module '{s}': {s}\n", .{ lpath, formatWasmError(err) }); - try stderr.flush(); - return false; + var cfg = base_cfg; + cfg.imports = import_entries.items; + const lm = types.WasmModule.loadWithOptions(allocator, link_bytes, cfg) catch |err| blk: { + // Retry without imports if the linked module doesn't need them + if (err == error.ImportNotFound and cfg.imports.len > 0) { + var retry_cfg = base_cfg; + retry_cfg.imports = &.{}; + break :blk types.WasmModule.loadWithOptions(allocator, link_bytes, retry_cfg) catch |retry_err| { + allocator.free(link_bytes); + try stderr.print("error: failed to load linked module '{s}': {s}\n", .{ lpath, formatWasmError(retry_err) }); + try stderr.flush(); + return false; + }; } - else - types.WasmModule.load(allocator, link_bytes) catch |err| { - allocator.free(link_bytes); - try stderr.print("error: failed to load linked module '{s}': {s}\n", .{ lpath, formatWasmError(err) }); - try stderr.flush(); - return false; - }; + allocator.free(link_bytes); + try stderr.print("error: failed to load linked module '{s}': {s}\n", .{ lpath, formatWasmError(err) }); + try stderr.flush(); + return false; + }; try linked_bytes.append(allocator, link_bytes); try linked_modules.append(allocator, lm); try import_entries.append(allocator, .{ @@ -374,7 +384,7 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer } if (batch_mode) { - return cmdBatch(allocator, wasm_bytes, import_entries.items, link_names.items, linked_modules.items, stdout, stderr, trace_categories, dump_regir_func, dump_jit_func); + return cmdBatch(allocator, wasm_bytes, import_entries.items, link_names.items, linked_modules.items, stdout, stderr, trace_categories, dump_regir_func, dump_jit_func, base_cfg); } const imports_slice: ?[]const types.ImportEntry = if (import_entries.items.len > 0) @@ -395,11 +405,22 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer }; const module = load_blk: { - if (imports_slice != null) { + const wasi_cfg = blk: { + var c = base_cfg; + c.wasi = true; + c.wasi_options = wasi_opts; + break :blk c; + }; + + if (import_entries.items.len > 0) { // With --link: try imports only, then imports + WASI - break :load_blk types.WasmModule.loadWithImports(allocator, wasm_bytes, imports_slice.?) catch |err| { + var imports_only_cfg = base_cfg; + imports_only_cfg.imports = imports_slice.?; + break :load_blk types.WasmModule.loadWithOptions(allocator, wasm_bytes, imports_only_cfg) catch |err| { if (err == error.ImportNotFound) { - break :load_blk types.WasmModule.loadWasiWithImports(allocator, wasm_bytes, imports_slice, wasi_opts) catch |err2| { + var imports_wasi_cfg = wasi_cfg; + imports_wasi_cfg.imports = imports_slice.?; + break :load_blk types.WasmModule.loadWithOptions(allocator, wasm_bytes, imports_wasi_cfg) catch |err2| { try stderr.print("error: failed to load module: {s}\n", .{formatWasmError(err2)}); try stderr.flush(); return false; @@ -411,9 +432,10 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer }; } // No --link: try plain, then WASI - break :load_blk types.WasmModule.load(allocator, wasm_bytes) catch |err| { + const plain_cfg = base_cfg; + break :load_blk types.WasmModule.loadWithOptions(allocator, wasm_bytes, plain_cfg) catch |err| { if (err == error.ImportNotFound) { - break :load_blk types.WasmModule.loadWasiWithOptions(allocator, wasm_bytes, wasi_opts) catch |err2| { + break :load_blk types.WasmModule.loadWithOptions(allocator, wasm_bytes, wasi_cfg) catch |err2| { try stderr.print("error: failed to load module: {s}\n", .{formatWasmError(err2)}); try stderr.flush(); return false; @@ -455,13 +477,6 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer if (trace_categories != 0 or dump_regir_func != null or dump_jit_func != null) { module.vm.trace = &trace_config; } - - // Apply resource limits - module.vm.max_memory_bytes = max_memory_bytes; - module.vm.fuel = fuel; - module.vm.force_interpreter = force_interpreter; - if (timeout_ms) |ms| module.vm.setDeadlineTimeoutMs(ms); - // Lookup export info for type-aware parsing and validation const export_info = module.getExportInfo(func_name); const func_args_slice = args[func_args_start..]; @@ -564,15 +579,20 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer try wasi_args_list.append(allocator, @ptrCast(a)); } - // Run as WASI module (_start), with --link imports if provided - const wasi_opts2: types.WasiOptions = .{ + const wasi_opts: types.WasiOptions = .{ .args = wasi_args_list.items, .env_keys = env_keys.items, .env_vals = env_vals.items, .preopen_paths = preopen_paths.items, .caps = caps, }; - var module = types.WasmModule.loadWasiWithImports(allocator, wasm_bytes, imports_slice, wasi_opts2) catch |err| { + + // Run as WASI module (_start), with --link imports if provided + var cfg = base_cfg; + cfg.wasi = true; + cfg.wasi_options = wasi_opts; + cfg.imports = imports_slice orelse &.{}; + var module = types.WasmModule.loadWithOptions(allocator, wasm_bytes, cfg) catch |err| { try stderr.print("error: failed to load WASI module: {s}\n", .{formatWasmError(err)}); try stderr.flush(); return false; @@ -604,13 +624,6 @@ fn cmdRun(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Writer if (trace_categories != 0 or dump_regir_func != null or dump_jit_func != null) { module.vm.trace = &wasi_trace_config; } - - // Apply resource limits - module.vm.max_memory_bytes = max_memory_bytes; - module.vm.fuel = fuel; - module.vm.force_interpreter = force_interpreter; - if (timeout_ms) |ms| module.vm.setDeadlineTimeoutMs(ms); - var no_args = [_]u64{}; var no_results = [_]u64{}; module.invoke("_start", &no_args, &no_results) catch |err| { @@ -671,7 +684,7 @@ fn cmdCompile(allocator: Allocator, args: []const []const u8, stdout: *std.Io.Wr defer allocator.free(wasm_bytes); // Load module (with WASI to handle any imports) - var module = types.WasmModule.loadWasi(allocator, wasm_bytes) catch |err| { + var module = types.WasmModule.loadWithOptions(allocator, wasm_bytes, .{ .wasi = true }) catch |err| { try stderr.print("error: failed to load module: {s}\n", .{formatWasmError(err)}); try stderr.flush(); return false; @@ -1234,20 +1247,15 @@ fn threadRunner(ctx: *ThreadCtx) void { /// Batch mode: read invocations from stdin, one per line. /// Protocol: "invoke [arg1 arg2 ...]" /// Output: "ok [val1 val2 ...]" or "error " -fn cmdBatch(allocator: Allocator, wasm_bytes: []const u8, imports: []const types.ImportEntry, link_names: []const []const u8, linked_modules: []const *types.WasmModule, stdout: *std.Io.Writer, stderr: *std.Io.Writer, trace_categories: u8, dump_regir_func: ?u32, dump_jit_func: ?u32) !bool { +fn cmdBatch(allocator: Allocator, wasm_bytes: []const u8, imports: []const types.ImportEntry, link_names: []const []const u8, linked_modules: []const *types.WasmModule, stdout: *std.Io.Writer, stderr: *std.Io.Writer, trace_categories: u8, dump_regir_func: ?u32, dump_jit_func: ?u32, base_cfg: types.WasmModule.Config) !bool { _ = stderr; - var module = if (imports.len > 0) - types.WasmModule.loadWithImports(allocator, wasm_bytes, imports) catch |err| { - try stdout.print("error load {s}\n", .{@errorName(err)}); - try stdout.flush(); - return false; - } - else - types.WasmModule.load(allocator, wasm_bytes) catch |err| { - try stdout.print("error load {s}\n", .{@errorName(err)}); - try stdout.flush(); - return false; - }; + var cfg = base_cfg; + cfg.imports = imports; + var module = types.WasmModule.loadWithOptions(allocator, wasm_bytes, cfg) catch |err| { + try stdout.print("error load {s}\n", .{@errorName(err)}); + try stdout.flush(); + return false; + }; defer module.deinit(); // Enable tracing if requested @@ -1369,7 +1377,7 @@ fn cmdBatch(allocator: Allocator, wasm_bytes: []const u8, imports: []const types try stdout.flush(); continue; }; - const load_result = types.WasmModule.loadLinked(allocator, load_bytes, root_store) catch |err| { + const load_result = types.WasmModule.loadLinked(allocator, load_bytes, root_store, true) catch |err| { allocator.free(load_bytes); try stdout.print("error load {s}\n", .{@errorName(err)}); try stdout.flush(); @@ -2111,6 +2119,7 @@ fn formatWasmError(err: anyerror) []const u8 { error.MemoryLimitExceeded => "memory grow exceeded maximum", error.FuelExhausted => "fuel limit exhausted", error.TimeoutExceeded => "execution timed out", + error.Canceled => "execution canceled", // File errors error.FileNotFound => "file not found", error.WatNotEnabled => "WAT format disabled (build with -Dwat=true)", diff --git a/src/component.zig b/src/component.zig index 78c95c89..a132185e 100644 --- a/src/component.zig +++ b/src/component.zig @@ -1144,7 +1144,7 @@ pub const ComponentInstance = struct { pub fn instantiate(self: *ComponentInstance) !void { // Phase 1: Load embedded core modules for (self.comp.core_modules.items) |mod_bytes| { - const m = try types.WasmModule.load(self.alloc, mod_bytes); + const m = try types.WasmModule.loadWithOptions(self.alloc, mod_bytes, .{}); self.core_modules.append(self.alloc, m) catch return error.OutOfMemory; } @@ -1164,9 +1164,9 @@ pub const ComponentInstance = struct { // Phase 1: Load embedded core modules with imports for (self.comp.core_modules.items) |mod_bytes| { const m = if (imports.len > 0) - try types.WasmModule.loadWithImports(self.alloc, mod_bytes, imports) + try types.WasmModule.loadWithOptions(self.alloc, mod_bytes, .{ .imports = imports }) else - try types.WasmModule.load(self.alloc, mod_bytes); + try types.WasmModule.loadWithOptions(self.alloc, mod_bytes, .{}); self.core_modules.append(self.alloc, m) catch return error.OutOfMemory; } diff --git a/src/fuzz_gen.zig b/src/fuzz_gen.zig index e3ada46b..17905420 100644 --- a/src/fuzz_gen.zig +++ b/src/fuzz_gen.zig @@ -1721,8 +1721,8 @@ fn loadAndExercise(alloc: Allocator, wasm: []const u8) void { // Call multiple times to trigger JIT compilation for (0..FUZZ_JIT_CALLS) |_| { + module.fuel = FUZZ_FUEL; module.invoke(ei.name, arg_slice, result_slice) catch break; - module.vm.fuel = FUZZ_FUEL; } } } diff --git a/src/fuzz_loader.zig b/src/fuzz_loader.zig index c06be0e6..f8f237df 100644 --- a/src/fuzz_loader.zig +++ b/src/fuzz_loader.zig @@ -48,7 +48,7 @@ fn loadModule(allocator: std.mem.Allocator, input: []const u8) ?*zwasm.WasmModul const m = zwasm.WasmModule.loadWasiWithOptions(allocator, input, .{ .caps = zwasm.Capabilities.sandbox, }) catch return null; - m.vm.fuel = FUEL_LIMIT; + m.fuel = FUEL_LIMIT; return m; } @@ -79,8 +79,8 @@ fn fuzzOne(allocator: std.mem.Allocator, input: []const u8) void { // Call multiple times to trigger JIT compilation for (0..JIT_CALLS) |_| { + module.fuel = FUEL_LIMIT; module.invoke(ei.name, arg_slice, result_slice) catch break; - module.vm.fuel = FUEL_LIMIT; } } } diff --git a/src/fuzz_wat_loader.zig b/src/fuzz_wat_loader.zig index f7f03c2a..bb9fe778 100644 --- a/src/fuzz_wat_loader.zig +++ b/src/fuzz_wat_loader.zig @@ -64,8 +64,8 @@ fn fuzzOne(allocator: std.mem.Allocator, wat_source: []const u8) void { // Call multiple times to trigger JIT compilation for (0..JIT_CALLS) |_| { + module.fuel = FUEL_LIMIT; module.invoke(ei.name, arg_slice, result_slice) catch break; - module.vm.fuel = FUEL_LIMIT; } } } diff --git a/src/module.zig b/src/module.zig index fdfc9c08..2f5d21a2 100644 --- a/src/module.zig +++ b/src/module.zig @@ -2059,7 +2059,7 @@ test "fuzz — full pipeline (load+instantiate) does not panic" { var results: [1]u64 = .{0}; const result_slice = results[0..ei.result_types.len]; module.invoke(ei.name, &.{}, result_slice) catch continue; - module.vm.fuel = 100_000; + module.fuel = 100_000; } } } diff --git a/src/types.zig b/src/types.zig index 604936cd..1990abac 100644 --- a/src/types.zig +++ b/src/types.zig @@ -232,15 +232,39 @@ pub const WasmModule = struct { vm: *rt.vm_mod.Vm = undefined, /// Owned wasm bytes (from WAT conversion). Freed on deinit. owned_wasm_bytes: ?[]const u8 = null, + /// Persistent fuel budget from Config. Decremented across all invocations. + fuel: ?u64 = null, + /// Persistent timeout setting from Config. Applied at start of every invocation. + timeout_ms: ?u64 = null, + /// Persistent memory limit from Config. Applied at start of every invocation. + max_memory_bytes: ?u64 = null, + /// Persistent interpreter-only setting from Config. + force_interpreter: bool = false, + + /// Configuration for module loading. + pub const Config = struct { + wasi: bool = false, + wasi_options: ?WasiOptions = null, + imports: []const ImportEntry = &.{}, + fuel: ?u64 = null, + timeout_ms: ?u64 = null, + max_memory_bytes: ?u64 = null, + force_interpreter: bool = false, + }; + + /// Load a Wasm module from binary bytes with explicit configuration. + pub fn loadWithOptions(allocator: Allocator, wasm_bytes: []const u8, config: Config) !*WasmModule { + return loadCore(allocator, wasm_bytes, config); + } /// Load a Wasm module from binary bytes, decode, and instantiate. pub fn load(allocator: Allocator, wasm_bytes: []const u8) !*WasmModule { - return loadCore(allocator, wasm_bytes, false, null, null); + return loadWithOptions(allocator, wasm_bytes, .{}); } /// Load with a fuel limit (traps start function if it exceeds the limit). pub fn loadWithFuel(allocator: Allocator, wasm_bytes: []const u8, fuel: u64) !*WasmModule { - return loadCore(allocator, wasm_bytes, false, null, fuel); + return loadWithOptions(allocator, wasm_bytes, .{ .fuel = fuel }); } /// Load a module from WAT (WebAssembly Text Format) source. @@ -248,7 +272,7 @@ pub const WasmModule = struct { pub fn loadFromWat(allocator: Allocator, wat_source: []const u8) !*WasmModule { const wasm_bytes = try rt.wat.watToWasm(allocator, wat_source); errdefer allocator.free(wasm_bytes); - const self = try loadCore(allocator, wasm_bytes, false, null, null); + const self = try loadWithOptions(allocator, wasm_bytes, .{}); self.owned_wasm_bytes = wasm_bytes; return self; } @@ -257,14 +281,14 @@ pub const WasmModule = struct { pub fn loadFromWatWithFuel(allocator: Allocator, wat_source: []const u8, fuel: u64) !*WasmModule { const wasm_bytes = try rt.wat.watToWasm(allocator, wat_source); errdefer allocator.free(wasm_bytes); - const self = try loadCore(allocator, wasm_bytes, false, null, fuel); + const self = try loadWithOptions(allocator, wasm_bytes, .{ .fuel = fuel }); self.owned_wasm_bytes = wasm_bytes; return self; } /// Load a WASI module — registers wasi_snapshot_preview1 imports. pub fn loadWasi(allocator: Allocator, wasm_bytes: []const u8) !*WasmModule { - return loadCore(allocator, wasm_bytes, true, null, null); + return loadWithOptions(allocator, wasm_bytes, .{ .wasi = true }); } /// Apply WasiOptions to a WasiContext (shared logic for all WASI loaders). @@ -301,31 +325,17 @@ pub const WasmModule = struct { /// Load a WASI module with custom args, env, and preopened directories. pub fn loadWasiWithOptions(allocator: Allocator, wasm_bytes: []const u8, opts: WasiOptions) !*WasmModule { - const self = try loadCore(allocator, wasm_bytes, true, null, null); - errdefer self.deinit(); - - if (self.wasi_ctx) |*wc| { - try applyWasiOptions(wc, opts); - } - - return self; + return loadWithOptions(allocator, wasm_bytes, .{ .wasi = true, .wasi_options = opts }); } /// Load with imports from other modules or host functions. - pub fn loadWithImports(allocator: Allocator, wasm_bytes: []const u8, imports: []const ImportEntry) !*WasmModule { - return loadCore(allocator, wasm_bytes, false, imports, null); + pub fn loadWithImports(allocator: Allocator, wasm_bytes: []const u8, imports: ?[]const ImportEntry) !*WasmModule { + return loadWithOptions(allocator, wasm_bytes, .{ .imports = imports orelse &.{} }); } /// Load with combined WASI + import support. Used by CLI for --link + WASI fallback. pub fn loadWasiWithImports(allocator: Allocator, wasm_bytes: []const u8, imports: ?[]const ImportEntry, opts: WasiOptions) !*WasmModule { - const self = try loadCore(allocator, wasm_bytes, true, imports, null); - errdefer self.deinit(); - - if (self.wasi_ctx) |*wc| { - try applyWasiOptions(wc, opts); - } - - return self; + return loadWithOptions(allocator, wasm_bytes, .{ .wasi = true, .wasi_options = opts, .imports = imports orelse &.{} }); } /// Register this module's exports in its store under the given module name. @@ -358,17 +368,22 @@ pub const WasmModule = struct { /// Phase 2 (applyActive): apply element/data segments — may partially fail. /// On phase 2 failure, partial writes persist in the shared store (v2 spec behavior). /// Returns .{ module, apply_error } where apply_error is null on full success. - pub fn loadLinked(allocator: Allocator, wasm_bytes: []const u8, shared_store: *rt.store_mod.Store) !struct { module: *WasmModule, apply_error: ?anyerror } { + pub fn loadLinked(allocator: Allocator, wasm_bytes: []const u8, shared_store: *rt.store_mod.Store, cancellable: bool) !struct { module: *WasmModule, apply_error: ?anyerror } { const self = try allocator.create(WasmModule); self.allocator = allocator; self.owned_wasm_bytes = null; self.store = rt.store_mod.Store.init(allocator); self.wasi_ctx = null; + self.timeout_ms = null; + self.fuel = null; + self.max_memory_bytes = null; + self.force_interpreter = false; self.module = rt.module_mod.Module.init(allocator, wasm_bytes); self.module.decode() catch |err| { self.module.deinit(); + self.store.deinit(); allocator.destroy(self); return err; }; @@ -379,6 +394,7 @@ pub const WasmModule = struct { self.instance.instantiateBase() catch |err| { self.instance.deinit(); self.module.deinit(); + self.store.deinit(); allocator.destroy(self); return err; }; @@ -394,6 +410,7 @@ pub const WasmModule = struct { return .{ .module = self, .apply_error = error.OutOfMemory }; }; self.vm.* = rt.vm_mod.Vm.init(allocator); + self.vm.cancellable = cancellable; // Phase 2: apply active element/data segments (may partially fail). var apply_error: ?anyerror = null; @@ -404,7 +421,7 @@ pub const WasmModule = struct { return .{ .module = self, .apply_error = apply_error }; } - fn loadCore(allocator: Allocator, wasm_bytes: []const u8, wasi: bool, imports: ?[]const ImportEntry, fuel: ?u64) !*WasmModule { + fn loadCore(allocator: Allocator, wasm_bytes: []const u8, config: Config) !*WasmModule { const self = try allocator.create(WasmModule); errdefer allocator.destroy(self); @@ -417,7 +434,7 @@ pub const WasmModule = struct { errdefer self.module.deinit(); try self.module.decode(); - if (wasi) { + if (config.wasi) { try rt.wasi.registerAll(&self.store, &self.module); self.wasi_ctx = rt.wasi.WasiContext.init(allocator); self.wasi_ctx.?.caps = rt.wasi.Capabilities.cli_default; @@ -426,8 +443,14 @@ pub const WasmModule = struct { } errdefer if (self.wasi_ctx) |*wc| wc.deinit(); - if (imports) |import_entries| { - try registerImports(&self.store, &self.module, import_entries, allocator); + if (self.wasi_ctx) |*wc| { + if (config.wasi_options) |opts| { + try applyWasiOptions(wc, opts); + } + } + + if (config.imports.len > 0) { + try registerImports(&self.store, &self.module, config.imports, allocator); } self.instance = rt.instance_mod.Instance.init(allocator, &self.store, &self.module); @@ -440,14 +463,26 @@ pub const WasmModule = struct { self.wit_funcs = &[_]wit_parser.WitFunc{}; self.vm = try allocator.create(rt.vm_mod.Vm); + errdefer allocator.destroy(self.vm); self.vm.* = rt.vm_mod.Vm.init(allocator); - self.vm.fuel = fuel; + self.max_memory_bytes = config.max_memory_bytes; + self.force_interpreter = config.force_interpreter; + self.timeout_ms = config.timeout_ms; + self.fuel = config.fuel; + self.vm.max_memory_bytes = self.max_memory_bytes; + self.vm.force_interpreter = self.force_interpreter; + self.vm.fuel = self.fuel; + self.vm.setDeadlineTimeoutMs(self.timeout_ms); // Execute start function if present if (self.module.start) |start_idx| { self.vm.reset(); - self.vm.fuel = fuel; + self.vm.fuel = self.fuel; + self.vm.max_memory_bytes = self.max_memory_bytes; + self.vm.force_interpreter = self.force_interpreter; + self.vm.setDeadlineTimeoutMs(self.timeout_ms); try self.vm.invokeByIndex(&self.instance, start_idx, &.{}, &.{}); + self.fuel = self.vm.fuel; } return self; @@ -476,6 +511,11 @@ pub const WasmModule = struct { /// Args and results are passed as u64 arrays. pub fn invoke(self: *WasmModule, name: []const u8, args: []const u64, results: []u64) !void { self.vm.reset(); + self.vm.fuel = self.fuel; + self.vm.max_memory_bytes = self.max_memory_bytes; + self.vm.force_interpreter = self.force_interpreter; + self.vm.setDeadlineTimeoutMs(self.timeout_ms); + defer self.fuel = self.vm.fuel; try self.vm.invoke(&self.instance, name, args, results); } @@ -483,11 +523,24 @@ pub const WasmModule = struct { /// Used by differential testing to get a reference result. pub fn invokeInterpreterOnly(self: *WasmModule, name: []const u8, args: []const u64, results: []u64) !void { self.vm.reset(); + self.vm.fuel = self.fuel; + self.vm.max_memory_bytes = self.max_memory_bytes; self.vm.force_interpreter = true; - defer self.vm.force_interpreter = false; + self.vm.setDeadlineTimeoutMs(self.timeout_ms); + defer self.vm.force_interpreter = self.force_interpreter; + defer self.fuel = self.vm.fuel; try self.vm.invoke(&self.instance, name, args, results); } + /// Request cancellation of the currently executing Wasm function. + /// Can be called from another thread while invoke() is in progress. + /// The execution will be stopped at the next instruction checkpoint (approximately every 1024 instructions), + /// and invoke() will return error.Canceled. + /// Thread-safe. Has no effect if no function is currently executing. + pub fn cancel(self: *WasmModule) void { + self.vm.cancel(); + } + /// Read bytes from linear memory at the given offset. /// The returned slice is owned by the caller and must be freed with `allocator`. pub fn memoryRead(self: *WasmModule, allocator: Allocator, offset: u32, length: u32) ![]const u8 { @@ -1109,7 +1162,7 @@ test "multi-module — shared table via loadLinked" { // Load Ot into Mt's shared store: imports tab and h from Mt, writes elem [1,2] = [$i,$h] const ot_bytes = @embedFile("testdata/31_table_import.wasm"); - const ot_result = try WasmModule.loadLinked(testing.allocator, ot_bytes, &mt.store); + const ot_result = try WasmModule.loadLinked(testing.allocator, ot_bytes, &mt.store, true); var ot = ot_result.module; defer ot.deinit(); try testing.expect(ot_result.apply_error == null); @@ -1385,3 +1438,57 @@ test "loadWasiWithOptions explicit all grants full access" { try testing.expect(caps.allow_env); try testing.expect(caps.allow_path); } + +test "force_interpreter — persistence across invokeInterpreterOnly calls" { + if (!@import("build_options").enable_wat) return error.SkipZigTest; + var wasm_mod = try WasmModule.loadFromWat(testing.allocator, + \\(module + \\ (func (export "f") (result i32) + \\ i32.const 42 + \\ ) + \\) + ); + defer wasm_mod.deinit(); + + // Set force_interpreter to true on load + wasm_mod.force_interpreter = true; + wasm_mod.vm.force_interpreter = true; + + var results = [_]u64{0}; + + // 1. Regular invoke should follow persistent setting + try wasm_mod.invoke("f", &.{}, &results); + try testing.expect(wasm_mod.vm.force_interpreter == true); + try testing.expectEqual(@as(u64, 42), results[0]); + + // 2. invokeInterpreterOnly should restore the persistent setting afterward + try wasm_mod.invokeInterpreterOnly("f", &.{}, &results); + try testing.expect(wasm_mod.vm.force_interpreter == true); // must NOT be reset to false + try testing.expectEqual(@as(u64, 42), results[0]); + + // 3. Subsequent regular invoke still sees interpreter mode + try wasm_mod.invoke("f", &.{}, &results); + try testing.expect(wasm_mod.vm.force_interpreter == true); + try testing.expectEqual(@as(u64, 42), results[0]); + + // 4. Clearing the flag persists to the next invoke + wasm_mod.force_interpreter = false; + try wasm_mod.invoke("f", &.{}, &results); + try testing.expect(wasm_mod.vm.force_interpreter == false); +} + +test "WasmModule.Config applies VM limits" { + const wasm_bytes = &[_]u8{ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00 }; + var wasm_mod = try WasmModule.loadWithOptions(testing.allocator, wasm_bytes, .{ + .fuel = 12345, + .timeout_ms = 5000, + .max_memory_bytes = 1048576, + .force_interpreter = true, + }); + defer wasm_mod.deinit(); + + try testing.expectEqual(@as(?u64, 12345), wasm_mod.vm.fuel); + try testing.expectEqual(@as(?u64, 1048576), wasm_mod.vm.max_memory_bytes); + try testing.expectEqual(true, wasm_mod.vm.force_interpreter); + try testing.expect(wasm_mod.vm.deadline_ns != null); +} diff --git a/src/vm.zig b/src/vm.zig index 458fabfb..65dc966a 100644 --- a/src/vm.zig +++ b/src/vm.zig @@ -102,6 +102,7 @@ pub const WasmError = error{ WasmException, FuelExhausted, TimeoutExceeded, + Canceled, LabelStackUnderflow, OperandStackUnderflow, MemoryLimitExceeded, @@ -399,14 +400,19 @@ pub const Vm = struct { fuel: ?u64 = null, deadline_ns: ?i128 = null, deadline_check_remaining: u32 = DEADLINE_CHECK_INTERVAL, + cancelled: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Whether this module should support asynchronous cancellation. + /// If true (default), JIT loops are periodically interrupted to check the flag. + /// If false, JIT execution runs at maximum speed but cannot be cancelled. + cancellable: bool = true, /// Force stack-based interpreter for all functions, bypassing RegIR and JIT. /// Used by differential testing to get a "reference" result. force_interpreter: bool = false, /// JIT-accessible fuel counter. Signed so JIT can check < 0 with a single /// branch after decrement. Synced from/to `fuel` before/after JIT execution. - /// When deadline is active, armed to DEADLINE_JIT_INTERVAL so JIT periodically - /// calls the fuel check helper to verify wall-clock time. - /// maxInt = unlimited (JIT skips the check entirely when this value is seen). + /// When deadline or cancellation is active, armed to DEADLINE_JIT_INTERVAL so + /// JIT periodically calls the fuel check helper to verify state. + /// maxInt = unlimited (JIT practically never fires the check helper). jit_fuel: i64 = std.math.maxInt(i64), /// The value jit_fuel was last armed to. Used to calculate consumed fuel /// when the fuel check helper fires or JIT exits normally. @@ -452,6 +458,7 @@ pub const Vm = struct { self.exn_store_count = 0; self.call_depth = 0; self.deadline_check_remaining = DEADLINE_CHECK_INTERVAL; + self.cancelled.store(false, .release); } pub fn setDeadlineTimeoutMs(self: *Vm, timeout_ms: ?u64) void { @@ -467,18 +474,32 @@ pub const Vm = struct { self.deadline_check_remaining = DEADLINE_CHECK_INTERVAL; } + pub fn cancel(self: *Vm) void { + self.cancelled.store(true, .release); + } + inline fn consumeInstructionBudget(self: *Vm) WasmError!void { + // 1. Precise fuel check (if enabled) — non-atomic, very hot if (self.fuel) |*f| { if (f.* == 0) return error.FuelExhausted; f.* -= 1; } - if (self.deadline_ns) |deadline_ns| { + + // 2. Periodic check for time/cancellation budget — reduced frequency + if (self.deadline_ns != null or self.cancellable) { if (self.deadline_check_remaining == 0) { + // Check cancellation (atomic) only once every 1024 instructions + if (self.cancellable and self.cancelled.load(.acquire)) { + return error.Canceled; + } + // Check deadline (wal-clock time) + if (self.deadline_ns) |d| { + if (std.time.nanoTimestamp() >= d) return error.TimeoutExceeded; + } + // Reset check counter self.deadline_check_remaining = DEADLINE_CHECK_INTERVAL; - if (std.time.nanoTimestamp() >= deadline_ns) return error.TimeoutExceeded; - } else { - self.deadline_check_remaining -= 1; } + self.deadline_check_remaining -= 1; } } @@ -488,7 +509,8 @@ pub const Vm = struct { pub fn armJitFuel(self: *Vm) void { const fuel_budget: i64 = if (self.fuel) |f| @intCast(f) else std.math.maxInt(i64); const deadline_budget: i64 = if (self.deadline_ns != null) DEADLINE_JIT_INTERVAL else std.math.maxInt(i64); - self.jit_fuel = @min(fuel_budget, deadline_budget); + const cancel_budget: i64 = if (self.cancellable) DEADLINE_JIT_INTERVAL else std.math.maxInt(i64); + self.jit_fuel = @min(@min(fuel_budget, deadline_budget), cancel_budget); self.jit_fuel_initial = self.jit_fuel; } @@ -520,13 +542,14 @@ pub const Vm = struct { 8 => error.InvalidConversion, 9 => error.FuelExhausted, 10 => error.TimeoutExceeded, + 11 => error.Canceled, else => error.Trap, }; } /// JIT fuel check helper — called from JIT code when jit_fuel goes negative. /// Returns 0 to continue execution, or an error code to exit JIT: - /// 9 = FuelExhausted, 10 = TimeoutExceeded. + /// 9 = FuelExhausted, 10 = TimeoutExceeded, 11 = Canceled. pub fn jitFuelCheckHelper(vm: *Vm) callconv(.c) u64 { // Sync consumed fuel back to interpreter counter vm.syncJitFuelBack(); @@ -536,6 +559,9 @@ pub const Vm = struct { if (f == 0) return 9; // FuelExhausted } + // Check cancellation + if (vm.cancellable and vm.cancelled.load(.acquire)) return 11; // Canceled + // Check wall-clock deadline if (vm.deadline_ns) |dl| { if (std.time.nanoTimestamp() >= dl) return 10; // TimeoutExceeded @@ -692,8 +718,7 @@ pub const Vm = struct { // JIT compilation: check hot threshold (skip when profiling or fuel metering) if (comptime jit_mod.jitSupported()) { - if (self.profile == null and wf.jit_code == null and !wf.jit_failed) - { + if (self.profile == null and wf.jit_code == null and !wf.jit_failed) { wf.call_count += 1; if (wf.call_count >= jit_mod.HOT_THRESHOLD) { // Skip JIT for very large functions — single-pass regalloc @@ -4368,7 +4393,6 @@ pub const Vm = struct { // Arm fuel/deadline interval for JIT self.armJitFuel(); - // Call OSR entry: sets up callee-saved, memory cache, then jumps to loop body const err_code = osr_fn(regs_ptr, @ptrCast(self), @ptrCast(instance)); @@ -4577,7 +4601,6 @@ pub const Vm = struct { const cached_mem: ?*WasmMemory = instance.getMemory(0) catch null; var pc: u32 = 0; - // Back-edge counting for JIT hot loop detection (ARM64 only) var back_edge_count: u32 = 0; const wf: ?*store_mod.WasmFunction = if (func_ptr.subtype == .wasm_function) @@ -5689,19 +5712,19 @@ pub const Vm = struct { // --- Push operands from regs[] to op_stack --- if (effect.pop == 3) { // bitselect(a, b, c): main rs1=a, rs2=b, NOP.rd=c - self.pushRegToOpStack(regs,instr.rs1); - self.pushRegToOpStack(regs,instr.rs2_field); - self.pushRegToOpStack(regs,third_operand); + self.pushRegToOpStack(regs, instr.rs1); + self.pushRegToOpStack(regs, instr.rs2_field); + self.pushRegToOpStack(regs, third_operand); } else if (effect.push == 0 and effect.pop == 2) { // Store ops: rd=value, rs1=addr. Stack: [addr(bottom), value(top)] - self.pushRegToOpStack(regs,instr.rs1); - self.pushRegToOpStack(regs,instr.rd); + self.pushRegToOpStack(regs, instr.rs1); + self.pushRegToOpStack(regs, instr.rd); } else if (effect.pop == 2) { // Binary ops: rs1=first, rs2=second. Stack: [first(bottom), second(top)] - self.pushRegToOpStack(regs,instr.rs1); - self.pushRegToOpStack(regs,instr.rs2_field); + self.pushRegToOpStack(regs, instr.rs1); + self.pushRegToOpStack(regs, instr.rs2_field); } else if (effect.pop == 1) { - self.pushRegToOpStack(regs,instr.rs1); + self.pushRegToOpStack(regs, instr.rs1); } // --- Call existing SIMD interpreter --- @@ -10153,12 +10176,113 @@ test "armJitFuel — fuel+deadline picks smaller" { try testing.expectEqual(@as(i64, 500), vm.jit_fuel); } -test "armJitFuel — no fuel no deadline stays maxInt" { +test "armJitFuel — no fuel no deadline arms to DEADLINE_JIT_INTERVAL" { var vm = Vm.init(testing.allocator); vm.armJitFuel(); + try testing.expectEqual(DEADLINE_JIT_INTERVAL, vm.jit_fuel); +} + +test "armJitFuel — cancellable = false prevents capping" { + var vm = Vm.init(testing.allocator); + vm.cancellable = false; + vm.armJitFuel(); try testing.expectEqual(@as(i64, std.math.maxInt(i64)), vm.jit_fuel); } +test "Cancellation — cancel flag stops interpreter loop" { + // A background thread calls cancel() while invoke() is running. + // consumeInstructionBudget() detects the flag at the next checkpoint + // and returns error.Canceled, unwinding the infinite loop. + const wasm = try readTestFile(testing.allocator, "30_infinite_loop.wasm"); + defer testing.allocator.free(wasm); + var mod = Module.init(testing.allocator, wasm); + defer mod.deinit(); + try mod.decode(); + var store = Store.init(testing.allocator); + defer store.deinit(); + var inst = Instance.init(testing.allocator, &store, &mod); + defer inst.deinit(); + try inst.instantiate(); + + var vm = Vm.init(testing.allocator); + vm.force_interpreter = true; + + const cancel_thread = try std.Thread.spawn(.{}, struct { + fn run(v: *Vm) void { + std.Thread.sleep(1 * std.time.ns_per_ms); // let invoke() start + v.cancel(); + } + }.run, .{&vm}); + defer cancel_thread.join(); + + var results = [_]u64{0}; + try testing.expectError(error.Canceled, vm.invoke(&inst, "loop", &.{}, &results)); +} + +test "Cancellation — cancel flag resets on reset" { + // reset() clears the cancel flag — the next invoke can proceed normally. + var vm = Vm.init(testing.allocator); + vm.cancel(); + try testing.expect(vm.cancelled.load(.acquire)); + vm.reset(); + try testing.expect(!vm.cancelled.load(.acquire)); +} + +test "Cancellation — jitFuelCheckHelper cancelled returns Canceled" { + // Unit test for the JIT helper: returns error code 11 (Canceled) when flag is set. + var vm = Vm.init(testing.allocator); + vm.cancel(); + vm.jit_fuel = -1; + vm.jit_fuel_initial = DEADLINE_JIT_INTERVAL; + + const result = Vm.jitFuelCheckHelper(&vm); + try testing.expectEqual(@as(u64, 11), result); // Canceled +} + +test "Cancellation — jitFuelCheckHelper respects cancellable = false" { + // When cancellable is false, jitFuelCheckHelper should ignore the cancelled flag. + var vm = Vm.init(testing.allocator); + vm.cancellable = false; + vm.cancel(); + vm.jit_fuel = -1; + vm.jit_fuel_initial = DEADLINE_JIT_INTERVAL; + + const result = Vm.jitFuelCheckHelper(&vm); + // Should NOT return 11 (Canceled) even though the flag is set + try testing.expect(result != 11); +} + +test "Cancellation — cancel flag stops JIT loop" { + if (!build_options.enable_jit) return error.SkipZigTest; + // A background thread calls cancel() while the JIT-compiled loop is running. + // When cancellable is true (default), armJitFuel caps jit_fuel to + // DEADLINE_JIT_INTERVAL even without a deadline, so that + // jitFuelCheckHelper fires periodically and detects the cancel flag. + const wasm = try readTestFile(testing.allocator, "30_infinite_loop.wasm"); + defer testing.allocator.free(wasm); + var mod = Module.init(testing.allocator, wasm); + defer mod.deinit(); + try mod.decode(); + var store = Store.init(testing.allocator); + defer store.deinit(); + var inst = Instance.init(testing.allocator, &store, &mod); + defer inst.deinit(); + try inst.instantiate(); + + var vm = Vm.init(testing.allocator); + + const cancel_thread = try std.Thread.spawn(.{}, struct { + fn run(v: *Vm) void { + std.Thread.sleep(1 * std.time.ns_per_ms); // let invoke() start + v.cancel(); + } + }.run, .{&vm}); + defer cancel_thread.join(); + + var results = [_]u64{0}; + try testing.expectError(error.Canceled, vm.invoke(&inst, "loop", &.{}, &results)); +} + test "Back-edge JIT — hasPrologueSideEffects" { const R = regalloc_mod.RegInstr; diff --git a/test/c_api/run_ffi_test.sh b/test/c_api/run_ffi_test.sh index eb25bae1..b66e88f4 100644 --- a/test/c_api/run_ffi_test.sh +++ b/test/c_api/run_ffi_test.sh @@ -32,7 +32,7 @@ fi # Compile test binary echo "Compiling FFI test..." -gcc -o /tmp/zwasm_ffi_test test/c_api/test_ffi.c -ldl -O0 -g +gcc -o /tmp/zwasm_ffi_test test/c_api/test_ffi.c -ldl -pthread -O0 -g # Run echo "" diff --git a/test/c_api/test_ffi.c b/test/c_api/test_ffi.c index 1e3c6e07..a9087bca 100644 --- a/test/c_api/test_ffi.c +++ b/test/c_api/test_ffi.c @@ -18,6 +18,7 @@ #include #include #include +#include /* ------------------------------------------------------------------ */ /* Test harness */ @@ -70,6 +71,7 @@ typedef bool (*fn_module_validate)(const uint8_t *, size_t); /* Invocation */ typedef bool (*fn_module_invoke)(zwasm_module_t, const char *, uint64_t *, uint32_t, uint64_t *, uint32_t); typedef bool (*fn_module_invoke_start)(zwasm_module_t); +typedef void (*fn_module_cancel)(zwasm_module_t); /* Export introspection */ typedef uint32_t (*fn_export_count)(zwasm_module_t); @@ -89,6 +91,11 @@ typedef const char *(*fn_last_error)(void); /* Config */ typedef zwasm_config_t (*fn_config_new)(void); typedef void (*fn_config_delete)(zwasm_config_t); +typedef void (*fn_config_set_fuel)(zwasm_config_t, uint64_t); +typedef void (*fn_config_set_timeout)(zwasm_config_t, uint64_t); +typedef void (*fn_config_set_max_memory)(zwasm_config_t, uint64_t); +typedef void (*fn_config_set_force_interpreter)(zwasm_config_t, bool); +typedef void (*fn_config_set_cancellable)(zwasm_config_t, bool); /* Imports */ typedef zwasm_imports_t (*fn_import_new)(void); @@ -117,6 +124,7 @@ static struct { fn_module_validate module_validate; fn_module_invoke module_invoke; fn_module_invoke_start module_invoke_start; + fn_module_cancel module_cancel; fn_export_count export_count; fn_export_name export_name; fn_export_param_count export_param_count; @@ -128,6 +136,11 @@ static struct { fn_last_error last_error; fn_config_new config_new; fn_config_delete config_delete; + fn_config_set_fuel config_set_fuel; + fn_config_set_timeout config_set_timeout; + fn_config_set_max_memory config_set_max_memory; + fn_config_set_force_interpreter config_set_force_interpreter; + fn_config_set_cancellable config_set_cancellable; fn_import_new import_new; fn_import_delete import_delete; fn_import_add_fn import_add_fn; @@ -138,6 +151,21 @@ static struct { fn_module_new_wasi_configured module_new_wasi_configured; } api; +typedef struct { + zwasm_module_t module; +} CancelThreadArgs; + +static void *cancel_thread_main(void *raw) { + CancelThreadArgs *args = (CancelThreadArgs *)raw; + /* Keep cancel requests alive across invoke start/reset race. */ + usleep(100); + for (int i = 0; i < 200; i++) { + api.module_cancel(args->module); + usleep(100); + } + return NULL; +} + static void *lib_handle = NULL; #define LOAD_SYM(field, name) do { \ @@ -162,6 +190,7 @@ static bool load_api(const char *path) { LOAD_SYM(module_validate, "zwasm_module_validate"); LOAD_SYM(module_invoke, "zwasm_module_invoke"); LOAD_SYM(module_invoke_start, "zwasm_module_invoke_start"); + LOAD_SYM(module_cancel, "zwasm_module_cancel"); LOAD_SYM(export_count, "zwasm_module_export_count"); LOAD_SYM(export_name, "zwasm_module_export_name"); LOAD_SYM(export_param_count, "zwasm_module_export_param_count"); @@ -173,6 +202,11 @@ static bool load_api(const char *path) { LOAD_SYM(last_error, "zwasm_last_error_message"); LOAD_SYM(config_new, "zwasm_config_new"); LOAD_SYM(config_delete, "zwasm_config_delete"); + LOAD_SYM(config_set_fuel, "zwasm_config_set_fuel"); + LOAD_SYM(config_set_timeout, "zwasm_config_set_timeout"); + LOAD_SYM(config_set_max_memory, "zwasm_config_set_max_memory"); + LOAD_SYM(config_set_force_interpreter, "zwasm_config_set_force_interpreter"); + LOAD_SYM(config_set_cancellable, "zwasm_config_set_cancellable"); LOAD_SYM(import_new, "zwasm_import_new"); LOAD_SYM(import_delete, "zwasm_import_delete"); LOAD_SYM(import_add_fn, "zwasm_import_add_fn"); @@ -234,12 +268,23 @@ static const uint8_t IMPORT_WASM[] = { 0x0a, 0x0a, 0x01, 0x08, 0x00, 0x41, 0x03, 0x41, 0x04, 0x10, 0x00, 0x0b }; +/* Module: (func (export "loop") (loop (br 0))) — infinite loop, never completes */ +static const uint8_t INFINITE_LOOP_WASM[] = { + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, /* type: () -> () */ + 0x03, 0x02, 0x01, 0x00, /* func 0: type 0 */ + 0x07, 0x08, 0x01, 0x04, 0x6c, 0x6f, 0x6f, 0x70, /* export "loop" */ + 0x00, 0x00, + 0x0a, 0x09, 0x01, 0x07, 0x00, 0x03, 0x40, 0x0c, 0x00, /* code: loop br 0 end end */ + 0x0b, 0x0b +}; + /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ static void test_symbol_resolution(void) { - printf("-- symbol resolution (all 22 exports)\n"); + printf("-- symbol resolution (all required exports)\n"); /* Already verified by load_api — if we got here, all symbols resolved. */ ASSERT(api.module_new != NULL, "zwasm_module_new resolved"); ASSERT(api.module_delete != NULL, "zwasm_module_delete resolved"); @@ -450,6 +495,15 @@ static void test_config_lifecycle(void) { zwasm_config_t cfg = api.config_new(); ASSERT(cfg != NULL, "config_new"); + api.config_set_fuel(cfg, 10000); + api.config_set_timeout(cfg, 5000); + api.config_set_max_memory(cfg, 65536); + api.config_set_force_interpreter(cfg, true); + + zwasm_module_t mod2 = api.module_new_configured(RETURN42_WASM, sizeof(RETURN42_WASM), cfg); + ASSERT(mod2 != NULL, "module_new_configured(cfg)"); + if (mod2) api.module_delete(mod2); + if (cfg) api.config_delete(cfg); /* Configured module (NULL config = default) */ @@ -541,6 +595,65 @@ static void test_repeated_create_destroy(void) { ASSERT(true, "100 create/invoke/destroy cycles"); } +static void test_cancellable_config(void) { + zwasm_config_t *config = api.config_new(); + ASSERT(config != NULL, "config created"); + + /* Test disabling cancellation */ + api.config_set_cancellable(config, false); + + zwasm_module_t mod = api.module_new_configured(MINIMAL_WASM, sizeof(MINIMAL_WASM), config); + ASSERT(mod != NULL, "module created with cancellable=false"); + + api.module_delete(mod); + api.config_delete(config); +} + +static void test_cancel_api(void) { + printf("-- cancel API (thread-safety check)\n"); + + zwasm_module_t mod = api.module_new(RETURN42_WASM, sizeof(RETURN42_WASM)); + ASSERT(mod != NULL, "module loaded"); + if (!mod) return; + + /* Call cancel on idle module (should be no-op) */ + api.module_cancel(mod); + ASSERT(true, "module_cancel on idle module"); + + /* invoke() resets cancellation state at entry, so this should succeed */ + uint64_t r[1] = {0}; + ASSERT(api.module_invoke(mod, "f", NULL, 0, r, 1), + "invoke after cancel (flag cleared by reset)"); + ASSERT_EQ_U64(r[0], 42, "result after cancel is correct"); + + api.module_delete(mod); + + /* Concurrent cancel: cancel from another thread while invoke("loop") is running. + * The module runs an infinite loop, so the ONLY way invoke() can return is + * via cancellation. If cancel() is broken, this test will hang forever + * (caught by CI timeout). */ + zwasm_module_t loop_mod = api.module_new(INFINITE_LOOP_WASM, sizeof(INFINITE_LOOP_WASM)); + ASSERT(loop_mod != NULL, "infinite loop module loaded"); + if (!loop_mod) return; + + CancelThreadArgs cargs = { .module = loop_mod }; + + pthread_t tid; + int create_rc = pthread_create(&tid, NULL, cancel_thread_main, &cargs); + ASSERT(create_rc == 0, "pthread_create for cancel thread"); + if (create_rc == 0) { + bool ok = api.module_invoke(loop_mod, "loop", NULL, 0, NULL, 0); + /* invoke() MUST fail — the loop is infinite, so success is impossible */ + ASSERT(!ok, "invoke of infinite loop was cancelled (did not complete)"); + const char *err = api.last_error(); + ASSERT(err != NULL && strstr(err, "Canceled") != NULL, + "last_error indicates Canceled"); + ASSERT(pthread_join(tid, NULL) == 0, "pthread_join cancel thread"); + } + + api.module_delete(loop_mod); +} + /* ------------------------------------------------------------------ */ /* Main */ /* ------------------------------------------------------------------ */ @@ -577,9 +690,11 @@ int main(int argc, char **argv) { test_no_memory_module(); test_host_imports(); test_config_lifecycle(); + test_cancellable_config(); test_multiple_modules(); test_wasi_config_fd_api(); test_repeated_create_destroy(); + test_cancel_api(); printf("\n%d/%d passed, %d failed\n", tests_passed, tests_run, tests_failed);