Skip to content

renderer: one render target per gesture (ghostty_surface_set_host_capacity) - #82

Open
lawrencecchen wants to merge 4 commits into
release/cmux-9ce9b7d0-e2from
fix/host-capacity-one-target-2
Open

lawrencecchen wants to merge 4 commits into
release/cmux-9ce9b7d0-e2from
fix/host-capacity-one-target-2

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Replaces #81: the red commit now fails on behavior (it compiles), and the landing gives the capacity-sized targets back.

Problem

cmux contract m14 says that in every frame, a moving terminal presents a frame drawn at its pane's size. So a held-key resize of 14 terminals makes about one present per moving terminal per frame (805-1022 per round; 84 with the switch off). That count is the contract, so each present must cost less.

Today each new host size runs FrameState.resize -> api.initTarget, which makes a new IOSurface and a new MTLTexture for each swap-chain frame at each new size. Time Profiler on cmux's held keys-stress, per round:

switch off switch on
IOSurfaceCreate 7 ms 57 ms
newTextureWithDescriptor:iosurface 15 ms 36 ms
IOSurface client calls about +36 ms
__mmap 30 ms 104 ms

Each new IOSurface set as layer contents is also a new surface for WindowServer.

The bound: 1 allocation per swap-chain target per gesture

With a capacity, each swap-chain target allocates at most once per gesture: once, to reach the capacity. With swap_chain_count 3, that is 3 per terminal per gesture, not 1. It cannot be 1 per terminal: of the 3 targets, one can be in flight (the GPU draws it, and its completion presents it) and one is on screen. Neither can be redrawn, so a second size needs a second target, and so on. The Lead's red criterion said "at most 1 surface allocation per terminal per gesture". The test asserts exactly 1 per swap-chain target (3 in total for 24 frames at new sizes; today it is 24).

Design

ghostty_surface_set_host_capacity(surface, width_px, height_px) (macOS; 0, 0 clears; default none, so behavior is unchanged without it):

  • Targets. While a capacity is set, Metal.fitTarget (called by FrameState.fit from drawFrame) keeps each target at least the capacity (Metal.targetSize). A frame at a new size that fits allocates nothing. A size past the capacity grows the target it is drawn into once, to that size (a target never shrinks while the capacity is set).
  • Drawn at the host size, top-left. Target.drawn_width/drawn_height is the frame's size. For a target larger than its frame, RenderPass.begin sets the viewport and the scissor to it; otherwise it makes no encoder call. The projection, the uniforms and the fragment positions already use the screen (host) size with the origin at the top-left. Custom shader textures keep the frame's size.
  • The layer shows only the drawn pixels. Every present goes through IOSurfaceLayer.showLocked, which sets contents and contentsRect (drawnRect) in one [CATransaction lock] span. The four present paths are setSurfaceSync, setSurface/setSurfaceCallback, setSurfaceOffMain and setSurfacePresented. contentsGravity stays top-left, so bounds ahead of the frame still show it at the top-left, unscaled. The y flip: for an IOSurface, a contentsRect y of 0 is the surface's bottom row (probe run 41970106668, and the CARenderer test below), so the top-left sub-rect is (0, 1 - h/H, w/W, h/H).
  • One source of truth for sizes. A frame carries its drawn size (IOSurfaceLayer.Drawn, Metal.fresh.width/height). The shown frame's size is read from the layer itself (shownSizeLocked = contentsRect x the IOSurface size, under the CA lock), so it cannot disagree with what the layer shows, and a contents change by the embedder still counts.
  • The landing gives memory back, and never waits (revised after review comment 6652213651). A clear arms a trim (Metal.trim_armed), marks the landing frame due (Metal.landing_due) and wakes the renderer thread (draw_now, one renderer_draw_now). In its next draw, Renderer.SwapChain.trim never waits: it gives back at once every target larger than the host size that no frame is drawing (Target.in_flight, set by Metal.frameCommitted at the commit and cleared by Metal.frameDone at the completion; Target.release leaves the target empty until the next frame drawn into it makes one of its size). That draw then draws the landing frame at the host size. A target whose frame is in flight goes back when that frame completes: Metal.frameDone runs on the GPU completion thread after the present (outside the Core Animation lock) and before the swap-chain slot is released, and releases the target if it is larger than the size the next frame draws at. No later draw is needed, so a terminal that never draws again (no output, a hidden cursor) keeps no capacity-sized target. The trim disarms only when no frame is in flight. A new capacity before then disarms it (the next gesture reuses the targets).
  • Counters, appended at the end of ghostty_surface_render_counters_s (C and Zig, same order): host_target_allocs (new render targets, all causes), host_target_bytes (the peak bytes of live target IOSurfaces, as agreed with frame-owner-s2), and host_target_live_bytes (live target IOSurface bytes now). Target.live_bytes lowers the live count when a target is freed. The swap chain now gets the API by pointer, so initTarget counts on the renderer's own Metal, not on a copy.
  • ghostty_surface_shown_frame_size reports the shown frame's drawn size (the IOSurface can be larger). See "cmux follow-up".

Every size comparison changed, and why

Each of these compared the IOSurface's size. With a capacity target that is the capacity, not the frame. Each now compares the drawn size:

Where Before Now
IOSurfaceLayer.changesShownSize shown surface vs new surface shown drawn size vs the frame's drawn size
IOSurfaceLayer.shownSize (used by Metal.hostFrame, displayWhileEmbedderPresents, the C API) shown surface size shownSizeLocked (contentsRect x surface)
IOSurfaceLayer.setSurfaceOffMain host check surface vs host size drawn size vs host size
IOSurfaceLayer.offMainDecisionLocked shown surface vs frame surface shown drawn vs frame drawn
IOSurfaceLayer.embedderDrops (and embedderShowsFrameLocked) shown surface vs new surface; bounds vs new surface drawn sizes for both
IOSurfaceLayer.setSurfaceCallback wrong-size discard host/bounds vs surface host/bounds vs drawn size
Metal.takeFresh, Metal.hasFresh fresh surface size fresh drawn size (fresh.width/height)
Metal.presentHostSizedWait shown surface vs host (unlocked read) layer.shownSize() (drawn, locked) vs host
Metal.embedderShowsKeptFrame passed the surface passes the frame (sizes drawn; identity still on the surface)
drawFrame target check target size == screen size FrameState.fit -> Metal.targetSize (fits, with a capacity)

Unchanged: Metal.setHostDrawableSize only checks whether contents is null. Overlay.zig and the overlay check in generic.zig read z2d surfaces, not IOSurfaces.

Default-path changes (no capacity, cmux with the switch off)

  • setSurfaceSync now takes [CATransaction lock] in renderer mode too. Before, it took the lock only while the embedder presents host sizes. Now it always sets contents and contentsRect together under the lock, because an off-main present reads both under it. It never commits or flushes under the lock (the lock is recursive). It runs on the main thread (display pass, ghostty_surface_draw) and in the completion block of a sync draw.
  • Every present also writes contentsRect, but only when it differs (showLocked compares first). With no capacity it stays the unit rect, so no property is written.
  • The swap chain gets the API by pointer, and FrameState.fit replaces the size check in drawFrame with the same rule as before when no capacity is set. SwapChain.trim returns at once while no trim is armed.

Tests (red on behavior, then green)

All new tests are named host present: (the workflow's existing filter). The Metal.zig ones use a real Metal device (Device.initForTest) and the renderer's real swap chain (Renderer.SwapChain, nextFrame, FrameState.fit, trim, releaseFrame), without the GPU (testDraw presents and completes each frame).

Test Red (stubs, today's behavior) Green
a held-key resize makes one render target per swap-chain frame with a host capacity: 24 frames at new sizes inside the capacity; each drawn at its host size and shown exactly (contentsRect, top-left); past the capacity, the target grows once, then nothing expected 3, found 24 pass
clearing the host capacity at the landing leaves one target, at the pane's size: capacity set, 12 sizes, clear, one landing draw: live bytes = the on-screen target at the pane's size, both spares empty expected 3404800, found 13529600 pass
the landing gives back the idle targets at once and never waits for a frame in flight (review F2): the trim returns in < 4 ms with a frame in flight and gives back the idle targets; that frame's completion gives back its own, no draw first pair: n/a; second pair: the landing trim took 9043417 ns, TestUnexpectedResult pass (12417 ns)
a frame in flight at the landing gives back its capacity target when it completes, with no other draw (review F1) second pair: expected 3404800, found 14694400 pass
without a host capacity each new size makes a target of exactly that size (the guard; nothing given back) pass pass
a frame in a capacity target is drawn only in its top-left drawn pixels (real render pass, bg_color pipeline, IOSurface read back) surface pixel (6, 0): alpha 255 pass
a capacity frame shows only its drawn pixels, top-left (Core Animation renders the layer with CARenderer; proves the y flip) expected 6, found 8 pass
a frame that fills its surface keeps the whole surface as its contents rect expected 120, found 160 pass
an off-main frame of a capacity target compares its drawn size, not its surface's TestUnexpectedResult pass

Commit 1 is the red: it adds the C API declarations, the entry points, ghostty_surface_shown_frame_size (returning today's IOSurface size), the counters (they count), the seams with today's behavior (setHostCapacity does nothing, fitTarget uses the exact-size rule, SwapChain.trim gives nothing back, the layer still shows and compares the whole IOSurface), and the tests. Commit 2 adds the behavior.

Runs (fork CI, workflow cut to the host present: filter; scratch branches never merge)

  • Red, scratch/host-capacity-red-3 = red commit 2460a557d + the cut: run 41977016390: Build Summary: 96/98 steps succeeded (1 failed); 112/119 tests passed (7 failed). It compiles; the 7 failures are the behavior lines in the table. All 33 existing host present: tests pass in it.
  • Green, scratch/host-capacity-green-3 = fix commit 4a4182c22 + the cut: run 41977364169: Build Summary: 98/98 steps succeeded; 119/119 tests passed (the 33 existing and the 8 new host present: tests) and lib-vt 46/46 steps succeeded; 97/97 tests passed.
  • Probe 41970106668: the CI runner has a Metal device ("Apple Paravirtual device") and CARenderer renders there. For an IOSurface, contentsRect y = 0 selects the surface's bottom rows.

Review fixes (comment 6652213651): runs

  • Red, scratch/host-capacity-red-4 = red commit 47c05f27c (test only; Metal.frameCommitted/frameDone are no-op seams called from Frame.zig) + the cut: run 41987264371: 118/120 tests passed (2 failed). The failures: the landing trim took 9043417 ns with a frame in flight (the 8 ms wait), and expected 3404800, found 14694400 (two capacity-sized targets kept after the landing with a frame in flight).
  • Green, scratch/host-capacity-green-4 = fix commit f867bd2bf + the cut: run 41987511810: 98/98 steps succeeded; 120/120 tests passed, lib-vt 97/97. The trim took 12417 ns; live bytes after the landing with a frame in flight: 3404800 (the landing target at the pane's size), peak 19801600.

Live bytes (the landing test, 1000 x 700 px pane, capacity 2000 x 700, BGRA)

red (today) green
live target bytes during the gesture 14,873,600 16,934,400 (3 targets at the capacity)
live target bytes after the landing 13,529,600 (the 2 spares keep their sizes) 3,404,800 = the on-screen target at the pane's size (1200 x 700)
peak (host_target_bytes) 19,443,200 19,801,600

What the tests do not cover: drawFrame itself, which needs a full renderer (font grid, terminal state). Its calls to FrameState.fit and SwapChain.trim were read, not run. Also not covered: the renderer thread wake on clear (draw_now, read only); the terminal's own cell pipelines at a capacity size (only bg_color is drawn); custom shaders with a capacity; and the C entry points (compile-checked only). No cmux end-to-end run.

cmux follow-up

cmux reads the shown frame from layer.contents (TerminalSurfaceView.presentedFrame, an IOSurface) and takes its width/height as the frame's pixel size. With a capacity, that is the capacity. cmux must:

  1. Call ghostty_surface_set_host_capacity(surface, w, h) at the gesture's start for each terminal that will move, with the largest pixel size the gesture can give it. Call ghostty_surface_set_host_capacity(surface, 0, 0) at the landing, before the landing size's ghostty_surface_set_size, so the landing frame is drawn in a target of its own size and the spares are given back.
  2. Read the shown frame's size with ghostty_surface_shown_frame_size, not presentedFrame.width/height: in TerminalSurfaceView+MotionSample.swift (shown?.width, shown?.height) and in TerminalSurfaceCommitSizeTests (sample()'s frame.width/height and shownPixels).
  3. Anything that shows or pictures presentedFrame elsewhere must show only the drawn part. TerminalFrameHold.HeldFrameView.show copies contents, gravity and scale from the source layer; it must also copy contentsRect. TerminalLastFrame / showLastFrame (a kept IOSurface set as another layer's contents) must carry the contentsRect with it. TerminalSnapshotRenderer (CIImage(ioSurface:)) must crop to the drawn rect (CIImage's origin is bottom-left: (0, H - h, w, h)). After the landing frame is drawn, the shown frame is whole again.
  4. Optional: read host_target_live_bytes and host_target_allocs in the held-key bench to confirm 3 allocations per terminal per gesture and the landing's memory.
  5. layer.contents = nil (a purged frame) needs no change.

Risks

  • Memory during a gesture. Each moving terminal keeps 3 targets at the capacity (today about 3 at the current size, plus the churn). A capacity much larger than the gesture's real sizes costs memory until the landing; host_target_bytes reports the peak. At the landing, the idle targets go back in the landing draw and a target in flight goes back when its frame completes.
  • Completion thread. Metal.frameDone now releases a target (an IOSurface and a texture) on the GPU completion thread after a clear, outside the CA lock. It calls surfaceSize there; for a surface that is not host-sized that reads the layer's bounds off the main thread (the renderer thread already does). Target.in_flight hands the target between the threads (release/acquire); the renderer thread never touches a target in flight.
  • One more frame at the landing. A clear makes the renderer thread draw one frame at the host size (one renderer_draw_now), even when nothing else changed.
  • GPU bandwidth. The render pass clears the whole target (load action clear), not only the drawn part, so there is a little more bandwidth per frame while a capacity is set.
  • The lock. setSurfaceSync now takes [CATransaction lock] in the default mode too. It never commits under it.
  • Embedder contents. An embedder that sets layer.contents itself while the layer's contentsRect is a sub-rect would make the read size wrong (see follow-up 3).
  • Counter scope. host_target_allocs counts all causes, including the 3 1x1 placeholder targets of every new swap chain.
  • Not ported to the sync line (release/cmux-sync-20261104).

Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Reduces held-key resize cost on macOS by giving each swap-chain render target one allocation per gesture instead of one per frame per new size. A new ghostty_surface_set_host_capacity(surface, w, h) keeps every swap-chain target at least the gesture's capacity, so frames at new sizes that fit allocate nothing, and 0, 0 clears the capacity and gives the memory back at the landing. Without the call, behavior is unchanged, including cmux with its switch off.

  • Each swap-chain target (3 per surface) allocates at most once per gesture: one can be in flight and one is on screen, and neither can be redrawn, so it cannot be one per surface.
  • Frames are drawn at the host size from the target's top-left; the render pass sets viewport and scissor, and the layer sets contents and contentsRect together under the Core Animation lock at every present, with the IOSurface y flip.
  • Clearing the capacity wakes the renderer thread, which never waits: it releases at once every target larger than the host size that no frame is drawing and draws the landing frame at the host size; a target whose frame is in flight is released when that frame completes, with no further draw.
  • ghostty_surface_shown_frame_size reports the drawn frame's size (the surface can be larger), the render counters gain host_target_allocs, host_target_bytes (peak), and host_target_live_bytes, and all present-mode size comparisons now use drawn sizes instead of the surface's.

Migration

  • cmux should set the capacity at a gesture's start, clear it at the landing before the final ghostty_surface_set_size, and read the shown size via ghostty_surface_shown_frame_size instead of presentedFrame.width/height.
  • Embedders that set layer.contents themselves or copy contents properties into other layers must carry contentsRect while a capacity is set.

Written for commit 4a4182c. Summary will update on new commits.

View guided diff Turn on auto-fix

…ain frame, and the landing gives them back (red)

cmux FRAME OWNER S4 (contract m14): a held-key resize presents one frame per
moving terminal per frame, and each new size made a new IOSurface and
MTLTexture per frame (FrameState.resize -> initTarget). Time Profiler on the
held keys-stress, switch on: IOSurfaceCreate 7 -> 57 ms,
newTextureWithDescriptor:iosurface 15 -> 36 ms, __mmap 30 -> 104 ms per round.

The API and the seams, with today's behavior, and the tests (red on behavior):
- ghostty.h: ghostty_surface_set_host_capacity, ghostty_surface_shown_frame_size,
  and host_target_allocs, host_target_bytes (peak), host_target_live_bytes at
  the end of ghostty_surface_render_counters_s. The counters count (Metal
  initTarget, Target.deinit).
- Stubs: Metal.setHostCapacity does nothing; ghostty_surface_shown_frame_size
  returns the shown IOSurface's size (today's); Metal.fitTarget keeps today's
  rule (a target of exactly the frame's size); Renderer.SwapChain.trim gives
  nothing back. IOSurfaceLayer takes frames with their drawn size
  (IOSurfaceLayer.Drawn) and still shows and compares the whole IOSurface.
- generic: drawFrameLocked gives the frame its target through FrameState.fit
  and calls SwapChain.trim; the swap chain gets the API by pointer (Metal
  counts its targets); SwapChain is public for the tests.

Tests (`host present: `, real Metal device, real swap chain without the GPU):
- 24 frames at new sizes inside the capacity: one allocation per swap-chain
  target for the gesture (red: 24, one per frame); each frame drawn at its host
  size and shown exactly (contentsRect, top-left); past the capacity, the
  target grows once.
- the landing: clearing the capacity leaves one target, at the pane's size
  (red: the two spares keep their sizes);
- a frame in flight keeps its target until it completes (red: never given back);
- the guard: without a capacity each new size makes one target of exactly that
  size and nothing is given back (green in red too);
- a real render pass into a capacity target draws only the top-left drawn
  pixels (red: the whole target);
- Core Animation renders the layer (CARenderer): a capacity frame shows only
  its drawn pixels, top-left, unscaled, the right way up (red: the whole surface);
- the off-main and embedder size rules compare drawn sizes.

Test helpers: Device.initForTest (the test binary does not link Metal; its
entry point is looked up at run time) and the IOSurface lock binding, which
ignored IOSurfaceLock's result and did not compile once used.
…acity)

A held-key resize makes one present per moving terminal per frame (cmux
contract m14), and each new host size made a new IOSurface and MTLTexture per
frame (FrameState.resize -> initTarget). With
ghostty_surface_set_host_capacity(w, h) (macOS; 0, 0 clears) every swap-chain
target keeps one IOSurface at least that large (Metal.fitTarget, targetSize):
a frame at a new size that fits allocates nothing. One allocation per
swap-chain target per gesture (3 per surface): a target in flight or on
screen cannot be redrawn, so it cannot be one per surface. A size past the
capacity grows the target it is drawn into once, to that size. Without a
capacity nothing changes.

- Each frame is drawn at the host size from the target's top-left corner:
  Target.drawn_width/drawn_height; the render pass sets viewport and scissor
  to it (RenderPass.begin) for a target larger than its frame.
- The layer shows only those pixels: IOSurfaceLayer.showLocked sets contents
  and contentsRect (drawnRect; y runs up for an IOSurface, as the CARenderer
  test proves) in one Core Animation lock span at every present.
  setSurfaceSync now takes the lock in renderer mode too.
- Every size rule of the present modes compares drawn sizes, never a
  capacity surface's: frames carry their drawn size (IOSurfaceLayer.Drawn,
  Metal.fresh.width/height), and the shown size is read from the layer
  itself (shownSizeLocked: contentsRect x the IOSurface size).
- The landing: clearing the capacity arms a trim and wakes the renderer
  thread (draw_now). Renderer.SwapChain.trim waits, at most 8 ms, until no
  frame is in flight, gives back every target larger than the host size
  (Target.release: empty until the next frame drawn into it makes one of its
  size), and a frame is then drawn at the host size, so the layer stops
  showing a capacity surface and memory returns to the pane's size even when
  nothing else draws. Never in the main thread's sync draws.
- ghostty_surface_shown_frame_size reports the shown frame's drawn size.
@coderabbitai

coderabbitai Bot commented Nov 5, 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: e88cc6f4-cb1c-4dfc-8d27-185194bcf557

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

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent): FIX at 4a4182c

I read the full diff (base release/cmux-9ce9b7d0-e2...head). I did not build or run zig. CI evidence: red run 41977016390 and green run 41977364169 ran on scratch commits 85df576 and 9abe93d. I checked that each scratch tree is the PR commit plus a change to .github/workflows/next-xcframework.yml only (the filter narrowed to host present: ). Red: 112/119, with 7 behavior failures (expected 3, found 24; expected 3404800, found 13529600; alpha expected 0, found 255; shown width expected 6, found 8; and others). Green: 119/119 and 97/97. The head's own CI run (41979853449) does not run host present:, so the scratch runs are the only test evidence. The trees match, so that evidence holds.

Blocking

F1. A trim that times out never tries again, so a terminal that draws nothing more keeps its capacity-sized spare targets. src/renderer/generic.zig:499: if (!self.waitIdle(trim_wait_ns)) return false; leaves trim_armed set. The only retry is "the next draw". The wake from ghostty_surface_set_host_capacity(0,0) (src/apprt/embedded.zig, draw_now.notify) gives one draw only. If that trim times out, trimmed is false, so that draw can also skip (needs_redraw false) and nothing drives a second attempt. The timeout is likely at the landing:

  • Frame.zig:82-90 presents before frameCompleted releases the semaphore.
  • That present takes [CATransaction lock] (setSurfaceOffMain, embedderShowsKeptFrame).
  • During main's landing commit, which holds that lock while it commits, in-flight completions stall, and waitIdle waits for all 3 frames.

This is the case the PR says it covers. NEXT.md:127 says "memory returns to the pane's size even when nothing else draws", and the m14 condition says "a terminal that draws nothing after the landing kept two window-sized IOSurfaces". As written, it still can. The ghostty.h text (line 1637, "else it tries again at its next draw") is accurate but does not promise a next draw.

F2. The trim waits (up to 8 ms) on the renderer thread before the landing frame. generic.zig:2156 runs the trim at the start of the first draw after the clear. During a held-key gesture, frames are almost always in flight at that moment, so this draw blocks for up to 8 ms, while holding draw_mutex, until all 3 frames drain. The landing resize message that main sends right after (ghostty.h tells the host to clear before set_size) is then handled late, and the embedder's hostFrame stays PENDING longer. The landing is on the < 10 ms budget path. It is not the main thread, but this is new latency on the gesture's most visible frame. Measure it, or remove it.

Suggested fix, which closes both: do not wait.

  • In trim, release only targets not in flight. The in-flight frames are the last buf_count - permits indexes before frame_index, and the one the layer shows can go, because the layer holds its own reference.
  • Keep trim_armed while any target stays.
  • Have the completion path (frameCompleted, after releaseFrame) notify draw_now while trimArmed() and a larger target remains.

Then the release does not depend on a later draw and never delays the landing frame. Add a test: clear while a frame is in flight, complete it, and with no other draw the targets are given back. The current test "gives back no target while a frame is in flight" calls trim again by hand, so it does not prove this.

Checked, no problem found

  1. contents and contentsRect. Every non-nil contents write goes through showLocked (IOSurfaceLayer.zig:182), under the CA lock:

    • setSurfaceSync: 220
    • setSurfaceOffMain: 353, before the unlock, commit after the unlock
    • setSurfacePresented: 303
    • setSurfaceCallback: 490
    • detachFromHostCallback sets nil only.

    shownSizeLocked (148) reads the rect and the surface under the same lock, so no reader sees a new surface with an old rect. The cmux-side writes I found to a Ghostty layer are nil only (TerminalSurfaceView.swift:579,590). Implicit animation of contentsRect is off, because the subclass returns nil from actionForKey:.

  2. Size comparisons. All production comparisons use drawn sizes: setSurfaceOffMain, offMainDecisionLocked, embedderDrops, changesShownSize, setSurfaceCallback, takeFresh/hasFresh (via fresh.width/height), presentHostSizedWait/hostFrame (via shownSize). The remaining getWidth/getHeight uses are correct: Drawn.whole, drawnRect, shownSizeLocked, test helpers (testShownWidth, testTarget, used only with whole surfaces), and Overlay/GTK (not IOSurfaces of targets).

  3. Threading. ghostty_surface_set_host_capacity on main does atomics and an xev.Async.notify only, so it never blocks. The trim runs only when !sync, and Thread.drawFrame always passes false, so it runs on the renderer thread only. Main's sync draws (ghostty_surface_draw, display pass) skip it. Main can wait on draw_mutex behind the trim only in those rare sync draws, the same class as the current nextFrame wait. Lock order: draw_mutex -> semaphore mutex, the same as nextFrame. No CA lock, fresh.mutex or terminal lock is held in the trim. No commit or flush under the CA lock.

  4. y flip and gravity. drawnRect (161, contents_rect_y_up at 176) is a unit rect, so contentsScale does not change it. With top-left gravity, the drawn sub-rect shows at drawn/scale points. The same function serves the default and embedder paths. The CARenderer test discriminates: with the wrong sign it shows rows 4-7 (red 20) and fails. It proves an unflipped root layer at scale 1 only. cmux's TerminalSurfaceView.isFlipped is false. Before cmux turns this on, take one real capture with a capacity set.

  5. Memory and counters. initTarget counts allocs, live and peak. Target.deinit subtracts once and is safe on an empty target. release clears live_bytes. A replaced target counts in the peak until it is freed (documented). After a successful trim, the first frame draws into an empty target at the exact size, and the off-main present swaps the layer to it (same drawn size). Correct, except for F1.

  6. Tests. The red commit compiles and fails on behavior. The one new test that passes on red ("without a host capacity ...") guards the default path, as it should. The 33 existing host present: tests pass on green. Not covered: the draw_now wake and the drawFrameLocked order (!sync and trim, trimmed in needs_redraw). testDraw re-implements that order.

  7. C API. The three counters are at the end, in the same order in ghostty.h and CAPI.RenderCounters (I diffed the field lists: identical). ghostty_surface_shown_frame_size gives the drawn pixel size of the shown frame and returns false when nothing is shown. The docs match the code. Nit: host_target_bytes holds a peak, and host_target_peak_bytes would read better.

  8. Other.

    • Viewport and scissor apply to .target attachments only, so the final custom-shader pass draws into the drawn sub-rect while iResolution keeps the frame size. Correct.
    • The clear load action clears the whole capacity target each frame (small GPU cost).
    • The OpenGL/GTK build is not compiled in this CI. The comptime @hasDecl guards look right, but I did not run them.
    • Follow-up for the cmux PR that turns this on: readers that treat the layer's IOSurface as the frame (TerminalSnapshot.presentedFrame, TerminalFrameHold HeldFrameView, TerminalLastFrame, TerminalSurfaceView+MotionSample) will see capacity-sized surfaces. They must use ghostty_surface_shown_frame_size or copy contentsRect.

Review (independent subagent): FIX at 4a4182c

…back its capacity target when it completes (red)

Review of #82 (comment 6652213651), two findings in the clear/trim path:
- F1: when the trim's wait for frames in flight timed out, the targets stayed
  for "the next draw", but the clear wakes the renderer for one draw only: a
  terminal that draws nothing more kept its capacity-sized targets.
- F2: the trim waited up to 8 ms under the draw mutex at the start of the
  landing draw.

Seams (no behavior yet): Metal.frameCommitted (Frame.complete, before the
commit) and Metal.frameDone (Frame.bufferCompleted, after the present and
before the swap-chain slot is released). The test helpers commit and complete
frames through them, in commit order.

Tests:
- the landing trim with a frame in flight returns at once (< 4 ms) and gives
  back the idle targets; the frame's completion gives back its own, with no
  draw (red: the trim waits 8 ms and gives nothing back);
- a frame of the gesture in flight at the clear, then the landing frame, then
  both complete, no other draw: live target bytes are the landing target at
  the pane's size (red: the two capacity-sized targets stay).
The test that called the trim again by hand is replaced.
…ts capacity target when it completes

Review of #82 (comment 6652213651):
- F2: SwapChain.trim no longer waits (the 8 ms waitIdle under the draw mutex
  on the landing frame is gone). It gives back at once every target larger
  than the host size that no frame is drawing (Target.in_flight, set by
  Metal.frameCommitted at the commit, cleared by Metal.frameDone at the
  completion).
- F1: a target whose frame is in flight at the landing goes back when that
  frame completes: Metal.frameDone, on the GPU completion thread after the
  present (outside the Core Animation lock) and before the swap-chain slot is
  released, releases it while a cleared capacity's trim is armed and it is
  larger than the size the next frame draws at. No later draw is needed. The
  trim disarms only when no frame is in flight.
- The landing frame is drawn because of Metal.landing_due (once per clear),
  no longer because the trim gave something back. The trim and the landing
  also run in the main thread's sync draws (nothing waits any more).
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review fixes for comment 6652213651 pushed as new commits (no force-push): red 47c05f27c (run 41987264371: the landing trim took 9.04 ms with a frame in flight; live bytes 14694400, expected 3404800), fix f867bd2bf (run 41987511810: 120/120, trim 12.4 µs, live bytes 3404800). F2: SwapChain.trim never waits; idle targets go back at once. F1: a target in flight goes back in Metal.frameDone when its frame completes, with no further draw. The landing frame is drawn from Metal.landing_due. host_target_bytes keeps its name (I could not confirm frame-owner-s2 does not use it yet).

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Heads-up from the upstream sync (#74): I am landing #74 into main now. It contains upstream Ghostty c7f68a7 plus release/cmux-9ce9b7d0-e2 up to 12ec155 (#75 #77 #79) and the sync-line ports #76 #78 #80 (release/cmux-sync-20261104 = 30db97a). After it lands, please open renderer fixes against main (or release/cmux-sync-20261104), not release/cmux-9ce9b7d0-e2, so the cmux-next pin never goes back to a line without the CVDisplayLinkStop freeze fix (cmux #35300). Note: I briefly force-pushed release/cmux-sync-20261104 over 706c696 and restored it within minutes (706c696 is an ancestor of 30db97a); nothing was lost.

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