Skip to content

app: a crash report says whose code crashed - #351

Merged
foxnne merged 1 commit into
mainfrom
app/crash-reports
Oct 10, 2026
Merged

foxnne merged 1 commit into
mainfrom
app/crash-reports

Conversation

@foxnne

@foxnne foxnne commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Part of plans/RESTART_AND_CRASHES_PLAN.md, step 3 (crash reports), first PR.

What changes

When fizzy crashes, it says whose code crashed. The report names fizzy itself or the plugin, with versions, every loaded module and each stack frame as a module plus an offset. It goes to stderr and to <config>/crashes/<launch ms>-<pid>.txt. A plugin crash in a ReleaseFast build, from a sandbox run:

fizzy crashed in crashy 0.3.1: Segmentation fault at address 0x10

fizzy 0.2.0, sdk 0.2.22 (1bf417f650c67637), macos aarch64 ReleaseFast

modules:
  0 fizzy 0.2.0 0x1028ec000 0xe353cc bc2c449a69303c4fa630cc912ef818f2
  1 workbench 0.1.1 0x1204dc000 0x27e41c 37365a5baeef32629cd6ff1518dde68e
  …
  5 crashy 0.3.1 0x125898000 0x107798 f1055092556d30e1aed6ec438d739214
frames:
  crashy+0x531bc
  crashy+0x1d12b
  fizzy+0x5a6b9f
  …
  0x180f3bdff dyld

Before this, a release build had no fault handler at all: a crash printed nothing.

What reaches it (app/crash/crash.zig):

  • A panic in fizzy: the root's panic handler is now FullPanic(crash.panic).
  • A fault anywhere in the process: std's segfault handler, switched on in every build mode (enable_segfault_handler), routed through root.debug.handleSegfault. Covers SIGSEGV/BUS/ILL/FPE and Windows access violations.
  • An abort or trap anywhere (POSIX): a SIGABRT/SIGTRAP handler. A plugin's panic lands here: the dylib has its own std, which prints the message and calls abort().

After the report, each one hands on to what ran before: std's handler, which still prints the symbolized trace (the executable isn't stripped), or the signal's default action. Only one report is written per run, so fizzy's own panic doesn't also report the abort it ends in.

The module table: a fixed table of 64 slots.

  • The executable (and the plugins linked into it) is entered first thing in main.
  • Each plugin dylib is entered in PluginLoader.register before its register runs, and removed in unloadPlugin before dlclose.
  • Each entry's range and build id are read from the mapped headers at load time (app/crash/image.zig): the Mach-O LC_UUID, the ELF NT_GNU_BUILD_ID note, or the PE CodeView GUID+age. A crash only compares addresses.
  • An address in no known module (system libraries) is named with dladdr.

Build ids on ELF: Zig's linker writes a build id only when asked. The executable and plugins built for ELF now set build_id = .fast (build/exe.zig, sdk/plugin_sdk.zig).

Writing allocates nothing: the file is named once the config folder is known (crash.writeTo, in Editor.init), and a crash writes it with open/write (CreateFileW/WriteFile on Windows) from a static buffer. A crash before that point reports to stderr only. A run that doesn't crash leaves no file.

Cost: none per frame. The handlers are installed once, and each plugin load does one table write. std already attaches the per-thread alternate signal stack whenever signal_stack_size is set, which is the default, so turning the handler on in release builds adds nothing there.

crash is a std-only module of its own (build/sdk.zig's crashModule), so the plugin-loader test, which is rooted at PluginLoader.zig, can reach it. The app re-exports it as app.crash.

SDK impact

  • None
  • Core-only or additive: reaches plugins at the next SDK release. plugin_sdk.zig gives ELF plugins a build id; no fingerprint move (test-sdk-version passes).
  • Fingerprint moved
  • SDK release

Verified

  • macOS (arm64), in a sandbox profile with a throwaway plugin that crashes in its register on request:
    • Plugin segfault (Debug and ReleaseFast): reported as crashed in crashy 0.3.1. Each crashy+0x… offset matches std's symbolized trace (crashy+0x1b9404 = 0x12ba11404 in deep).
    • Plugin panic: the plugin's std prints the message and aborts, and the report says crashed in crashy 0.3.1: Abort, with frames from libsystem_c into crashy.
    • fizzy's own panic: the first sandbox run panicked in this PR's own code (an aliasing bufPrint in writeTo, fixed). The report named fizzy with its frames, and std's trace followed.
    • kill -SEGV while idle (ReleaseFast): the report shows system frames (kernel, CoreFoundation, HIToolbox, AppKit) named by image, then fizzy's.
    • kill -TERM: a normal exit, no report file.
  • Gates: zig build; zig build test (85/85 steps, 525/526 tests, 1 skipped), including the new fizzy-crash-tests (report text, image lookup, build-id note parsing); test-integration (44/44 steps, 359/359); check-web; test-sdk-version. scripts/check-examples.sh built example-plugin against this tree; its example-app half fails locally on macOS (the script writes an absolute temp path into the zon, which Zig rejects), unrelated to this change, so that half is CI's.
  • Linux: CI. The crash tests cross-compile for x86_64-linux-gnu, handlers included (refAllDecls).
  • Windows: CI. The crash tests cross-compile for x86_64-windows-gnu.
  • Web: check-web; nothing is installed there (crash.supported is false).

Follow-ups

  • A plugin's panic message in its report: the dylib entry the build helper generates declares a panic that forwards to the host's crash.panic through a symbol the executable exports. This also covers Windows, where a plugin's panic ends in RtlExitUserProcess(3), which no handler sees, so today it writes no report.
  • The report string: about 150 bytes, frames as module index plus VLQ offset, paste-able (plan step 3).
  • On the next launch: say fizzy crashed and whose code it was; "Report" opens a prefilled issue on that plugin's repo; safe mode offers to leave that plugin off.
  • Symbols: release builds upload their debug info by build id (fizzy's release and plugin-build-action), and the next launch resolves the frames locally.
  • A ring of the last log lines in the report, and the risky features in use (floats, glass, a live tape).
  • Frame pointers in release builds: measure the cost before deciding. On aarch64 macOS the ABI keeps them anyway, which is what the ReleaseFast run above walked.

🤖 Generated with Claude Code

fizzy's panic handler, std's segfault handler (now on in every build mode) and
a SIGABRT/SIGTRAP handler write one report per crash to stderr and
<config>/crashes/: who crashed (fizzy or which plugin), the loaded modules with
their build ids, and each frame as module+offset. Plugin dylibs enter a fixed
module table before their register runs and leave it before dlclose; ranges and
build ids are read from the mapped headers at load. ELF builds now carry a
build id.

Part of plans/RESTART_AND_CRASHES_PLAN.md, step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne merged commit fdf59d3 into main Oct 10, 2026
10 of 11 checks passed
@foxnne
foxnne deleted the app/crash-reports branch October 10, 2026 19:59
foxnne added a commit that referenced this pull request Oct 10, 2026
Part of `plans/RESTART_AND_CRASHES_PLAN.md`, step 3 (crash reports).
Follows #351.

## What changes

**A plugin's panic now reaches fizzy's crash report, message included.**
Before, a plugin dylib panicked through its own copy of std. That
printed the message to stderr, which the report never saw, and called
`abort()`. On macOS and Linux, #351's abort handler then wrote `crashed
in <plugin>: Abort` with no message. On Windows, std's abort is
`RtlExitUserProcess(3)`, which no handler sees, so no report at all.

Now the report reads:

```
fizzy crashed in crashy 0.3.1: panic: crashy was asked to panic
…
frames:
  crashy+0x1b6ea3
  crashy+0x1b6fbf
  fizzy+0x1a046f3
  …
```

The frames start at the plugin's panic site (`crashy+0x1b6ea3` is
`0x12a2eaea3 in register` in std's trace), with no `abort`/`libsystem`
frames in front.

**How:**
- **Plugin side:** the generated dylib root (`sdk/plugin_sdk.zig`, and
`plugins/shared/build/helpers.zig` for built-ins) declares `pub const
panic = sdk.dylib.panic`. That is `FullPanic` over a forwarder, so every
safety panic goes through it too.
- **The handoff:** the forwarder hands the message and the first trace
address to the host through a new `EditorAPI.panic`.
- **Host side:** fizzy raises it again as its own panic
(`std.debug.panicExtra`). Natively that is #351's `crash.panic`; on the
web it is the web backend's handler, which logs to the console where a
side module used to trap silently.
- **Fallback:** before the host is installed
(`sdk.runtime.installedHost()`, new; the runtime's host pointer is now
optional rather than `undefined`), the dylib falls back to std's own
handler.

## SDK impact

- [ ] None
- [ ] Core-only or additive
- [x] Fingerprint moved: `EditorAPI` gains `panic`; recorded
`0x65096e9408ddb32f` in `sdk/src/version.zig`, `sdk_version` untouched.
Plugins get the forwarding at the next SDK release, once they rebuild
against it. A plugin built against an older SDK keeps the old behavior,
and the fingerprint keeps it from loading into a fizzy built from this
commit.
- [ ] SDK release

## Verified

- [x] **macOS (arm64), sandbox profile, throwaway plugin that panics in
`register` on request:**
- Debug: the report above, with the message and frames from the panic
site.
- ReleaseFast fizzy and plugin: `fizzy crashed in crashy 0.3.1: panic:
crashy was asked to panic`, first frame `crashy+0x1d173`.
- A plugin segfault (ReleaseFast) is unchanged: `crashed in crashy
0.3.1: Segmentation fault at address 0x10`.
- [x] **Gates:** `zig build`, `zig build test` (87/87 steps, 531/532, 1
skipped), `test-integration` (46/46 steps, 371/371), `check-web`,
`test-sdk-version`.
- [x] **Web:** `plugins/image` builds as a wasm side module
(`-Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`) with the new
root. Not run in a browser.
- [ ] **Windows:** CI cross-compile only. This is the case it newly
covers (a plugin's panic reports instead of exiting silently); not run.
- [ ] **Linux:** CI.

## Follow-ups

- The rest of step 3: the short paste-able report string, a notice at
the next launch (which also says when step 4's checkpoint recovered
unsaved work, `KeptDocuments.recoveredFromCheckpoint()`), symbols by
build id, and safe mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne mentioned this pull request Oct 10, 2026
2 of 8 tasks
foxnne added a commit that referenced this pull request Oct 11, 2026
## What changes

`sdk_version` goes from 0.2.22 to 0.2.23. Merging tags `sdk-v0.2.23`,
publishes its tarball, and asks the store plugins to repin.

**Why now.** The plugin boundary's fingerprint moved since `sdk-v0.2.22`
(`0x1bf417f6…` → `0x65096e94…`, by #360), so store plugins built against
0.2.22 don't load in a fizzy built from `main`, and fizzyed.it/app is
built from `main`. Also, #355 moved the `plugins` service to version 2,
and the agent plugin's `fizzy_build` asks for version 1 until it repins.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**The `sdk` PRs since `sdk-v0.2.22`:**
- #360: a plugin's panic message reaches its crash report. This moves
the fingerprint.

**Additive, reaching plugins with this release:**
- #355: the `plugins` service is version 2, with `list` (each plugin's
identity, link, version, registration counts and status). A caller built
for version 1 is refused until it rebuilds, which this release's repin
does.
- #351: a crash report says whose code crashed (`sdk/plugin_sdk.zig`).

**Also reaching plugins (`core/`):**
- `core.viz`, graphics for live numbers: `line`, `bar`, `stat`, `Table`
(#345); `Pie`, `keyColor`, `palette` (#347); the table's rounded header
and shaded rows (#359).
- `core.profile`:
- `lookback()`: 30 s of costs and the frames over budget, plus
`ReportOptions.over_ms` / `.hitches` and `selfTimes` (#353).
- `frameCauses()` and `ReportOptions.causes`: why each frame happened
(#364).
- `phase` / `Phases` / `ReportOptions.startup`: startup timing (#365,
which landed inside #364's squash commit).
  - The profiler's `abi` stays 4.

## Verified

- [x] macOS:
  - `zig build` and `test-sdk-version` on this head.
  - `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.23.tar.gz`.
- Every store plugin (fresh pulls of pixi, zig, drive, atlas and ghostty
`main`) builds against this tree's `sdk/` with `zig build --fork=<sdk>`
into a throwaway profile (31/31, and 28/28 for each of the other four).
The fingerprint moved, so each repin PR is a real rebuild, but none
should open as a draft for a broken build. The agent and chat plugins
build too (22/22, 19/19).
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- Each repin PR merged and its plugin tagged (the plugin repos' tags
stay the maintainer's).
- `fizzyedit/agent` and `fizzyedit/chat` repin to the `sdk-v0.2.23`
tarball.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant