diff --git a/.dev/decisions.md b/.dev/decisions.md index c6195664..22a1faf4 100644 --- a/.dev/decisions.md +++ b/.dev/decisions.md @@ -601,3 +601,47 @@ or `own` (runtime closes on teardown). to keep the header cross-language friendly. **Affected files**: wasi.zig, types.zig, c_api.zig, include/zwasm.h, test_ffi.c. + +## D134: Async Execution Cancellation + +**Context**: Issue #27 / PR #28. Embedders need to abort a running Wasm invocation +from another thread — for instance to enforce user-initiated cancel buttons on top +of Wasm plugins, or to unwind infinite loops that escape fuel/deadline budgets. +Pre-set fuel limits and deadline timeouts were the only existing escape hatches; +both are decided before execution starts. + +**Decision**: Expose a thread-safe `cancel()` request on `Vm` / `WasmModule` / +`zwasm_module_t`. The interpreter and JIT both poll the flag at their existing +periodic budget checkpoints, so the new path reuses proven machinery rather than +adding a second interrupt mechanism. + +**Mechanism**: +- `Vm.cancelled: std.atomic.Value(bool)` — `release` store on cancel, `acquire` + load on check. +- Interpreter path: `consumeInstructionBudget()` already fired every + `DEADLINE_CHECK_INTERVAL` (1024) instructions for deadline polling; cancel + checks piggyback on the same checkpoint with no extra branch on the hot path. +- JIT path: `armJitFuel` caps `jit_fuel` to `DEADLINE_JIT_INTERVAL` when + cancellation is armed, so `jitFuelCheckHelper` fires periodically even when + no fuel/deadline is set. The helper returns error code `11` (`Canceled`) back + into the trampoline. +- `reset()` clears the flag — each `invoke()` starts from a clean state, and + cancel requests issued against an idle module are dropped. This is documented + on both the Zig and C APIs; the FFI test races cancels across invoke start + to cover the corresponding window. + +**Opt-out**: `Vm.cancellable: bool = true` (Zig default) / `cancellable: ?bool` +in `WasmModule.Config` / `zwasm_config_set_cancellable(config, false)`. Disabling +restores `jit_fuel = maxInt(i64)` for fuel/deadline-free runs, recovering pre-PR +throughput for hosts that never need cancel. + +**API surface**: +- Zig: `Vm.cancel()`, `WasmModule.cancel()`, `error.Canceled`, + `WasmModule.Config.cancellable: ?bool`. +- C: `void zwasm_module_cancel(zwasm_module_t *)`, + `void zwasm_config_set_cancellable(zwasm_config_t *, bool)`. +- CLI: `error.Canceled` prints "execution canceled". + +**Affected files**: vm.zig, types.zig, cli.zig, c_api.zig, include/zwasm.h, +test/c_api/test_ffi.c, docs/{embedding,errors,usage,api-boundary}.md, +book/{en,ja}/src/{c-api,embedding-guide}.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2707c6..b4da524a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Asynchronous execution cancellation (PR #28 by @jtakakura, closes #27). A host thread can now abort a running invocation: + - Zig API: `WasmModule.cancel()` / `Vm.cancel()` — thread-safe, returns `error.Canceled` from `invoke()` at the next ~1024-instruction checkpoint (or JIT fuel interval). + - C API: `void zwasm_module_cancel(zwasm_module_t *)` and `void zwasm_config_set_cancellable(zwasm_config_t *, bool)`. + - CLI: reports `execution canceled` when the runtime returns `error.Canceled`. +- `WasmModule.Config.cancellable: ?bool` — opt-out of periodic cancellation checks for peak JIT throughput when the host never cancels. + +### Changed +- By default, JIT-compiled loops now fire the fuel-check helper every `DEADLINE_JIT_INTERVAL` iterations even when no fuel/deadline is set, so cancellation takes effect without host instrumentation. Pass `cancellable = false` to restore the pre-PR unconditional `jit_fuel = maxInt(i64)` behaviour. + ## [1.8.0] - 2026-04-21 ### Added 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/bench/history.yaml b/bench/history.yaml index 90cfc60b..cc2cb2e6 100644 --- a/bench/history.yaml +++ b/bench/history.yaml @@ -3629,3 +3629,67 @@ entries: rw_cpp_string_cached: {time_ms: 5.1} rw_cpp_sort: {time_ms: 3.4} rw_cpp_sort_cached: {time_ms: 4.3} + - id: "PR28" + date: "2026-04-24" + reason: "execution cancellation API (D134)" + commit: "3bad07a" + build: ReleaseSafe + results: + fib: {time_ms: 48.8} + fib_cached: {time_ms: 49.6} + tak: {time_ms: 7.3} + tak_cached: {time_ms: 6.3} + sieve: {time_ms: 4.9} + sieve_cached: {time_ms: 6.2} + nbody: {time_ms: 26.3} + nbody_cached: {time_ms: 22.3} + nqueens: {time_ms: 3.0} + nqueens_cached: {time_ms: 2.1} + tgo_fib: {time_ms: 33.9} + tgo_fib_cached: {time_ms: 36.3} + tgo_tak: {time_ms: 6.3} + tgo_tak_cached: {time_ms: 7.8} + tgo_arith: {time_ms: 2.6} + tgo_arith_cached: {time_ms: 2.3} + tgo_sieve: {time_ms: 3.7} + tgo_sieve_cached: {time_ms: 2.9} + tgo_fib_loop: {time_ms: 1.5} + tgo_fib_loop_cached: {time_ms: 3.4} + tgo_gcd: {time_ms: 2.7} + tgo_gcd_cached: {time_ms: 2.3} + tgo_nqueens: {time_ms: 56.7} + tgo_nqueens_cached: {time_ms: 57.2} + tgo_mfr: {time_ms: 50.0} + tgo_mfr_cached: {time_ms: 44.6} + tgo_list: {time_ms: 60.1} + tgo_list_cached: {time_ms: 60.3} + tgo_rwork: {time_ms: 7.2} + tgo_rwork_cached: {time_ms: 8.0} + tgo_strops: {time_ms: 83.5} + tgo_strops_cached: {time_ms: 85.9} + st_fib2: {time_ms: 889.9} + st_fib2_cached: {time_ms: 885.9} + st_sieve: {time_ms: 181.0} + st_sieve_cached: {time_ms: 181.7} + st_nestedloop: {time_ms: 2.1} + st_nestedloop_cached: {time_ms: 2.0} + st_ackermann: {time_ms: 5.6} + st_ackermann_cached: {time_ms: 4.4} + st_matrix: {time_ms: 289.0} + st_matrix_cached: {time_ms: 289.2} + gc_alloc: {time_ms: 6.0} + gc_alloc_cached: {time_ms: 5.5} + gc_tree: {time_ms: 33.1} + gc_tree_cached: {time_ms: 25.7} + rw_rust_fib: {time_ms: 38.8} + rw_rust_fib_cached: {time_ms: 36.6} + rw_c_matrix: {time_ms: 4.3} + rw_c_matrix_cached: {time_ms: 3.1} + rw_c_math: {time_ms: 22.5} + rw_c_math_cached: {time_ms: 19.1} + rw_c_string: {time_ms: 44.8} + rw_c_string_cached: {time_ms: 45.8} + rw_cpp_string: {time_ms: 10.2} + rw_cpp_string_cached: {time_ms: 7.0} + rw_cpp_sort: {time_ms: 4.1} + rw_cpp_sort_cached: {time_ms: 3.6} 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 02d70f8c..61623dd0 100644 --- a/docs/api-boundary.md +++ b/docs/api-boundary.md @@ -37,6 +37,8 @@ Types and functions listed here are covered by SemVer guarantees. | `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 df69eb69..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`, `zwasm_config_set_fuel`, `..._set_timeout`, `..._set_max_memory`, `..._set_force_interpreter` | +| 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 c531307e..280d23ca 100644 --- a/include/zwasm.h +++ b/include/zwasm.h @@ -113,6 +113,13 @@ void zwasm_config_set_max_memory(zwasm_config_t *config, uint64_t max_memory_byt */ 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 * ================================================================ */ @@ -222,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 5b80e434..cf9676a9 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -92,6 +92,7 @@ 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, @@ -109,12 +110,17 @@ const CApiConfig = struct { } /// Build a WasmModule.Config from this C API config. + /// The C ABI exposes `cancellable` as a plain bool, so we always forward a + /// concrete value. The C default (true) matches the Vm default, so the + /// resulting Config is a no-op override for callers who never touch the + /// setter. 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, + .cancellable = self.cancellable, }; } }; @@ -140,8 +146,8 @@ const default_allocator = std.heap.c_allocator; const CApiModule = struct { module: *WasmModule, - fn create(wasm_bytes: []const u8, wasi: bool) !*CApiModule { - return createConfigured(wasm_bytes, wasi, null); + fn create(wasm_bytes: []const u8, wasi: bool, config: ?*CApiConfig) !*CApiModule { + return createConfigured(wasm_bytes, wasi, config); } fn createConfigured(wasm_bytes: []const u8, wasi: bool, config: ?*CApiConfig) !*CApiModule { @@ -156,8 +162,8 @@ const CApiModule = struct { return self; } - fn createWasiConfigured(wasm_bytes: []const u8, opts: WasiOptions) !*CApiModule { - return createWasiConfiguredEx(wasm_bytes, opts, null); + fn createWasiConfigured(wasm_bytes: []const u8, opts: WasiOptions, config: ?*CApiConfig) !*CApiModule { + return createWasiConfiguredEx(wasm_bytes, opts, config); } fn createWasiConfiguredEx(wasm_bytes: []const u8, opts: WasiOptions, config: ?*CApiConfig) !*CApiModule { @@ -173,10 +179,15 @@ const CApiModule = struct { 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.loadWithOptions(default_allocator, wasm_bytes, .{ .imports = imports }); + + 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.imports = imports; + + self.module = try WasmModule.loadWithOptions(allocator, wasm_bytes, mod_cfg); return self; } @@ -314,6 +325,12 @@ export fn zwasm_config_set_force_interpreter(config: *zwasm_config_t, force_inte 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 // ============================================================ @@ -322,7 +339,7 @@ export fn zwasm_config_set_force_interpreter(config: *zwasm_config_t, force_inte /// 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; }; @@ -332,7 +349,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; }; @@ -535,6 +552,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 // ============================================================ @@ -754,7 +779,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; }; @@ -868,7 +893,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; }; diff --git a/src/cli.zig b/src/cli.zig index c3c627a4..9ec818c6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -2121,6 +2121,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/types.zig b/src/types.zig index 41ff1510..d2bbe353 100644 --- a/src/types.zig +++ b/src/types.zig @@ -252,6 +252,10 @@ pub const WasmModule = struct { timeout_ms: ?u64 = null, max_memory_bytes: ?u64 = null, force_interpreter: ?bool = null, + /// Null keeps the Vm default (true — periodic cancellation checks enabled). + /// Set to `false` to skip the check for peak JIT throughput, at the cost + /// of making `WasmModule.cancel()` ineffective for JIT-compiled code. + cancellable: ?bool = null, }; /// Load a Wasm module from binary bytes with explicit configuration. @@ -370,6 +374,10 @@ 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. + /// + /// The resulting module uses the Vm defaults (including `cancellable = true`). + /// To opt out of periodic cancellation checks, set `result.module.vm.cancellable = false` + /// after this call returns. pub fn loadLinked(allocator: Allocator, wasm_bytes: []const u8, shared_store: *rt.store_mod.Store) !struct { module: *WasmModule, apply_error: ?anyerror } { const self = try allocator.create(WasmModule); @@ -470,9 +478,11 @@ pub const WasmModule = struct { self.force_interpreter = config.force_interpreter; self.timeout_ms = config.timeout_ms; self.fuel = config.fuel; + if (self.fuel) |f| self.vm.fuel = f; if (self.max_memory_bytes) |mb| self.vm.max_memory_bytes = mb; if (self.force_interpreter) |fi| self.vm.force_interpreter = fi; + if (config.cancellable) |c| self.vm.cancellable = c; if (self.timeout_ms) |ms| self.vm.setDeadlineTimeoutMs(ms); // Execute start function if present. @@ -544,6 +554,18 @@ pub const WasmModule = struct { 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. + /// Execution stops at the next checkpoint (~every 1024 instructions or at + /// the JIT fuel interval) and `invoke()` returns `error.Canceled`. + /// + /// Thread-safe. The cancel flag is cleared by `vm.reset()` at the start of + /// every `invoke()`, so requests issued while the module is idle are + /// dropped — the host must race the cancel against a live invocation. + 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 { 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 51be221c..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); @@ -93,6 +95,7 @@ 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); @@ -121,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; @@ -136,6 +140,7 @@ static struct { 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; @@ -146,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 { \ @@ -170,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"); @@ -185,6 +206,7 @@ static bool load_api(const char *path) { 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"); @@ -246,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"); @@ -562,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 */ /* ------------------------------------------------------------------ */ @@ -598,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);