renderer: a frame the embedder presents itself wakes no main-thread present

Merged
LA
2d ago
+268-7

Summary

  • With GHOSTTY_HOST_PRESENT_EMBEDDER, a frame that could not go to the screen off main went to the main queue (IOSurfaceLayer.setSurface), and setSurfaceCallback dropped 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.present skips the main-queue block for that frame only (Metal.embedderShowsKeptFrame). The decision runs under the Core Animation lock and then fresh.mutex, the order in which the main thread takes them in hostFrame's lock span. It needs both: the layer cannot show the frame in its bounds now (IOSurfaceLayer.embedderShowsFrameLocked, the test setSurfaceCallback makes), 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 sets resized, and drawsNow draws). While frames are held or the surface is hidden, the layer keeps its last frame. ghostty.h now says this, and says that after giving bounds without a present the embedder calls with present true when the answer is READY.
  • HostFrameCounters.main_presents counts the frames present hands 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: embedderShowsKeptFrame is 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):

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 in 3d209283a: that frame goes through main again.
  • FIX at 3d209283a: "published" only meant that the publish ran. Fixed in 17ccbcfea: 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 in cd11dc544 with the kept-frame rule, the renderer's draw at a resize, and a test of that sequence.
  • SHIP at cd11dc544 (two nits): the ghostty.h wording promised more than the code does (fixed in 5f97f6389, 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 --check and zig ast-check of 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
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_presents to count frames handed to the main thread's present (not in the C counters struct, no ABI change).