From 17347eebd2a7b0e73dc5856527d60bb60ee69f81 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sat, 10 Oct 2026 15:13:30 -0500 Subject: [PATCH] sdk: a plugin's panic message reaches its crash report A plugin dylib panicked through its own std: the message went to a stderr no report read, then abort() (on Windows RtlExitUserProcess, which no handler sees). The generated dylib root now declares `panic = sdk.dylib.panic`, which hands the message and first trace address to the host through the new EditorAPI.panic; fizzy raises it again as its own panic, so the crash report (or the web console) gets the message and frames from the panic site. Fingerprint recorded. Part of plans/RESTART_AND_CRASHES_PLAN.md, step 3. Co-Authored-By: Claude Opus 5.5 --- docs/PLUGINS.md | 5 +++++ plugins/shared/build/helpers.zig | 1 + sdk/plugin_sdk.zig | 1 + sdk/src/EditorAPI.zig | 10 ++++++++++ sdk/src/dylib.zig | 12 ++++++++++++ sdk/src/runtime.zig | 10 ++++++++-- sdk/src/version.zig | 2 +- src/editor/Editor.zig | 7 +++++++ 8 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 7a8b4a4bc..226158d71 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1720,6 +1720,11 @@ drop straight into the plugins directory, exactly like §2.6. | `fizzy_plugin_set_dvui_context` | Host injects live dvui window/io before draw | | `fizzy_plugin_set_render_bridge` | Host injects the dvui proxy render bridge | +The generated root also declares the dylib's `std_options` (its logging reaches fizzy's Output +panel, `sdk.dylib.stdOptions`) and its `panic` (`sdk.dylib.panic`): a panic in plugin code goes +to fizzy's own handler through `EditorAPI.panic`, so fizzy's crash report names the plugin and +carries the message. + ### Plugin dylib layout ``` diff --git a/plugins/shared/build/helpers.zig b/plugins/shared/build/helpers.zig index 639004e01..26bf02f59 100644 --- a/plugins/shared/build/helpers.zig +++ b/plugins/shared/build/helpers.zig @@ -206,6 +206,7 @@ fn generatedDylibRoot( \\const plugin = @import("plugin_impl"); \\const opts = plugin.plugin_options; \\pub const std_options: std.Options = sdk.dylib.stdOptions(opts.id); + \\pub const panic = sdk.dylib.panic; \\comptime { \\ sdk.dylib.exportEntry(plugin, .{ \\ .id = opts.id, diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index 68403418d..12af52b08 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -500,6 +500,7 @@ fn generatedDylibRoot( \\const plugin = @import("plugin_impl"); \\const opts = plugin.plugin_options; \\pub const std_options: std.Options = sdk.dylib.stdOptions(opts.id); + \\pub const panic = sdk.dylib.panic; \\comptime { \\ sdk.dylib.exportEntry(plugin, .{ \\ .id = opts.id, diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index bbf4e20cf..3d76d08f5 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -382,6 +382,12 @@ pub const VTable = struct { /// process's stderr). logLine: *const fn (ctx: *anyopaque, level: std.log.Level, scope: []const u8, message: []const u8) void, + /// A plugin dylib's panic, handed to the app's own panic handler (`sdk.dylib.panic`). A + /// dylib compiles its own std, whose handler would print the message to a stderr nobody reads + /// and abort; through here the message reaches the app's crash report, and on the web the + /// console. `first_trace_addr` is where the stack trace starts, in the plugin's frames. + panic: *const fn (ctx: *anyopaque, msg: []const u8, first_trace_addr: ?usize) noreturn, + /// Draws a standard menu-item row (separator above, label + keybind hint, click-detect) /// inside whatever menu is currently open, and returns whether it was clicked this frame. /// `command_id` is the registered `Command` this row runs (e.g. `"text.format"`), or null @@ -754,6 +760,10 @@ pub fn logLine(self: EditorAPI, level: std.log.Level, scope: []const u8, message self.vtable.logLine(self.ctx, level, scope, message); } +pub fn panic(self: EditorAPI, msg: []const u8, first_trace_addr: ?usize) noreturn { + self.vtable.panic(self.ctx, msg, first_trace_addr); +} + pub fn drawMenuItem(self: EditorAPI, title: []const u8, command_id: ?[]const u8) bool { return self.vtable.drawMenuItem(self.ctx, title, command_id); } diff --git a/sdk/src/dylib.zig b/sdk/src/dylib.zig index ac280d3eb..926e02b3c 100644 --- a/sdk/src/dylib.zig +++ b/sdk/src/dylib.zig @@ -356,6 +356,18 @@ pub fn stdOptions(comptime id: []const u8) std.Options { return .{ .logFn = Impl.logFn }; } +/// The dylib's panic handler, which the generated root declares (`pub const panic = +/// sdk.dylib.panic`): the panic goes to the app's (`EditorAPI.panic`), so a crash report carries +/// the message and the plugin's frames. Before the host is installed (nothing of the plugin's has +/// run yet), std's own. +pub const panic = std.debug.FullPanic(forwardPanic); + +fn forwardPanic(msg: []const u8, first_trace_addr: ?usize) noreturn { + @branchHint(.cold); + if (runtime.installedHost()) |h| if (h.fizzy_api) |api| api.panic(msg, first_trace_addr orelse @returnAddress()); + std.debug.defaultPanic(msg, first_trace_addr); +} + /// Identity passed to `exportEntry` — built-injected from `plugin.zig.zon` (see `readManifest` /// in `plugin_sdk.zig` / `helpers.zig`), never author-supplied `Zig` source. `version`/ /// `min_sdk_version` are typed `SemanticVersion` (not strings) because the generated dylib root diff --git a/sdk/src/runtime.zig b/sdk/src/runtime.zig index 9c62ddc71..d3c83d25a 100644 --- a/sdk/src/runtime.zig +++ b/sdk/src/runtime.zig @@ -12,7 +12,7 @@ const dvui = @import("dvui"); const Host = @import("Host.zig"); var gpa: std.mem.Allocator = undefined; -var host_ptr: *Host = undefined; +var host_ptr: ?*Host = null; /// Fizzy-owned plugin state injected before `register` (built-in static/dylib path). var injected_state: ?*anyopaque = null; @@ -24,6 +24,12 @@ pub fn allocator() std.mem.Allocator { /// Fizzy `*Host` — registries, services, and the `EditorAPI` read surface. pub fn host() *Host { + return host_ptr.?; +} + +/// The `*Host`, or null before fizzy has installed it: for code that may run before +/// `register`, such as the panic handler. +pub fn installedHost() ?*Host { return host_ptr; } @@ -52,7 +58,7 @@ pub fn installRuntime( } fn hostFrostPane(id: dvui.Id, rect: dvui.Rect.Physical, corners: dvui.CornerRect, scale: f32) bool { - const api = host_ptr.fizzy_api orelse return false; + const api = host_ptr.?.fizzy_api orelse return false; return api.frostPane(id, rect, corners, scale); } diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 676a9aa54..7bab6ec58 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x1bf417f650c67637; +pub const recorded_sdk_shape_fingerprint: u64 = 0x65096e9408ddb32f; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 1426aefef..fbac5cffe 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2312,6 +2312,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .resumeSaveAllQuit = fizzyResumeSaveAllQuit, .abortSaveAllQuit = fizzyAbortSaveAllQuit, .logLine = fizzyLogLine, + .panic = fizzyPanic, .drawMenuItem = fizzyDrawMenuItem, .askCommandArguments = fizzyAskCommandArguments, .loadPluginSettingsFile = fizzyLoadPluginSettingsFile, @@ -2329,6 +2330,12 @@ fn fizzyLogLine(ctx: *anyopaque, level: std.log.Level, scope: []const u8, messag fizzy.OutputLog.appendLine(level, scope, message); } +/// A plugin's panic, raised again as the app's own: the root's handler writes the crash report +/// (`app.crash`), or on the web logs it to the console. +fn fizzyPanic(_: *anyopaque, msg: []const u8, first_trace_addr: ?usize) noreturn { + std.debug.panicExtra(first_trace_addr, "{s}", .{msg}); +} + /// See `EditorAPI.VTable.drawMenuItem`'s doc comment for why this widget construction has to /// happen here (in fizzy) rather than in the calling plugin. ///