renderer: a frame the embedder presents itself wakes no main-thread present
Summary
- With
GHOSTTY_HOST_PRESENT_EMBEDDER, a frame that could not go to the screen off main went to the main queue (IOSurfaceLayer.setSurface), andsetSurfaceCallbackdropped it there when it was a frame of the host size, kept for the embedder's present (publishFresh), while the layer showed another size in bounds of another size. The embedder shows that frame itself, with its bounds (hostFrame), so the block only woke the main thread. - cmux frame owner refactor S4 measured the cost in a held key resize with 7 moving terminals: one main-thread wake per moving terminal and frame, +0.27 ms of main per motion frame, and more missed refreshes with S4 on than off (cmux24 job
c33bd85dbb064376b348d0c2: 55 vs 44 over 6 rounds). This is why cmux keeps S4 off by default. - Fix:
Metal.presentskips the main-queue block for that frame only (Metal.embedderShowsKeptFrame). The decision runs under the Core Animation lock and thenfresh.mutex, the order in which the main thread takes them inhostFrame's lock span. It needs both: the layer cannot show the frame in its bounds now (IOSurfaceLayer.embedderShowsFrameLocked, the testsetSurfaceCallbackmakes), and the kept fresh frame is still this frame at its present sequence and host size generation. - Unchanged: a first frame (nothing shown), a frame at the shown size (it waits for the embedder's commit), a frame for bounds the embedder already set, a frame of a size the host no longer has, and a frame no longer kept all still go through main. Renderer mode and iOS are unchanged.
- Kept-frame rule: a host change after the skip drops the kept frame (
setHostDrawableSize), even back to an equal size (A->B->A). This is the existing rule for kept frames: a frame kept before a host change is not presented for the new size. The renderer draws a new size at once while visible (Thread: a resize setsresized, anddrawsNowdraws). While frames are held or the surface is hidden, the layer keeps its last frame.ghostty.hnow says this, and says that after giving bounds without a present the embedder calls with present true when the answer is READY. HostFrameCounters.main_presentscounts the framespresenthands to the main thread. It is not in the C counters struct (no ABI change).
Testing
New host present: tests in renderer/Metal.zig (real layer and IOSurfaces, no GPU):
- a frame of a new host size that the embedder presents: no main present, and
hostFrame(true)presents it; - a kept frame that a host change dropped since its publish:
embedderShowsKeptFrameis false (it goes through main); - after a host change drops a skipped frame (A->B->A, bounds A without a present):
hostFrame(false)is PENDING with the last frame kept, and the renderer's frame of the second A presents itself through main; - guards: a frame for bounds the embedder already set goes through main; a frame of a size the host no longer has goes through main in both modes.
Red, then green (scratch branches that only narrow the workflow's test filter to host present: ; never merged):
- Red: test commit
051ecec11, run https://github.com/manaflow-ai/ghostty-next/actions/runs/41915563142: the two embedder-mode tests fail withexpected 0, found 1; 105/107 pass. - Green at the first fix
bc2ba9dd3: run https://github.com/manaflow-ai/ghostty-next/actions/runs/41915567752: 107/107 and lib-vt 97/97. - Green at
cd11dc544(the head's code;5f97f6389changes oneghostty.hcomment): run https://github.com/manaflow-ai/ghostty-next/actions/runs/41917705365: 109/109 and lib-vt 97/97.
Reviews (Codex, read-only with the code in the prompt: the sandbox here runs no command):
- FIX at
bc2ba9dd3: the early return for a frame of a size the host no longer has decided with a stale host size and kept the frame nowhere. Fixed in3d209283a: that frame goes through main again. - FIX at
3d209283a: "published" only meant that the publish ran. Fixed in17ccbcfea: the skip needs the kept frame to still be this one, checked under both locks. - FIX at
17ccbcfea: after the skip, a host change A->B->A drops the frame, which the old queued block could have shown. Answered incd11dc544with the kept-frame rule, the renderer's draw at a resize, and a test of that sequence. - SHIP at
cd11dc544(two nits): theghostty.hwording promised more than the code does (fixed in5f97f6389, comment only); the tests supply the replacement frame by hand (the renderer's wiring is outside this change).
Observed / Not run
- Observed: the runs above;
zig fmt --checkandzig ast-checkof the changed files. - Not run: the cmux side. The cmux pin move to this fix runs the display suites (TerminalKeyResizeFrameTests, TerminalFrameOwnerMainThreadTests, TerminalSurfaceCommitSizeTests), and the held keys-stress A/B on cmux24 (S4 off vs on in one launch) gates cmux's default flip.
Changelog
none
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
In GHOSTTY_HOST_PRESENT_EMBEDDER mode, a frame of a new host size that was kept for the embedder's present no longer wakes the main thread: before, setSurfaceCallback dropped it after a main-queue trip, costing one wake per moving terminal and frame during a held key resize (cmux S4: +0.27 ms of main per motion frame). Metal.present now skips the main-queue block for that frame only, checking under the Core Animation lock and then fresh.mutex that the layer cannot show the frame in its bounds and the kept fresh frame is still this one (Metal.embedderShowsKeptFrame).
- Frames that still go through main: a first frame, a frame at the shown size, frames for bounds the embedder already set, frames of a size the host no longer has, and frames a host change dropped since their publish.
- A host change after the skip drops the kept frame, even back to an equal size (A→B→A); the renderer draws the new size at once while visible, and the layer keeps its last frame until then.
- Adds
HostFrameCounters.main_presentsto count frames handed to the main thread's present (not in the C counters struct, no ABI change).