Skip to content

Surface: feed terminal output to libghostty off the main thread (deadlock fix)#8

Open
robgough wants to merge 1 commit into
arach:mainfrom
robgough:fix/main-thread-output-feed-deadlock
Open

Surface: feed terminal output to libghostty off the main thread (deadlock fix)#8
robgough wants to merge 1 commit into
arach:mainfrom
robgough:fix/main-thread-output-feed-deadlock

Conversation

@robgough

@robgough robgough commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Another production find from running Termini in Belfry (see #2, #3): a permanent main-thread deadlock — beachball, force-quit — triggered by ordinary tmux usage.

The deadlock. SurfaceContainerView.processRemoteOutput calls ghostty_surface_process_output on the main thread. Inside ghostty, stream actions that notify the host — set_title, set_mouse_shape (one per DEC mouse-mode toggle 1000/1002/1003/1006), pwd/color changes — are pushed onto the app-wide 64-slot mailbox (BlockingQueue(Message, 64) in ghostty's App.zig); when it's full, surfaceMessageWriter does push(.forever) and blocks until ghostty_app_tick drains the mailbox. That tick only ever runs on the main thread — the very thread now parked inside process_output. Nothing can unblock it; the wakeup callback's tick is queued behind the blocked main thread.

Any single output burst carrying >64 such sequences deadlocks the app permanently. We hit it reliably with a tmux pane split under set -g mouse on (tmux toggles mouse tracking around every redraw cycle, and each toggle flips ghostty's mouse shape → one mailbox message). Hang reports show the main thread in __ulock_wait2 under processRemoteOutput with ghostty's io/renderer threads idle. This is the same deadlock class as the existing surfaceIOReady startup buffering, just resurfacing after startup.

The fix. Real ghostty always feeds terminal output from a dedicated read thread while the apprt main loop ticks. Surfaces now do the same: a per-surface serial outputFeedQueue hands bytes to ghostty_surface_process_output (byte order preserved; the feed block retains the view so deinit — the only place the surface is freed — can't run mid-call), then hops back to the main thread for the existing refresh/draw (drawAfterRemoteOutput). A full mailbox now just stalls the feed queue briefly until the next tick drains it. Same change on macOS and iOS.

Verified A/B with TerminiDemo and a fake $SHELL that emits 800 \e[?1003h\e[?1003l toggles in one burst: the unpatched binary deadlocks instantly with the exact production stack (100% of samples in __ulock_wait2 under processRemoteOutput); the patched binary stays responsive and keeps rendering. swift build clean on macOS and iOS.

Note: this touches the same processRemoteOutput region as #7, so whichever lands second will need a trivial rebase (in the combined version, the main-thread hop keeps #7's canRender gating — happy to update either PR once the other merges).

🤖 Generated with Claude Code

https://claude.ai/code/session_01RHWv6ccUfu4nK3yiGB4ocZ

…lock fix)

ghostty_surface_process_output can block: escape sequences that notify
the host (set_title, set_mouse_shape from DEC mouse-mode toggles,
pwd/color changes) push onto ghostty's app-wide 64-slot mailbox, and
when it fills the push waits until ghostty_app_tick drains it. That
tick runs on the main thread, so feeding output on the main thread
deadlocks the app for good whenever a single burst carries more than
64 such sequences -- e.g. a tmux pane-split redraw with `mouse on`,
which toggles mouse tracking modes around every redraw cycle.

Real ghostty always feeds output from a dedicated read thread while
the apprt main loop ticks, so surfaces now do the same: a per-surface
serial queue hands bytes to ghostty_surface_process_output (the feed
block retains the view so deinit can't free the surface mid-call),
then hops back to the main thread for the existing refresh/draw. A
full mailbox now just stalls the feed queue until the next tick. Same
fix on macOS and iOS; generalizes the existing surfaceIOReady startup
buffering, which covered the same deadlock class at surface creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHWv6ccUfu4nK3yiGB4ocZ
ghostty_surface_process_output(surface, ptr, UInt(data.count))
}
}
DispatchQueue.main.async { [weak self] in

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please capture self strongly in this main-queue callback so ownership transfers from outputFeedQueue to the main thread. Otherwise, if this feed block is the last owner, the view’s deinit can run on the background queue and tear down UI resources off-main.

DispatchQueue.main.async { [self] in
    drawAfterRemoteOutput()
}

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.

2 participants