Skip to content

layout: a pane writing its tabs back no longer reads freed ids - #476

Merged
foxnne merged 1 commit into
mainfrom
layout/assign-borrowed-ids
Oct 17, 2026
Merged

foxnne merged 1 commit into
mainfrom
layout/assign-borrowed-ids

Conversation

@foxnne

@foxnne foxnne commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

A crash fix, with no plan behind it.

What changes

Fizzy crashed on 2026-10-16. It was a Debug build of main around #461, at SDK 0.2.28, and it segfaulted in mem.eql inside Layout.State.evictIdFromOthers. The path was assign ← fizzyAssignSurfaces ← Workbench.showBeside ← Editor.newFile ← pixi's createDocument. An agent's pixi.create was opening a sprite beside the person (host.openBeside, #442), into a pane that already had tabs.

Cause: State.assign called setAssignment, which frees the region's old id strings. It then evicted each new id from every other region, reading the ids from the caller's list. A pane adds a tab by reading its assignment, appending to it and writing it back (Workspace.addTab; removeTab, insertTab, swapTabId and Workspaces.close work the same way). So the caller's list borrows the strings that were just freed, and the eviction compared against freed memory. Most of the time that read only returns garbage. It faults once the allocator has handed the freed slot's page back to the OS, which is why it took a long session with tabs opened and closed.

Fix: assign now evicts by the region's new, owned copy and its stored key. Nothing it reads after the free can be borrowed from what was freed. A caller's list may therefore borrow from any assignment, its own included. The SDK doc for assignedSurfaces (Host and EditorAPI) now says so: "read, edit, write" was already how every caller used it. It is a comment change only, and the fingerprint does not move.

The ownership is fixed in State, not with a guard in evictIdFromOthers, so every caller of the read-edit-write pattern is safe without each one copying first.

Found in review, fixed here too:

  • assign now takes its own entry's key and list out of the map before evicting. Rewriting another region runs getOrPut, which grows the map before it looks up even an existing key, so a pointer into the entry could go stale mid-loop. This was a second use-after-free of the same kind.
  • evictIdFromOthers kept at most 32 regions and 32 tabs in fixed buffers and swallowed setAssignment errors. A pane of 40 tabs that gave one up came back with 32, with no error. It now uses lists, and its errors reach assign's caller.

SDK impact

  • None (doc comments on assignedSurfaces only)
  • 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
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS:
    • New test: "assigning a region a list borrowed from the assignments it replaces" (tests/integration.zig). It uses std.heap.page_allocator, so every free unmaps and a read of a freed id faults every time. Without the fix it crashes with the same stack as the report (mem.eql ← evictIdFromOthers ← assign). With the fix it passes.
    • Two more tests, each failing before its fix:
      • "assigning a region whose ids other regions hold, whatever the map's fill" tries every count of regions up to 63, so some count leaves the map full at the rewrite. On the page allocator it segfaulted at the stale key_ptr.
      • "taking one id out of a region of many keeps the rest" expected 39 tabs and found 32.
    • Whole-editor test: the headless test now creates four documents with host.openBeside() into two panes, closing every other one. It checks that the person stays in their pane and that the last document is active beside them. That test runs on smp_allocator, so it exercises the path but would not reliably catch the read on its own.
    • Real run: a Debug build with --profile set to a fresh directory, with pixi and agent main built against this tree (--fork). Through the agent's fizzy-mcp, I ran 8 pixi.create calls with paths and 3 fizzy.close calls. That ended with two panes, holding s2, s4 and s6, s7, s8, and fizzy kept running. The same sequence run 3 times on a build without the fix did not crash either, because the freed slots' pages weren't unmapped; the page-allocator test is the deterministic proof.
    • Gates: check-fmt, zig build, test and test-integration (53/53 steps succeeded, 558 tests), plus check-web, test-sdk-version and check-tests-reached.
  • Windows:
  • Linux: (CI)
  • Web:

Follow-ups

None.

🤖 Generated with Claude Code

`Layout.State.assign` freed a region's old id strings (`setAssignment`), then took the new ids
out of every other region by reading the caller's list. A pane adds or drops a tab by reading
its assignment, editing it and writing it back (`Workspace.addTab`), so that list borrows the
very strings just freed. Fizzy crashed there on 2026-10-16 (`mem.eql` in `evictIdFromOthers`),
on an agent's `pixi.create` opening beside the person into a pane that had tabs.

`assign` now evicts by the region's new, owned copy, so its input may borrow from any
assignment, its own included. It takes that copy's key and list out of the map first:
rewriting the other regions can grow the map (`getOrPut` makes room before it looks) and move
its entries. The SDK's `assignedSurfaces` doc says so: read, edit, write.

`evictIdFromOthers` kept at most 32 regions and 32 tabs in fixed buffers and swallowed its
errors: a pane of 40 tabs that gave one up came back with 32. It now uses lists, and its
errors reach `assign`'s caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/assign-borrowed-ids branch from b5b6070 to 5c64103 Compare October 17, 2026 13:34
@foxnne
foxnne marked this pull request as ready for review October 17, 2026 13:35
@foxnne
foxnne merged commit 0d071c3 into main Oct 17, 2026
11 checks passed
@foxnne
foxnne deleted the layout/assign-borrowed-ids branch October 17, 2026 13:59
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