Skip to content

fix: never free ghostty surfaces on the main thread (app-wide deadlock) - #68

Open
roham wants to merge 2 commits into
dedene:mainfrom
roham:fix/main-thread-surface-free
Open

fix: never free ghostty surfaces on the main thread (app-wide deadlock)#68
roham wants to merge 2 commits into
dedene:mainfrom
roham:fix/main-thread-surface-free

Conversation

@roham

@roham roham commented Aug 11, 2026

Copy link
Copy Markdown

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_free can 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 -parse passes.
  • 9 adversarial opposite-vendor review rounds (strategy + patch + every amendment), all findings incorporated: join-inversion diagnosis, lock-guarded pointer handoff, no off-main property writes, TOCTOU-atomic state machine, nil-before-free ordering, closeRetain bridge, watchdog rejected (UAF risk) in favor of the bounded-leak trade-off. Review record in commit 288c45b.
  • Full build requires GhosttyKit.xcframework (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.

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
roham force-pushed the fix/main-thread-surface-free branch from 0f8b88b to 709a1a4 Compare August 11, 2026 01:30
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.
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