From 332a9dc776c33c8eb90ef395ea68492e224e8ead Mon Sep 17 00:00:00 2001 From: Ola Yeku Date: Sat, 25 Jul 2026 14:37:48 -0500 Subject: [PATCH 1/2] Adopt wick v0.1.0 and its typed guest-call API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-pin from a loose commit to the v0.1.0 tag. The release brings a pre-decoded IR and threaded dispatch — plugin bodies are translated once at load and executed over preallocated stacks — plus a typed host/guest API that the loader now uses. Entry points are resolved through wick.guest.func against Zig function types declaring the plugin ABI, so an export with the wrong shape fails with SignatureMismatch before any guest code runs, instead of being invoked with the host's assumed arity and reading garbage locals. activate keeps accepting both the void and status-code forms. Scratch-region writes go through Instance.writeBytes rather than a hand-rolled slice + memcpy; the explicit size check stays, since writeBytes bounds-checks against linear memory and not the plugin's own buffer. Also factors the 21-line callback literal the loader tests repeated into a helper, and bumps to 0.15.0-dev. --- build.zig.zon | 6 +- docs/plugins.md | 18 ++- src/plugins/wasm/loader.zig | 307 +++++++++++++++++++++--------------- 3 files changed, 199 insertions(+), 132 deletions(-) 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/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); +} From ecb0fea0cefa8a71d9252b4cdf78a4e62bc18cac Mon Sep 17 00:00:00 2001 From: Ola Yeku Date: Sun, 26 Jul 2026 01:00:41 -0500 Subject: [PATCH 2/2] Chart 0.15.0 around verification you can trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.14.0 was themed repeatable operations and provable behavior, and shipped the repeatable half. Rather than roll the rest forward silently, record what slipped: deterministic simulation testing, the fuzz corpus expansion, the Unicode pass, and the terminal matrix. 0.15.0 takes its theme from what the release cleanup turned up — nine modules whose test suites had never run, and the drifted doubles and backwards assertions hiding behind them. A test suite that misreports coverage is the same instrument failure as a counter that cannot report a drop, which stem tells as a success story in its own architecture notes. Also correct the 0.13.0 section, which still listed OSC-52 and named registers as if they had shipped. --- docs/roadmap.md | 187 ++++++++++++++++++++++++++++-------------------- 1 file changed, 111 insertions(+), 76 deletions(-) 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. ---