fix: never free ghostty surfaces on the main thread (app-wide deadlock) - #68
Open
roham wants to merge 2 commits into
Open
fix: never free ghostty surfaces on the main thread (app-wide deadlock)#68roham wants to merge 2 commits into
roham wants to merge 2 commits into
Conversation
Main thread was freezing in pthread_join inside ghostty surface teardown, reached through a text-change notification observer. ghostty's free can join its io/renderer threads; when one is blocked after a pane stream stall, the join never returns and the app becomes unresponsive (no window switching). close() now hands the raw surface pointer to a lock-guarded pendingFree and nils the property on the main thread synchronously. The free happens only in ghostty's close callback (notifySurfaceClosed -> freeSurface) or in deinit as a fallback — always off the main thread, with a defensive bounce. Cross-vendor review record: strategy reviewed by opposite-vendor model (verdict: main-thread-safety fix A', confirmed against source); patch reviewed adversarially (CHANGES NEEDED -> conformance: pointer handoff with lock, surface=nil on main, bounce instead of dispatchPrecondition, no property writes on ghostty's thread).
roham
force-pushed
the
fix/main-thread-surface-free
branch
from
August 11, 2026 01:30
0f8b88b to
709a1a4
Compare
dedene#67) Final design after 9 cross-vendor review rounds (strategy + patch, opposite vendor each round): - Atomic TeardownState state machine (.live/.closing/.freed) under OSAllocatedUnfairLock, armed once at init — sole owner of the raw pointer. - close(): main-queue precondition; atomic live->closing; closeRetain strong bridge keeps the object alive until the callback drains (prevents deinit freeing a pointer ghostty still owns); property nil'd on main. - close callback (ghostty's safe-to-free signal) frees in both states, always off-main, after the property is nil'd on main, with withExtendedLifetime to keep userdata valid during teardown. - deinit: last-resort net; .live there asserts a contract violation. - Stalled panes (callback never fires) deliberately leak the surface: freeing while ghostty may still be closing is a guaranteed UAF; the leak is bounded to stalled panes. Rounds: 1 strategy A' (join-inversion, not dead-worker); 2 patch must-fixes (lock handoff, bounce, no off-main property writes); 3 TOCTOU (state machine); 4 atomic transitions; 5 nil-before-free ordering; 6 closeRetain bridge; 7 watchdog proposed; 8 watchdog removed (UAF on slow close beats leak trade-off); 9 APPROVE.
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 fixes
Zentty freezes completely (no window switching, no input) every few days of uptime. Process samples show the main thread pinned forever in
_pthread_join → __ulock_wait, reached through an AppKit text-change notification observer. The join is inside ghostty surface teardown:ghostty_surface_freecan join the surface's io/renderer threads, and when one of those threads is blocked (observed after per-pane stream stalls), the join never returns. Reproduced 2026-07-27, 2026-08-05, 2026-08-10; upstream reference: #67.The change (one file, LibghosttySurface.swift)
Never free a ghostty surface on the main thread. A single-owner teardown state machine (
.live/.closing/.freed, OSAllocatedUnfairLock, armed at init) is transitioned atomically by close(), the close callback (ghostty's safe-to-free signal), and deinit. close() keeps a strong self-bridge so deinit can never free a pointer ghostty still owns; the free always runs off-main after the property is nil'd on the main thread, keeping the Swift object alive through teardown (withExtendedLifetime).Deliberate trade-off: a stalled pane whose close callback never fires leaks its surface (bounded to stalled panes) — freeing while ghostty may still be closing is a guaranteed use-after-free.
Verification
swiftc -frontend -parsepasses.288c45b.scripts/build_ghosttykit.sh) — not available here; the change is one file, no new APIs. Maintainers should confirm the close callback fires for app-initiated closes on the normal path (worst case there is the documented deinit net).What this does not fix
The underlying ghostty behavior (joining a blocked io/renderer thread from teardown) is upstream; this removes the main-thread exposure so a stalled pane can no longer freeze the whole app.
I have read CLA.md and agree to its terms.