diff --git a/build.zig.zon b/build.zig.zon index 3b234ea..044fc11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .stem, - .version = "0.14.0", + .version = "0.15.0-dev", .fingerprint = 0x20bf92e5730ad4ee, // Changing this has security and trust implications. .minimum_zig_version = "0.16.0", .dependencies = .{ @@ -13,8 +13,8 @@ .hash = "vigil-3.0.1-0NlCVBMVCgAWMqsHUKLkbiCTNUnqBKOBEdYSHBBIN04E", }, .wick = .{ - .url = "git+https://github.com/ooyeku/wick#e1863b8398414a8cbefbceb56e411f9920d17696", - .hash = "wick-0.1.0-USl9A27VAQBXZpHJGamgAdIvdALivqpsfO1xLsrFrA9F", + .url = "git+https://github.com/ooyeku/wick?ref=v0.1.0#9c68751449683b27b1d4bc03412544b131f3f629", + .hash = "wick-0.1.0-USl9A3ljAwBiAPYTBkfg8R4ahLS1Ouk0Bwiic57arg7a", }, .zls = .{ .url = "https://github.com/zigtools/zls/archive/refs/tags/0.16.0.tar.gz", diff --git a/docs/plugins.md b/docs/plugins.md index 66e8b7e..ba9f393 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -394,16 +394,22 @@ orchestrates both runtimes. Notable responsibilities: ### Wasm runtime (wick) The interpreter is [wick](https://github.com/ooyeku/wick) — the -pure-Zig wasm interpreter extracted from this repo, pinned in -[build.zig.zon](../build.zig.zon). Loader and lifecycle live in -[src/plugins/wasm/loader.zig](../src/plugins/wasm/loader.zig); the -API/behavior contract between the two projects is wick's -`docs/stem-contract.md`. +pure-Zig wasm interpreter extracted from this repo, pinned by release +tag in [build.zig.zon](../build.zig.zon). Loader and lifecycle live in +[src/plugins/wasm/loader.zig](../src/plugins/wasm/loader.zig). - Full wasm 1.0 coverage (i32/i64/f32/f64, funcref tables and `call_indirect`) plus the bulk-memory `memory.init` / `data.drop` opcodes (so plugins can ship passive data segments for static - strings). + strings). Function bodies are translated to a pre-decoded IR once at + load time and executed by a threaded-dispatch loop. +- **Entry points are signature-checked when they're resolved.** The + loader declares the ABI as Zig function types and asks wick to match + them against the module, so a plugin exporting `handle_command` with + the wrong shape fails with `SignatureMismatch` instead of being + called with the host's assumed arity and reading garbage locals. + `activate` may be either `() -> ()` or `() -> i32`; the rest of the + ABI is exact. - Every plugin call runs under an instruction budget (`CALL_FUEL_BUDGET`, 50M instructions, reset per call). A runaway call fails with `OutOfFuel` instead of hanging the editor; host diff --git a/docs/roadmap.md b/docs/roadmap.md index 3ff7cd7..d7ee205 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,8 +9,11 @@ with editors that have a twenty-year head start. The plan closes stem's known gaps against mature terminal editors (registers, system clipboard, macros, format robustness, plugin -authoring, battle-testing) across three releases, but each gap is -implemented *the stem way*: durable, supervised, and inspectable. +authoring, battle-testing), each implemented *the stem way*: durable, +supervised, and inspectable. Items have moved between releases and +say so where they did — a roadmap that quietly rewrites its own +history is the same kind of dishonest instrument as a counter that +can't report a drop. --- @@ -42,32 +45,16 @@ Slipped to 0.14.0, where it shipped — see that release below. ### System clipboard that works everywhere (OSC-52) -Terminal editors live over SSH, inside tmux, on machines without a -display server. Exec-based clipboard bridges (`pbcopy`, `xclip`) fail -exactly where a terminal editor is most needed. - -- OSC-52 copy integration with capability detection and graceful - fallback to the internal clipboard -- `pbcopy`/`wl-copy`/`xclip` bridge as a secondary path when available -- Clipboard state visible in the control center (which backend is - active, last sync result) — no silent "why didn't that copy?" - -*Positioning check: dependable in hostile environments, with the -failure mode observable instead of mysterious.* +Did not ship. Tracked as +[#1](https://github.com/ooyeku/stem/issues/1) and rescheduled — see +0.15.0's deferral note, which batches it with the terminal +compatibility matrix since both need capability detection. ### Named registers with durable storage -Stem has a single unnamed clipboard. Mature modal editors have named -registers; stem's version makes them crash-safe. - -- Vim-style named registers (`"a`–`"z`, append with `"A`–`"Z`) plus a - numbered yank ring -- Registers persist per project through the checkpoint pipeline — - yanked text survives a crash and a restart, which no incumbent offers -- Register picker in the command palette (inspect before you paste) - -*Positioning check: aligned — registers become durable editor state -under the same recovery guarantees as sessions.* +Did not ship. Tracked as +[#3](https://github.com/ooyeku/stem/issues/3) and picked up in 0.15.0 +below, where it also makes 0.14.0's macros durable. --- @@ -126,74 +113,122 @@ the message bus, which makes them better than a keystroke tape: *Positioning check: aligned — "all-or-nothing replay" is a reliability claim no incumbent macro system makes.* -### Deterministic simulation testing - -Vigil ships a deterministic toolkit (`SimulatedClock`, -`SimulatedTimerService`, `FaultInjector`); stem uses only a corner of -it. This release makes time-dependent behavior provable: - -- Debounce, backoff, breaker, and watchdog logic tested against the - simulated clock — no sleeps, no flakes -- Fault-injection tests for the LSP lifecycle: scripted crash storms - must open breakers, recover on schedule, and never lose a queued - `didOpen` +### Did not ship: the "provable behavior" half -### Battle-testing, phase one +0.14.0 was themed *repeatable operations and provable behavior*. It +shipped the repeatable half. Recorded here rather than quietly rolled +forward, because the gap is the reason 0.15.0 looks the way it does: -- Expand the fuzz corpus beyond piece-table/state/URIs to the session - format, LSP framing, and plugin manifests -- Unicode robustness pass over cursor motion, rendering width, and - text objects (grapheme clusters, combining marks, East Asian width) -- Begin a terminal compatibility matrix (kitty, alacritty, wezterm, - tmux, iTerm2, Terminal.app, Linux console) with documented results +- **Deterministic simulation testing.** Vigil ships a deterministic + toolkit (`SimulatedClock`, `SimulatedTimerService`, `FaultInjector`) + and stem still uses one corner of it — a single test exercising + vigil's own timer service. Stem's debounce, backoff, breaker, and + watchdog logic remains untested against a simulated clock. +- **Fuzz corpus expansion.** A wasm-loader target landed; the session + format, LSP framing, and plugin manifests did not. +- **Unicode robustness pass** and the **terminal compatibility + matrix** ([#5](https://github.com/ooyeku/stem/issues/5)) — not + started. -*Positioning check: aligned — a reliability claim obligates proof, -not vibes.* +All four carry into 0.15.0. --- ## 0.15.0 — Proven Under Fire -Theme: hardening completed, and the plugin host becomes the most -dependable extension surface in the terminal. +Theme: verification you can trust. Not the runtime's honesty about +itself — that shipped — but stem's honesty about *stem*. + +The motivating discovery came during 0.14.0's release cleanup. Zig only +runs tests from files reachable from a test root, so a module can carry +a full suite that never executes and nothing complains. Nine modules +were in that state, `split_manager` — window splits — among them. Wiring +them back in didn't just surface failures; it surfaced test doubles that +had drifted out of sync with the interfaces they stand in for, +assertions that asserted the opposite of real behavior, and a +leak-checking helper that could only pass for code that allocated +nothing. + +That is the same failure as the dishonest drop counter in the +[architecture notes](architecture.md) — a number that couldn't report +the thing it claimed to measure. Stem tells that story as a success. +This release applies the lesson to stem's own verification. + +### Trustworthy test wiring + +- A build step that walks `src/`, finds every file containing a `test` + block, and fails the build when one isn't reachable from a test root. + Cheap, mechanical, and it permanently closes the hole above. +- Audit the surviving test doubles against the interfaces they double; + the drift found so far was caught by accident, not by design. -### Plugin host v1: reliability as the ecosystem strategy +*Positioning check: aligned — "N tests pass" has to be a claim, not a +number.* -Stem cannot out-plugin Neovim by volume. It can be the host where a -plugin crash is a contained, observable, recoverable event — and where -authoring is low-friction: +### Deterministic simulation testing (carried from 0.14.0) -- **API stability contract**: manifest and SDK surface frozen for the - 1.x line; breaking changes gated behind manifest versions -- `stem plugin new` scaffolding (wasm and exec templates, SDK wired) -- Supervised restart policies exposed per plugin (max restarts, - backoff, disable-on-poison) with breaker state in the dashboard -- A curated plugin index — small, but every entry vetted to run under - supervision without dead-lettering +- Debounce, backoff, breaker, and watchdog logic tested against + `SimulatedClock` — no sleeps, no flakes +- Fault-injection tests for the LSP lifecycle: scripted crash storms + must open breakers, recover on schedule, and never lose a queued + `didOpen` -*Positioning check: scrutinized hard. "Grow an ecosystem" chases the -incumbents on their terms and was cut; "the host that never lets a -plugin take the editor down" is the differentiated version of the same -gap.* +This is foundational for the next item: chaos runs can't gate merges +while the tests underneath them are timing-dependent. -### Battle-testing, phase two +### Chaos CI as a merge gate -- Chaos CI: fault-injection runs (killed LSPs, wedged plugins, full - queues, clock jumps) as a merge gate, built on the 0.14 simulation - harness -- Terminal compatibility matrix completed and published in the README -- Soak testing: multi-hour editing sessions under memory-leak and - file-descriptor tracking, with the runtime cockpit's own metrics as - the oracle +- Fault-injection runs — killed LSPs, wedged plugins, full queues, + clock jumps — built on the simulation harness above +- Fuzz corpus extended to the session format (now `std.json`, so the + round-trip is property-testable), LSP framing, and plugin manifests +- Soak testing: multi-hour sessions under memory and file-descriptor + tracking, with the runtime cockpit's own metrics as the oracle -### Cluster follow-through (stretch) +### Named registers with a yank ring -`STEM_CLUSTER` presence shipped in 0.13. If the foundation proves -stable, the first user-visible payoff: shared registers/clipboard -across local stem instances via the distributed registry. +The largest remaining modal-editing gap +([#3](https://github.com/ooyeku/stem/issues/3)), and it completes what +0.14.0 started: macros currently live in per-session registers, so +durable registers are what make "record a macro, crash, replay it" +true. -*Positioning check: aligned, and gated — ships only if presence -telemetry from 0.13–0.14 shows the transport is dependable.* +- Vim-style named registers (`"a`–`"z`, append with `"A`–`"Z`) plus a + numbered yank ring +- Persisted per project through the checkpoint pipeline, under the same + recovery guarantees as sessions +- Register picker in the command palette — inspect before you paste + +*Positioning check: aligned — durable editor state under the recovery +guarantees the rest of stem already makes.* + +### Deferred out of this release + +Judgment calls worth stating, not silent omissions: + +- **Plugin host v1 / API stability contract.** Freezing the manifest + and SDK surface for the 1.x line is the right destination, but the + plugin ABI changed shape in 0.14.0 (entry points are now + signature-checked at resolve time). Freeze after chaos CI has + stressed the host, not before. +- **A curated plugin index.** Needs an ecosystem that doesn't exist + yet. +- **OSC-52 clipboard** ([#1](https://github.com/ooyeku/stem/issues/1)) + and the **terminal compatibility matrix** + ([#5](https://github.com/ooyeku/stem/issues/5)). Both need terminal + capability detection; batching them into one cycle avoids building + that twice. Ship them together, here if there's room and in 0.16.0 + otherwise. +- **Cluster follow-through.** `STEM_CLUSTER` presence shipped in + 0.13.0 and the payoff — shared registers across local instances — + stays gated on telemetry showing the transport is dependable. No + such evidence yet. + +### If the schedule tightens + +Cut the registers and ship a purely-hardening release. For a project +that sells reliability, the wiring guard plus chaos CI is a defensible +0.15.0 on its own. --- diff --git a/src/plugins/wasm/loader.zig b/src/plugins/wasm/loader.zig index 4c7ff9a..f9809b6 100644 --- a/src/plugins/wasm/loader.zig +++ b/src/plugins/wasm/loader.zig @@ -98,6 +98,21 @@ pub const Callbacks = struct { pub const State = enum { loaded, activated, deactivated, failed }; +// The plugin ABI, as Zig function types. Resolving an export through +// `wick.guest.func` checks the wasm signature against these at resolve +// time, so a plugin whose export has the wrong shape fails with +// `error.SignatureMismatch` before any guest code runs — rather than +// being invoked with the wrong arity and reading garbage locals. +const ActivateFn = fn () void; +/// Older plugins return a status code from `activate` instead of void. +const ActivateStatusFn = fn () u32; +const CommandFn = fn (u32, u32) void; +const EventFn = fn (u32, u32, u32, u32) void; +const ScratchFn = fn () u32; + +/// Scratch size assumed when a plugin doesn't export `__stem_scratch_size`. +const default_scratch_size: u32 = 4 * 1024; + /// Options for `stem_spawn_capture` — a forward-compatible shape so we /// can extend the wasm host import without churning every call site. pub const SpawnOpts = struct { @@ -149,18 +164,30 @@ pub const WasmPlugin = struct { /// re-entrant; the host serializes calls. invoke_mu: Mutex = .{}, - /// Run a plugin entry point through the interpreter, updating - /// `stats` on the way out. Callers hold `invoke_mu`. - fn invokeTracked(self: *WasmPlugin, idx: u32, args: []const u64, results: []u64) interp.Error!u32 { + /// Call a resolved plugin entry point, updating `stats` on the way + /// out. Callers hold `invoke_mu`. + fn trackedCall( + self: *WasmPlugin, + comptime Sig: type, + f: interp.Func(Sig), + args: interp.Func(Sig).Args, + ) interp.Error!interp.Func(Sig).Result { self.stats.calls += 1; - const rc = interp.invoke(&self.instance, idx, args, results) catch |err| { + const result = f.call(args) catch |err| { self.stats.traps += 1; self.stats.last_error = @errorName(err); self.recordFuelUsed(); return err; }; self.recordFuelUsed(); - return rc; + return result; + } + + fn onTrap(self: *WasmPlugin, entry_point: []const u8, err: anyerror) void { + // warn, not err: a trapped plugin call is a *contained* failure + // (recorded in `stats`, surfaced to the manager); err-level is + // reserved for host integrity. + log.warn("plugin '{s}' {s} trapped: {s}", .{ self.plugin_id, entry_point, @errorName(err) }); } fn recordFuelUsed(self: *WasmPlugin) void { @@ -175,10 +202,9 @@ pub const WasmPlugin = struct { // Best-effort: call `deactivate` if it exists and we haven't // already failed. if (self.state == .activated) { - if (self.module.findExport("deactivate", .func)) |idx| { - var results: [4]u64 = undefined; - _ = self.invokeTracked(idx, &.{}, results[0..0]) catch {}; - } + if (interp.guest.func(&self.instance, "deactivate", ActivateFn)) |f| { + self.trackedCall(ActivateFn, f, .{}) catch {}; + } else |_| {} } self.instance.deinit(); self.module.deinit(); @@ -187,26 +213,32 @@ pub const WasmPlugin = struct { /// Invoke the plugin's exported `activate` function. pub fn activate(self: *WasmPlugin) !void { - const idx = self.module.findExport("activate", .func) orelse { - log.warn("plugin '{s}' missing 'activate' export", .{self.plugin_id}); - self.state = .failed; - return error.MissingExport; - }; self.invoke_mu.lock(); defer self.invoke_mu.unlock(); - var results: [4]u64 = undefined; - const ft = self.instance.funcType(idx) orelse return error.InvalidModule; - const rc = self.invokeTracked(idx, &.{}, results[0..ft.results.len]) catch |err| { - // warn, not err: a trapped plugin call is a *contained* - // failure (state -> .failed, error surfaced to the - // manager); err-level is reserved for host integrity. - log.warn("plugin '{s}' activate trapped: {s}", .{ self.plugin_id, @errorName(err) }); - self.state = .failed; - return err; - }; - _ = rc; - if (ft.results.len > 0 and results[0] != 0) { - log.warn("plugin '{s}' activate returned {d}", .{ self.plugin_id, results[0] }); + + var status: u32 = 0; + if (interp.guest.func(&self.instance, "activate", ActivateFn)) |f| { + self.trackedCall(ActivateFn, f, .{}) catch |err| { + self.onTrap("activate", err); + self.state = .failed; + return err; + }; + } else |_| { + // Not the void shape — accept the status-code form too. + const f = interp.guest.func(&self.instance, "activate", ActivateStatusFn) catch |err| { + log.warn("plugin '{s}' has no usable 'activate' export ({s})", .{ self.plugin_id, @errorName(err) }); + self.state = .failed; + return error.MissingExport; + }; + status = self.trackedCall(ActivateStatusFn, f, .{}) catch |err| { + self.onTrap("activate", err); + self.state = .failed; + return err; + }; + } + + if (status != 0) { + log.warn("plugin '{s}' activate returned {d}", .{ self.plugin_id, status }); } self.state = .activated; } @@ -215,22 +247,23 @@ pub const WasmPlugin = struct { /// Copies the command id into the plugin's `__stem_scratch` /// region. pub fn dispatchCommand(self: *WasmPlugin, command_id: []const u8) !void { - const idx = self.module.findExport("handle_command", .func) orelse return error.MissingExport; self.invoke_mu.lock(); defer self.invoke_mu.unlock(); - const scratch = try self.scratchSlice(); - if (command_id.len > scratch.len) return error.ScratchTooSmall; - @memcpy(scratch[0..command_id.len], command_id); - const scratch_ptr = self.scratchPtr() catch unreachable; - - var results: [4]u64 = undefined; - _ = self.invokeTracked( - idx, - &.{ @as(u64, scratch_ptr), @as(u64, command_id.len) }, - results[0..0], - ) catch |err| { - log.warn("plugin '{s}' handle_command trapped: {s}", .{ self.plugin_id, @errorName(err) }); + const f = interp.guest.func(&self.instance, "handle_command", CommandFn) catch |err| switch (err) { + error.ExportNotFound => return error.MissingExport, + else => return err, + }; + + // `writeBytes` bounds-checks against linear memory; the scratch + // size check is what keeps the write inside the plugin's own + // buffer rather than somewhere else in its heap. + const scratch_ptr = try self.scratchPtr(); + if (command_id.len > self.scratchSize()) return error.ScratchTooSmall; + try self.instance.writeBytes(scratch_ptr, command_id); + + self.trackedCall(CommandFn, f, .{ scratch_ptr, @intCast(command_id.len) }) catch |err| { + self.onTrap("handle_command", err); return err; }; } @@ -239,31 +272,30 @@ pub const WasmPlugin = struct { /// data_ptr, data_len)` export. Silently no-ops if the plugin /// doesn't export the function (most plugins ignore events). pub fn dispatchEvent(self: *WasmPlugin, topic: []const u8, data: []const u8) !void { - const idx = self.module.findExport("handle_event", .func) orelse return; self.invoke_mu.lock(); defer self.invoke_mu.unlock(); - const scratch = try self.scratchSlice(); + // Most plugins ignore events, so a missing export is a no-op — + // but one that exists with the wrong shape is now a reported + // error instead of a call with mismatched arity. + const f = interp.guest.func(&self.instance, "handle_event", EventFn) catch |err| switch (err) { + error.ExportNotFound => return, + else => return err, + }; + const total = topic.len + data.len; - if (total > scratch.len) return error.ScratchTooSmall; - @memcpy(scratch[0..topic.len], topic); - @memcpy(scratch[topic.len..total], data); - const scratch_ptr = self.scratchPtr() catch unreachable; - const topic_ptr = scratch_ptr; - const data_ptr = scratch_ptr + @as(u32, @intCast(topic.len)); - - var results: [4]u64 = undefined; - _ = self.invokeTracked( - idx, - &.{ - @as(u64, topic_ptr), - @as(u64, @intCast(topic.len)), - @as(u64, data_ptr), - @as(u64, @intCast(data.len)), - }, - results[0..0], - ) catch |err| { - log.warn("plugin '{s}' handle_event trapped: {s}", .{ self.plugin_id, @errorName(err) }); + const scratch_ptr = try self.scratchPtr(); + if (total > self.scratchSize()) return error.ScratchTooSmall; + try self.instance.writeBytes(scratch_ptr, topic); + try self.instance.writeBytes(scratch_ptr + @as(u32, @intCast(topic.len)), data); + + self.trackedCall(EventFn, f, .{ + scratch_ptr, + @intCast(topic.len), + scratch_ptr + @as(u32, @intCast(topic.len)), + @intCast(data.len), + }) catch |err| { + self.onTrap("handle_event", err); return err; }; } @@ -275,28 +307,19 @@ pub const WasmPlugin = struct { /// the buffer's length; if absent we default to 4 KiB. Linker- /// assigned global addresses can't be propagated as `const` /// values at Zig comptime, so we use a function call instead. + /// Host-internal, so deliberately not counted in `stats`: those + /// counters track plugin *logic* calls. fn scratchPtr(self: *WasmPlugin) !u32 { - const idx = self.module.findExport("__stem_scratch_addr", .func) orelse { - log.warn("plugin '{s}' missing __stem_scratch_addr export", .{self.plugin_id}); + const f = interp.guest.func(&self.instance, "__stem_scratch_addr", ScratchFn) catch |err| { + log.warn("plugin '{s}' has no usable __stem_scratch_addr export ({s})", .{ self.plugin_id, @errorName(err) }); return error.MissingScratch; }; - var results: [1]u64 = undefined; - _ = try interp.invoke(&self.instance, idx, &.{}, &results); - return @truncate(results[0]); + return f.call(.{}); } fn scratchSize(self: *WasmPlugin) u32 { - const idx = self.module.findExport("__stem_scratch_size", .func) orelse return 4 * 1024; - var results: [1]u64 = undefined; - _ = interp.invoke(&self.instance, idx, &.{}, &results) catch return 4 * 1024; - return @truncate(results[0]); - } - - fn scratchSlice(self: *WasmPlugin) ![]u8 { - const ptr = try self.scratchPtr(); - const len = self.scratchSize(); - if (@as(u64, ptr) + len > self.instance.memory.len) return error.OutOfBounds; - return self.instance.memory[ptr .. ptr + len]; + const f = interp.guest.func(&self.instance, "__stem_scratch_size", ScratchFn) catch return default_scratch_size; + return f.call(.{}) catch default_scratch_size; } }; @@ -743,6 +766,32 @@ const TestState = struct { } }; +fn testCallbacks(ts: *TestState) Callbacks { + return .{ + .user_data = @ptrCast(ts), + .on_log = TestState.onLog, + .on_register_command = TestState.onReg, + .on_show_notification = TestState.onNote, + .on_open_buffer = TestState.onOpenBuf, + .on_spawn_capture = TestState.onSpawn, + .on_subscribe_event = TestState.onSubEv, + .on_read_file = TestState.onReadFile, + .on_write_file = TestState.onWriteFile, + .on_set_status_item = TestState.onSetSI, + .on_clear_status_item = TestState.onClearSI, + .on_set_panel = TestState.onSetPanel, + .on_clear_panel = TestState.onClearPanel, + .on_get_buffer_content = TestState.onGetBufContent, + .on_get_buffer_path = TestState.onGetBufPath, + .on_get_plugin_dashboard_json = TestState.onGetPluginDashboardJson, + .on_get_plugin_dashboard_report = TestState.onGetPluginDashboardReport, + .on_storage_read = TestState.onStorageRead, + .on_storage_write = TestState.onStorageWrite, + .on_load_plugin = TestState.onLoadPlugin, + .on_unload_plugin = TestState.onUnloadPlugin, + }; +} + test "decode: integrate through loader path" { // Just exercise the interpreter through the loader's `decode` call // path — actual file I/O is covered by the integration test below. @@ -768,29 +817,7 @@ test "load + activate + dispatchCommand against the built echo.wasm" { var ts: TestState = .{ .allocator = a }; defer ts.deinit(); - const cbs: Callbacks = .{ - .user_data = @ptrCast(&ts), - .on_log = TestState.onLog, - .on_register_command = TestState.onReg, - .on_show_notification = TestState.onNote, - .on_open_buffer = TestState.onOpenBuf, - .on_spawn_capture = TestState.onSpawn, - .on_subscribe_event = TestState.onSubEv, - .on_read_file = TestState.onReadFile, - .on_write_file = TestState.onWriteFile, - .on_set_status_item = TestState.onSetSI, - .on_clear_status_item = TestState.onClearSI, - .on_set_panel = TestState.onSetPanel, - .on_clear_panel = TestState.onClearPanel, - .on_get_buffer_content = TestState.onGetBufContent, - .on_get_buffer_path = TestState.onGetBufPath, - .on_get_plugin_dashboard_json = TestState.onGetPluginDashboardJson, - .on_get_plugin_dashboard_report = TestState.onGetPluginDashboardReport, - .on_storage_read = TestState.onStorageRead, - .on_storage_write = TestState.onStorageWrite, - .on_load_plugin = TestState.onLoadPlugin, - .on_unload_plugin = TestState.onUnloadPlugin, - }; + const cbs = testCallbacks(&ts); const wp = try load(a, io, "echo", abs_path, cbs); defer { @@ -846,29 +873,7 @@ test "runaway plugin call is contained by the fuel budget" { var ts: TestState = .{ .allocator = a }; defer ts.deinit(); - const wp = try load(a, io, "runaway", abs_path, .{ - .user_data = @ptrCast(&ts), - .on_log = TestState.onLog, - .on_register_command = TestState.onReg, - .on_show_notification = TestState.onNote, - .on_open_buffer = TestState.onOpenBuf, - .on_spawn_capture = TestState.onSpawn, - .on_subscribe_event = TestState.onSubEv, - .on_read_file = TestState.onReadFile, - .on_write_file = TestState.onWriteFile, - .on_set_status_item = TestState.onSetSI, - .on_clear_status_item = TestState.onClearSI, - .on_set_panel = TestState.onSetPanel, - .on_clear_panel = TestState.onClearPanel, - .on_get_buffer_content = TestState.onGetBufContent, - .on_get_buffer_path = TestState.onGetBufPath, - .on_get_plugin_dashboard_json = TestState.onGetPluginDashboardJson, - .on_get_plugin_dashboard_report = TestState.onGetPluginDashboardReport, - .on_storage_read = TestState.onStorageRead, - .on_storage_write = TestState.onStorageWrite, - .on_load_plugin = TestState.onLoadPlugin, - .on_unload_plugin = TestState.onUnloadPlugin, - }); + const wp = try load(a, io, "runaway", abs_path, testCallbacks(&ts)); defer { wp.deinit(); a.destroy(wp); @@ -894,3 +899,59 @@ test "runaway plugin call is contained by the fuel budget" { try std.testing.expectEqualStrings("OutOfFuel", wp.stats.last_error.?); try std.testing.expectEqual(@as(u64, 100_000), wp.stats.last_fuel_used); } + +// A plugin whose entry point has the wrong wasm signature is caught at +// resolve time. Before typed guest calls the host looked the export up +// by name only, then invoked it with the host's assumed arity — the +// guest ran with garbage locals and no one found out. +test "an entry point with the wrong signature is rejected, not miscalled" { + const a = std.testing.allocator; + var threaded = std.Io.Threaded.init(a, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const wrong_shape = [_]u8{ + 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, + // types: 0: () -> (); 1: () -> i32 + 0x01, 0x08, 0x02, 0x60, 0x00, 0x00, 0x60, 0x00, + 0x01, 0x7F, + // funcs: activate=type0, handle_command=type0 (WRONG: ABI is (i32,i32)->()), scratch=type1 + 0x03, 0x04, 0x03, 0x00, 0x00, 0x01, + // memory: 1 page + 0x05, 0x03, 0x01, 0x00, 0x01, + // exports (payload: 1 count + 11 + 17 + 22 = 51 bytes) + 0x07, 0x33, 0x03, + 0x08, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x00, 0x00, 0x0E, 0x68, 0x61, 0x6E, 0x64, + 0x6C, 0x65, 0x5F, 0x63, 0x6F, 0x6D, 0x6D, 0x61, + 0x6E, 0x64, 0x00, 0x01, 0x13, 0x5F, 0x5F, 0x73, + 0x74, 0x65, 0x6D, 0x5F, 0x73, 0x63, 0x72, 0x61, + 0x74, 0x63, 0x68, 0x5F, 0x61, 0x64, 0x64, 0x72, + 0x00, 0x02, + // code: two empty bodies, then `i32.const 16` + 0x0A, 0x0C, 0x03, 0x02, 0x00, 0x0B, + 0x02, 0x00, 0x0B, 0x04, 0x00, 0x41, 0x10, 0x0B, + }; + + var tmp = try @import("../../test_utils.zig").Tempdir.init(a, io); + defer tmp.deinit(); + try tmp.writeFile("wrong.wasm", &wrong_shape); + const abs_path = try tmp.joinPath(a, "wrong.wasm"); + defer a.free(abs_path); + + var ts: TestState = .{ .allocator = a }; + defer ts.deinit(); + const wp = try load(a, io, "wrong", abs_path, testCallbacks(&ts)); + defer { + wp.deinit(); + a.destroy(wp); + } + + // `activate` matches the ABI, so the plugin still loads and runs. + try wp.activate(); + try std.testing.expectEqual(State.activated, wp.state); + + // `handle_command` does not, and says so instead of running. + try std.testing.expectError(error.SignatureMismatch, wp.dispatchCommand("x")); + try std.testing.expectEqual(@as(u64, 0), wp.stats.traps); +}