Skip to content

fix: reject stale ghostty surface userdata pointers before ARC retain - #262

Merged
arzafran merged 1 commit into
mainfrom
fix/stale-surface-userdata
Aug 5, 2026
Merged

fix: reject stale ghostty surface userdata pointers before ARC retain#262
arzafran merged 1 commit into
mainfrom
fix/stale-surface-userdata

Conversation

@arzafran

@arzafran arzafran commented Aug 5, 2026

Copy link
Copy Markdown
Member

What this does

Fixes the real crash behind the occluded-render re-land failures: 6 identical CI minidumps, all a use-after-free on a ghostty surface's userdata pointer. When a surface is torn down, ghostty can still deliver a queued message (in this case, a scrollbar-dirty notification) carrying that surface's old userdata pointer. We were dereferencing it unconditionally, which sometimes crashed and sometimes silently corrupted memory. Now we check a small live-pointer registry first and just ignore the message if the surface is already gone.

#258 and #259 (window observer fixes) are confirmed unrelated to this crash.

Symbolicated backtrace (identical across 6 minidumps)

objc_retain                                        EXC_BAD_ACCESS
GhosttySurfaceCallbackContext.tabId.getter
GhosttyApp.callbackContext(from:)                  Sources/GhosttyApp.swift:1489-1492
GhosttyApp.handleAction(target:action:)            Sources/GhosttyApp.swift:1622 (userdata read at :1529 region)
[ghostty] apprt embedded App.performAction
[ghostty] Surface.updateScrollbar                  ghostty/src/Surface.zig:1725
[ghostty] Surface.handleMessage / App.surfaceMessage / App.drainMailbox / App.tick
GhosttyApp.tick()  ->  scheduleTick() closure      (main thread)

One of the six dumps instead hit doesNotRecognizeSelector -> abort: same bug, the freed page had been reused by a live object of a different class.

Mechanism: the ghostty renderer thread enqueues a .scrollbar surface message (ghostty/src/renderer/generic.zig:1477-1483, gated by scrollbar_dirty) carrying the surface's userdata pointer. The main thread drains that mailbox later via App.tick -> drainMailbox -> Surface.updateScrollbar -> performAction -> handleAction. If the surface was torn down in between, callbackContext(from:)'s Unmanaged.fromOpaque(userdata).takeUnretainedValue() operates on a pointer whose backing object was already released — returning it requires an ARC retain, which faults on freed/reused memory.

Summary

  • Adds GhosttySurfaceUserdataRegistry (Sources/GhosttyApp.swift): a lock-guarded Set<UnsafeMutableRawPointer> of currently-live callback-context userdata pointers.
    • register(_:) at context creation (TerminalSurface.swift ~1158, before the pointer is ever handed to ghostty).
    • release(_:) — unregisters and releases atomically under the same lock — replacing every raw .release() call at all 10 existing teardown/release sites in TerminalSurface.swift (liveSurfaceForGhosttyAccess, performSurfaceTeardown's three exits, the surface-recreate and creation-failure paths, and the four DEBUG test helpers).
    • resolve(from:) — used by callbackContext(from:) — checks liveness and copies out the needed fields into a new value type, GhosttySurfaceCallbackSnapshot, all while holding the lock.
  • callbackContext(from:) now returns GhosttySurfaceCallbackSnapshot? instead of the live class reference, so no caller anywhere can retain or read a property of a GhosttySurfaceCallbackContext outside the registry's lock. All 4 existing call sites only ever read .tabId/.surfaceId, so this is a transparent swap; none of them force-unwrap the result.
  • Rejects with a DEBUG-only dlog under category surface.userdata so a future occurrence is visible.

Threading — why a lock, not a main-actor-only registry: the scrollbar crash path (App.tick -> drainMailbox -> handleAction) is main-thread only, and teardown is main-actor too, so a main-actor registry would have been sound for that path alone. But GhosttyApp.runtimeReadClipboardCallback explicitly documents resolving this same userdata pointer off-main, on ghostty's IO thread (read_clipboard_cb), and GhosttySurfaceCallbackContext's own doc comment states ghostty invokes clipboard/action callbacks synchronously before any main-thread hop. Given a real off-main caller exists, this uses a lock-guarded registry instead, per the fix's own escape-hatch condition.

Not attempted here: the deeper root fix is on ghostty's side — cancel queued .surface_message mailbox entries on ghostty_surface_free so a torn-down surface's messages are never redelivered at all. That's filed as a follow-up; this PR is the bounded Swift-side guard.

Regression test: added to the existing WorkspaceSplitWorkingDirectoryTests suite (programaTests/WorkspaceUnitTests.swift), using the existing replaceSurfaceWithFreedPointerForTesting() fixture built for exactly this scenario: capture a live userdata pointer, tear down through the real teardown path, then resolve that same pointer and assert it comes back nil instead of crashing. callbackContext(from:) is private, so this adds two small DEBUG-only test seams: GhosttyApp.debugCallbackContextResolves(from:) and TerminalSurface.debugCallbackUserdataPointer(). The real regression gate for this class of bug is the occluded-render re-land itself, PR #257.

Test Plan

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/programa-userdata build → BUILD SUCCEEDED
  • xcodebuild ... -scheme programa-unit ... test -only-testing:programaTests/WorkspaceSplitWorkingDirectoryTests → 4/4 passed, including the new testStaleSurfaceUserdataResolvesToNilInsteadOfCrashing
  • TerminalAndGhosttyTests — no suite by this name exists in the repo currently; nothing to run

Six identical CI minidumps from the occluded-render re-land symbolicated to
the same crash: objc_retain EXC_BAD_ACCESS in
GhosttySurfaceCallbackContext.tabId.getter, reached from
GhosttyApp.callbackContext(from:) resolving a ghostty_surface_userdata
pointer whose backing GhosttySurfaceCallbackContext had already been
released by teardown. The renderer thread enqueues a .scrollbar surface
message (ghostty/src/renderer/generic.zig:1477-1483) carrying the surface's
userdata pointer; App.tick -> drainMailbox -> Surface.updateScrollbar ->
performAction later drains it on main and hands the stale pointer to
handleAction. #258 and #259 (window observer fixes) are confirmed unrelated
to this crash.

Adds GhosttySurfaceUserdataRegistry: a lock-guarded set of live userdata
pointers. register() at context creation, release() (unregister + release,
atomically) at all 10 existing teardown/release call sites in
TerminalSurface.swift, and resolve() in callbackContext(from:), which now
returns a value-type GhosttySurfaceCallbackSnapshot instead of the live
class reference -- so no caller anywhere can retain or read a property of a
GhosttySurfaceCallbackContext outside the registry's lock.

Threading: a main-actor-only registry would NOT have been sound. The
scrollbar path is main-thread only, but runtimeReadClipboardCallback
(GhosttyApp.swift) explicitly documents resolving this same userdata
off-main, on ghostty's IO thread, and GhosttySurfaceCallbackContext's own
doc comment states callbacks read it synchronously before any main-thread
hop. That off-main call site is why this uses a lock instead of Main-actor
isolation.

The renderer-side fix (canceling queued .surface_message entries on
ghostty_surface_free so the mailbox never re-delivers a freed surface's
messages) is the deeper root cause and is a separate ghostty-side follow-up,
not attempted here. The real regression gate for this class of bug is the
occluded-render re-land, PR #257.

Adds a DEBUG-only regression test in WorkspaceSplitWorkingDirectoryTests
(WorkspaceUnitTests.swift) using the existing
replaceSurfaceWithFreedPointerForTesting() test fixture: capture a live
userdata pointer, tear down through the real teardown path, then resolve
that same pointer and assert it returns nil instead of crashing. Needed two
small DEBUG-only test seams since callbackContext(from:) is private:
GhosttyApp.debugCallbackContextResolves(from:) and
TerminalSurface.debugCallbackUserdataPointer().
@arzafran
arzafran merged commit 7403966 into main Aug 5, 2026
10 checks passed
@arzafran
arzafran deleted the fix/stale-surface-userdata branch August 5, 2026 18:21
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