fix: reject stale ghostty surface userdata pointers before ARC retain - #262
Merged
Conversation
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().
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
#258and#259(window observer fixes) are confirmed unrelated to this crash.Symbolicated backtrace (identical across 6 minidumps)
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
.scrollbarsurface message (ghostty/src/renderer/generic.zig:1477-1483, gated byscrollbar_dirty) carrying the surface's userdata pointer. The main thread drains that mailbox later viaApp.tick -> drainMailbox -> Surface.updateScrollbar -> performAction -> handleAction. If the surface was torn down in between,callbackContext(from:)'sUnmanaged.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
GhosttySurfaceUserdataRegistry(Sources/GhosttyApp.swift): a lock-guardedSet<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 inTerminalSurface.swift(liveSurfaceForGhosttyAccess,performSurfaceTeardown's three exits, the surface-recreate and creation-failure paths, and the four DEBUG test helpers).resolve(from:)— used bycallbackContext(from:)— checks liveness and copies out the needed fields into a new value type,GhosttySurfaceCallbackSnapshot, all while holding the lock.callbackContext(from:)now returnsGhosttySurfaceCallbackSnapshot?instead of the live class reference, so no caller anywhere can retain or read a property of aGhosttySurfaceCallbackContextoutside 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.dlogunder categorysurface.userdataso 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. ButGhosttyApp.runtimeReadClipboardCallbackexplicitly documents resolving this same userdata pointer off-main, on ghostty's IO thread (read_clipboard_cb), andGhosttySurfaceCallbackContext'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_messagemailbox entries onghostty_surface_freeso 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
WorkspaceSplitWorkingDirectoryTestssuite (programaTests/WorkspaceUnitTests.swift), using the existingreplaceSurfaceWithFreedPointerForTesting()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 backnilinstead of crashing.callbackContext(from:)is private, so this adds two small DEBUG-only test seams:GhosttyApp.debugCallbackContextResolves(from:)andTerminalSurface.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 SUCCEEDEDxcodebuild ... -scheme programa-unit ... test -only-testing:programaTests/WorkspaceSplitWorkingDirectoryTests→ 4/4 passed, including the newtestStaleSurfaceUserdataResolvesToNilInsteadOfCrashingTerminalAndGhosttyTests— no suite by this name exists in the repo currently; nothing to run