Skip to content

renderer: an off-main present never commits while it holds the Core Animation lock (embedder-mode deadlock from #70) - #71

Merged
lawrencecchen merged 4 commits into
release/cmux-9ce9b7d0-e2from
fix/off-main-commit-outside-ca-lock-2
Nov 3, 2026
Merged

lawrencecchen merged 4 commits into
release/cmux-9ce9b7d0-e2from
fix/off-main-commit-outside-ca-lock-2

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fixes a deadlock that renderer + embedded: the embedder presents a new host size with its bounds (set_host_present, host_frame) #70 added. In GHOSTTY_HOST_PRESENT_EMBEDDER mode, IOSurfaceLayer.setSurfaceOffMain (the GPU completion thread) took [CATransaction lock] before begin. Zig runs defers last-first, so the thread committed its transaction while it held the lock.
  • cmux frame owner refactor S4 films, 2026-11-03, with the S4 switch on and the film recorder's load: main hung in CA::Transaction::commit > commit_transaction, waiting for a lock. The Metal completion thread hung in bufferCompleted > setSurfaceOffMain, in its commit, while it held the CA lock. The renderer threads waited in SwapChain.nextFrame. The sample without the recorder did not hang. The default mode (GHOSTTY_HOST_PRESENT_RENDERER) never hung under the same load: it already did begin, lock, checks, unlock, commit.
  • Fix: both modes use that order. The embedder-mode gate stays: under the lock, a frame at the shown size goes off main only when the embedder reported its last change committed (main_pending clear). Otherwise it goes through main (setSurface).
  • Invariant (comment in setSurfaceOffMain, ghostty.h and NEXT.md): no thread commits a Core Animation transaction, or flushes, while it holds [CATransaction lock].
  • ghostty.h call-site rule for ghostty_surface_host_frame(present: true): present and set the bounds in one lock span, or in one Core Animation layout or display pass of the commit. An open transaction alone is not enough. Unlock before any commit or flush. Never call ghostty_surface_draw under the lock.
  • Lock order after the fix: the completion thread takes fresh.mutex in publishFresh and releases it before it takes the CA lock. Under the CA lock it does only the shown-size and sequence checks and the contents set. Main takes the CA lock, then fresh.mutex (takeFresh). fresh.mutex is a leaf on both threads, so no cycle. ghostty_surface_host_frame never waits. Main does wait for a free frame, and so for the completion thread, in a first-frame display pass and in ghostty_surface_draw, as before this PR (ghostty.h says so). cmux on macOS never calls ghostty_surface_draw.
  • The old order stopped an off-main commit from shipping a change that main made after the checks. Now main can run a lock span between the unlock and the off-main commit. cmux relies on Core Animation to encode a commit under the CA lock, so that a commit sees all of an embedder lock span or none of it. Main's sample shows commit_transaction waiting for that lock, but one frame does not prove that it holds the lock for all of its layer work. The S4 films judge it (two terminals in one span, with echo in a third). The gate (main_pending) and the mode are still read under the lock, so the checks for this surface stay correct.

Testing

  • New test: host present: an off-main present never commits while it holds the Core Animation lock (embedder), and the same for (renderer) as a control. It replaces +[CATransaction lock], +unlock and +commit with counting versions on the metaclass. It runs one setSurfaceOffMain on a spawned thread and expects one commit with no commit under the lock. It uses no GPU and no timing.
  • Red: test commit b0c7e291a, run https://github.com/manaflow-ai/ghostty-next/actions/runs/41694823112 on scratch/off-main-lock-red-2, which only narrows the filter to host present: . The embedder test fails with expected 0, found 1. The renderer control passes (102 of 103).
  • Green: fix 6a7472263, run https://github.com/manaflow-ai/ghostty-next/actions/runs/41694827381 on scratch/off-main-lock-green-2: ghostty-test 241/241, lib-vt 475/475.
  • 4338c404e and 3163f4b73 change only comments, ghostty.h and NEXT.md.
  • The workflow's test filter list does not change in this PR. The scratch branches are never merged.

Observed / Not run

  • Reviews at 3163f4b73: Codex SHIP and Claude SHIP (comments on this PR). The header text fix from the Claude review goes in a follow-up fork PR.
  • Observed: the red and green runs above, and the stacks from the cmux S4 sample (cmux STATUS 9732).
  • Not run yet: the cmux display test TerminalFrameOwnerMainThreadTests (main must keep turning under the recorder load) on a cmux build pinned to this fix. It goes in the cmux pin PR (#33956), with its red step on the old pin.

Changelog

none

…the Core Animation lock (red)

cmux FRAME OWNER REFACTOR S4 films, 2026-11-03 (build ada386be, pin
635c6c1): with GHOSTTY_HOST_PRESENT_EMBEDDER on and the app's frame
recorder running, the main thread hung for good at the first terminal
resize. Sample of the hung app: main in CA::Transaction::commit >
commit_transaction > _os_unfair_lock_lock_slow; com.Metal.CompletionQueueDispatch
in Frame.bufferCompleted > Metal.present > IOSurfaceLayer.setSurfaceOffMain
(the embedder branch); every renderer thread waiting for a swap-chain frame.
The embedder branch took [CATransaction lock] before begin and committed
while it held it (Zig runs defers last-first: commit, then unlock). The base
branch, which the same app ran without a hang under the same recorder load
(the switch-OFF films), unlocks before it commits.

The test states the invariant directly and needs no timing and no GPU:
+[CATransaction lock], +unlock and +commit are swapped for versions that
count, per thread, commits made at a lock depth above 0; one off-main present
of a frame at the shown size runs on its own thread. It must commit once, at
depth 0. Red in the embedder mode on 635c6c1 (1 commit under the lock);
the renderer mode is the control (0 on both).
…it commits (embedder mode deadlock)

The embedder branch of IOSurfaceLayer.setSurfaceOffMain (PR #70, third
review) took [CATransaction lock] before begin and committed under it (Zig
runs defers last-first: commit, then unlock). In cmux's S4 films that
deadlocked against the main thread's commit: main in
CA::Transaction::commit > commit_transaction waiting for a lock, the Metal
completion thread in this present, every renderer thread waiting for a
swap-chain frame the completion handler never returned.

The branch is gone: every off-main present takes the base order (begin,
lock, checks, unlock, then commit), which the same app ran without a hang
under the same load (the switch-OFF films), with the main_pending gate. The
invariant is written at the function: no thread but main commits a
transaction while it holds [CATransaction lock].

The removed branch also kept an off-main present from running its checks or
setting its frame between an embedder's present and its bounds change. That
stays the embedder's call-site rule, now said plainly in ghostty.h: present
and give the view its bounds while holding [CATransaction lock] (or inside a
Core Animation layout or display pass of the commit), which off-main presents
never hold while they commit (Codex review of the first version of this fix).
The default mode does not change.
…stty.h says to unlock before a commit, a flush or ghostty_surface_draw

Claude review of the first version: main is not exempt from the lock order
(a commit or flush under the lock on main deadlocks the same way against an
off-main commit), and ghostty_surface_draw under the lock can wait for a
frame whose completion waits for the lock. Comments and ghostty.h only.
…ceOffMain (no thread commits under the Core Animation lock)
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5369f490-35dc-45ff-b810-93ac9436051a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Codex review (gpt via codex exec -s read-only, code inline in the prompt; pack: fix.diff, IOSurfaceLayer.zig at head and base, Metal.zig, Frame.zig, ghostty.h host-present block, cmux TerminalFramePresenter.swift and TerminalSurfaceView+FrameOwner.swift). Questions asked: (A) can any thread other than main commit or flush under [CATransaction lock]; (B) can the completion handler block on anything main holds; (C) what the deleted embedder branch guaranteed; (D) test validity; (E) docs vs code; and whether the three v1 findings are fixed.

No blocking findings in the supplied code.

  • A — No bug: src/renderer/metal/IOSurfaceLayer.zig:254–260: every off-main return after begin unlocks before committing, through reverse defer order. Other explicit CA lock spans only inspect or mutate state; none begins, commits, or flushes. src/renderer/metal/Frame.zig:122 commits a Metal command buffer, not a CA transaction. No explicit CA flush appears in the supplied code.

  • B — No bug; bounded scope: src/renderer/Metal.zig:498–555: completion can wait for FreshFrame.mutex, then separately for the CA lock. Publication releases the mutex and wakes the fresh-frame futex before attempting CA presentation. Main can take that mutex while holding CA, but completion never holds it while acquiring CA: no reverse edge. Metal.zig:607–634 releases the mutex before its bounded futex wait; embedder hostFrame never performs that wait. Frame.zig:82 returns the swap-chain frame only after presentation, explaining renderer starvation during the original deadlock. Synchronous drawing can wait for swap-chain availability/completion, so calling it under CA remains unsafe.

    In TerminalFramePresenter.swift:1 (pass), main holds CA across hostFrame(true) and landRequestedFrame; TerminalSurfaceView+FrameOwner.swift:1 (landRequestedFrame) updates ownership state, the view frame, and layer geometry. Follow-up size requests happen after unlocking. The shown path neither draws synchronously nor commits/flushes under CA. Unshown AppKit callbacks and swap-chain internals cannot be audited from these excerpts.

  • C — No bug: IOSurfaceLayer.zig:254–274: the deleted branch excluded main’s changes throughout off-main commit and avoided transactions on rejected checks. The replacement excludes interleaving during checks/mutation, allowing main to run before commit. Main’s required single CA lock span makes its pixels-and-bounds update indivisible to CA readers; an intervening off-main commit cannot observe the halfway state. mainPending prevents subsequent independent presents until acknowledgment. Sequence comparisons and updates remain under CA (:154, :267, :357), preserving ordering between numbered presents.

  • D — Risk, nonblocking: IOSurfaceLayer.zig:723 onward (CommitOrder): metaclass replacements count explicit commits entered while the calling thread’s tracked lock depth is positive. Worker-local counters are copied before exit and read after join; deferred removal covers spawn errors and assertion failures. Contents, successful presentation, and exactly-one-commit assertions prevent a skipped base path from passing accidentally. No sleeps or watchdog remain. Zig/ObjC signatures and calls are plausible. Global swizzling assumes serial tests and stable methods; if parallel execution is introduced, serialize this test. It verifies commit ordering, not arbitrary CA internals or flushes.

  • E — No bug: include/ghostty.h:1569 now requires one lock span, explicitly rejects an open transaction alone, and prohibits commit/flush/draw under lock. NEXT.md:126 matches the implementation. All three v1 concerns are addressed.

Review (codex): SHIP at 3163f4b739c9f98a8f002c7499bda98e281a4f61

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Independent review (Claude subagent, read-only, no build) at 3163f4b: SHIP.

Findings and what I do with them:

  1. No thread other than main commits or flushes under [CATransaction lock]: the only off-main commit is setSurfaceOffMain (IOSurfaceLayer.zig:257-261), and its unlock runs before its commit on every return. The other lock sites run on main and never commit. Frame.zig:122 commits a Metal command buffer.
  2. Lock graph: the completion thread takes fresh.mutex and then the CA lock, and it releases fresh.mutex first (Metal.zig:479-487). Main takes the CA lock and then fresh.mutex (takeFresh). fresh.mutex is a leaf on both threads, so there is no cycle.
  3. should-fix (documentation; the risk is older than this PR). The PR body said that main never waits on the completion thread. That is false: a first-frame display pass and ghostty_surface_draw wait for a free frame (ghostty.h:1509-1512 says so). Also, the header's "(or one Core Animation layout or display pass of the commit)" either is not a lock span, or makes a first-frame draw a draw under the lock. Done: the PR body is corrected. Follow-up fork PR: the header text will allow only an explicit lock span. cmux uses only that span (TerminalFramePresenter.pass). cmux on macOS never calls ghostty_surface_draw.
  4. should-fix (not verified): "a commit sees all of a lock span or none" needs commit_transaction to hold the CA lock for all of its layer work. One sample frame does not prove that. Done: the PR body now says that cmux relies on this, and that the S4 films (two terminals in one span, with echo in a third) judge it.
  5. nit: a refused present now commits an empty transaction in embedder mode too. The renderer mode always did this. I keep the order that the default mode has used for a long time (begin, lock, checks, unlock, commit). The cmux bench ON arm shows the effect on fresh/late presents and missed frames. If it costs something, the follow-up uses lock, checks, begin, set, unlock, commit.
    6-8. The present order, the test and the docs are sound. Test nits: +flush is not swapped, although the comments say "or flushes". install() has a short gap between the swap and storing the original. The runner is single-threaded, so this gap is safe now.

Not verified (Core Animation and Metal internals): whether layout or display passes hold the CA lock, and whether commit_transaction holds it for all of its layer work. The cmux display test TerminalFrameOwnerMainThreadTests on a cmux build pinned to this fix comes next, in cmux #33956.

@lawrencecchen
lawrencecchen merged commit 7d43bb6 into release/cmux-9ce9b7d0-e2 Nov 3, 2026
9 checks passed
lawrencecchen added a commit that referenced this pull request Nov 3, 2026
… explicit CATransaction lock span with no draw in it

docs: ghostty_surface_host_frame's present needs an explicit CATransaction lock span with no draw in it (#71 follow-up)
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