From 30b8490a1d6a4cb8adb4986fb7e71b0eabc26d7f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:53 +0000 Subject: [PATCH 1/8] renderer: test that an embedder that presents host sizes gets every new-size frame first (red) cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the Core Animation transaction that shows the frame drawn at the new size, or keeps both the old bounds and the old frame. Today a host-sized surface shows a new-size frame itself: setHostDrawableSize asks for a display pass, and the renderer sends the frame to main, where setSurfaceCallback takes any frame of the host size, whatever the view's bounds. The frame then lands in the old bounds in a later main-thread turn. These tests run on macOS with a real IOSurfaceLayer and real IOSurfaces (no GPU). Seams with no effect yet: Metal.setEmbedderPresents and IOSurfaceLayer.setEmbedderPresents (the mode), Metal.hostFrame (the embedder's query and present; returns .none) and apprt.HostFrame. With the embedder presenting sizes: - a new host size asks for no display pass, - the renderer does not show a frame of a new size; it keeps it as the fresh frame, - a main-thread present of a new size keeps the shown frame, - hostFrame reports READY, PRESENTED (the frame is the layer's contents in the caller's transaction), SHOWN, PENDING (counted late only when it presents), HELD and HIDDEN, - a frame that reaches the main thread late never replaces the frame the embedder showed (present sequence). They fail until the renderer leaves new sizes to the embedder. Controls pass now and must stay green: the default mode shows a new size as before and asks for a display pass, a first frame presents itself, a frame at the shown size still goes off main, a new-size frame that fits bounds the embedder already set is shown, and going back to the renderer shows a frame drawn for the host size. --- src/apprt.zig | 1 + src/apprt/structs.zig | 26 ++ src/renderer.zig | 6 + src/renderer/Metal.zig | 336 ++++++++++++++++++++++++++ src/renderer/metal/IOSurfaceLayer.zig | 103 ++++++++ 5 files changed, 472 insertions(+) diff --git a/src/apprt.zig b/src/apprt.zig index 504c9d454..c30f658ac 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -39,6 +39,7 @@ pub const SurfaceIoMode = structs.SurfaceIoMode; pub const SurfaceGrid = structs.SurfaceGrid; pub const SurfaceSnapshotPhase = structs.SurfaceSnapshotPhase; pub const SurfaceLocalHistoryResult = structs.SurfaceLocalHistoryResult; +pub const HostFrame = structs.HostFrame; /// The implementation to use for the app runtime. This is comptime chosen /// so that every build has exactly one application runtime implementation. diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1d27ebaf6..8f08163d8 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -322,6 +322,32 @@ pub const SurfaceLocalHistoryResult = enum(c_int) { mismatch = 1, }; +/// What an embedder that presents host sizes can show for a host-sized macOS +/// surface (ghostty_surface_set_host_present, ghostty_surface_host_frame). +/// +/// If this is changed, you must also update ghostty.h +/// (ghostty_host_frame_e). +pub const HostFrame = enum(c_int) { + /// Nothing to keep: not host-sized, not macOS, or no frame shown yet + /// (the first frame presents itself). + none = 0, + /// The surface does not draw (occluded): no frame comes until it is + /// shown again. + hidden = 1, + /// The layer shows a frame at the host size. + shown = 2, + /// A query: the renderer drew the host size's frame, not shown yet. + ready = 3, + /// That frame is the layer's contents now, in the caller's Core + /// Animation transaction. + presented = 4, + /// Not drawn yet: the layer keeps the frame it shows. + pending = 5, + /// Frame updates are held (synchronized output, a restore): no frame + /// can come now, and the layer keeps the frame it shows. + held = 6, +}; + test "ghostty.h SurfaceLocalHistoryResult" { try @import("../lib/enum.zig").checkGhosttyHEnum( SurfaceLocalHistoryResult, diff --git a/src/renderer.zig b/src/renderer.zig index 780a495fb..86be450e8 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -69,4 +69,10 @@ test { _ = Thread; _ = State; _ = @import("renderer/mailbox.zig"); + + // The host-sized macOS present paths (Core Animation and IOSurface, no GPU). + if (comptime @import("builtin").os.tag == .macos) { + _ = @import("renderer/Metal.zig"); + _ = @import("renderer/metal/IOSurfaceLayer.zig"); + } } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 7ec246432..1c1077c14 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -81,6 +81,13 @@ frames_held: std.atomic.Value(bool) = .init(false), /// When the host size last changed (`setHostDrawableSize`). Main thread. host_size_set_at: ?std.Io.Timestamp = null, +/// macOS host-sized surfaces, opt-in (`ghostty_surface_set_host_present`): +/// the embedder, not the renderer, shows a frame whose size differs from the +/// frame the layer shows, so it can change the view's bounds in the same +/// Core Animation transaction. Main thread writes; the GPU completion thread +/// and the display pass read. +embedder_presents: std.atomic.Value(bool) = .init(false), + /// Numbers presented frames in completion order, so a frame the main thread /// presents late never replaces a newer one presented off main. GPU /// completion thread. @@ -281,6 +288,16 @@ pub fn setHostDrawableSize(self: *Metal, enabled: bool, width_px: u32, height_px self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{}); } +/// macOS host-sized surfaces (`ghostty_surface_set_host_present`): with +/// `enabled`, the embedder shows each frame of a new host size itself +/// (`hostFrame`), in the Core Animation transaction that changes the view's +/// bounds. Main thread. +pub fn setEmbedderPresents(self: *Metal, enabled: bool) void { + if (comptime builtin.os.tag != .macos) return; + self.embedder_presents.store(enabled, .release); + self.layer.setEmbedderPresents(enabled); +} + /// iOS only: detach the IOSurfaceLayer from the embedder's view before /// the renderer's memory is freed. /// @@ -528,6 +545,14 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { return true; } +/// What the embedder can show for the host size (`ghostty_surface_host_frame`). +/// Main thread. +pub fn hostFrame(self: *Metal, show: bool) apprt.HostFrame { + _ = self; + _ = show; + return .none; +} + /// The host size generation a frame drawn now belongs to (Target.size_generation). pub fn sizeGeneration(self: *const Metal) u64 { return self.fresh.generation.load(.acquire); @@ -712,3 +737,314 @@ pub inline fn beginFrame( ) !Frame { return try Frame.begin(.{ .queue = self.queue }, renderer, target); } + +fn testDevice() Device { + var device: Device = undefined; + device.max_texture_size = 16384; + return device; +} + +/// A host-sized Metal API with a real layer and no GPU: the paths under test +/// touch only the layer, the host size and the fresh frame. +fn testMetal(device: *const Device) !Metal { + return .{ + .layer = try IOSurfaceLayer.init(), + .device = device, + .queue = undefined, + .blending = .native, + }; +} + +fn testDeinit(self: *Metal) void { + self.dropFresh(); + self.layer.release(); +} + +fn testSurface(width: c_int, height: c_int) !*IOSurface { + return try IOSurface.init(.{ + .width = width, + .height = height, + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = null, + }); +} + +/// The width of the frame the layer shows; null while it shows none. +fn testShownWidth(self: *const Metal) ?usize { + const shown = self.layer.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return surface.getWidth(); +} + +/// A frame the renderer drew at `surface`'s size, with cells built for it. +fn testTarget(self: *const Metal, surface: *IOSurface) Target { + return .{ + .surface = surface, + .texture = undefined, + .width = surface.getWidth(), + .height = surface.getHeight(), + .cells_fresh = true, + .size_generation = self.sizeGeneration(), + }; +} + +fn testIsMainThread() bool { + return objc.getClass("NSThread").?.msgSend(bool, "isMainThread", .{}); +} + +test "host present: a new host size asks no display pass while the embedder presents sizes" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.layer.layer.msgSend(void, objc.sel("displayIfNeeded"), .{}); + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); + + metal.setHostDrawableSize(true, 120, 60); + // No display pass: Core Animation never presents (or waits for) the new + // size's frame on its own; the embedder shows it with its bounds. + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); +} + +test "host present: a new host size asks a display pass by default" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.layer.layer.msgSend(void, objc.sel("displayIfNeeded"), .{}); + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); + + metal.setHostDrawableSize(true, 120, 60); + try testing.expect(metal.layer.layer.getProperty(bool, "needsDisplay")); +} + +test "host present: the renderer never shows a frame of a new host size while the embedder presents sizes" { + const testing = std.testing; + // The main-thread present runs at once only on the main thread. + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // The renderer completed the frame of the new size. + try metal.present(testTarget(&metal, drawn), false); + // The layer keeps the shown frame and its size; the new frame waits, as + // the fresh frame, for the embedder's present. + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + try testing.expectEqual(@as(?*IOSurface, drawn), metal.fresh.surface); +} + +test "host present: the renderer shows a frame of a new host size by default" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setHostDrawableSize(true, 120, 60); + + try metal.present(testTarget(&metal, drawn), false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} + +test "host present: a first frame presents itself while the embedder presents sizes" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // Nothing is shown yet: there is no size to keep. + try metal.present(testTarget(&metal, drawn), false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} + +test "host present: a frame at the shown size goes to the screen off main while the embedder presents sizes" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(120, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // A content-only frame (a terminal echo) needs no geometry change. + try metal.present(testTarget(&metal, drawn), false); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(drawn)), contents); + try testing.expectEqual(@as(u64, 1), metal.counters.off_main_presents.load(.monotonic)); +} + +test "host present: hostFrame reports the frame drawn for a new host size and shows it in the caller's transaction" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + + // A query shows nothing. + try testing.expectEqual(HostFrame.ready, metal.hostFrame(false)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + // A present shows the frame now; the embedder gives the view its new + // bounds in the same transaction. + try testing.expectEqual(HostFrame.presented, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); + try testing.expectEqual(@as(u64, 1), metal.counters.fresh_presents.load(.monotonic)); + // Nothing is left to show for this size. + try testing.expectEqual(HostFrame.shown, metal.hostFrame(true)); +} + +test "host present: hostFrame keeps the shown frame until the renderer drew the new host size" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + try testing.expectEqual(HostFrame.pending, metal.hostFrame(false)); + try testing.expectEqual(HostFrame.pending, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + // Only a present that kept the shown frame counts as late. + try testing.expectEqual(@as(u64, 1), metal.counters.late.load(.monotonic)); +} + +test "host present: hostFrame reports nothing to keep, held frames and a hidden surface" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + + // Not host-sized: the target follows the bounds. + try testing.expectEqual(HostFrame.none, metal.hostFrame(true)); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + // Nothing shown yet: the first frame presents itself. + try testing.expectEqual(HostFrame.none, metal.hostFrame(true)); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + // Synchronized output or a restore holds frame updates: no frame can come now. + metal.setFramesHeld(true); + try testing.expectEqual(HostFrame.held, metal.hostFrame(false)); + try testing.expectEqual(HostFrame.held, metal.hostFrame(true)); + metal.setFramesHeld(false); + // A hidden surface draws nothing until it is shown again. + metal.setVisibleHint(false); + try testing.expectEqual(HostFrame.hidden, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); +} + +test "host present: a frame that reaches the main thread late never replaces the one the embedder showed" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const stale = try testSurface(120, 60); + defer stale.release(); + const fresh = try testSurface(120, 60); + defer fresh.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // The renderer drew the new size twice: first with the cells of the old + // size (present sequence 1), then with cells built for it (sequence 2). + var stale_target = testTarget(&metal, stale); + stale_target.cells_fresh = false; + try metal.present(stale_target, false); + try metal.present(testTarget(&metal, fresh), false); + try testing.expectEqual(HostFrame.presented, metal.hostFrame(true)); + + // The older frame's main-thread present runs after that (a block queued + // before it): it must not replace the newer frame. + try metal.layer.setSurface(stale, 1); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(fresh)), contents); +} + +test "host present: back to the renderer, a frame drawn for the host size goes to the screen" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + + metal.setEmbedderPresents(false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index a0755e364..8fa586f11 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -37,6 +37,7 @@ pub fn init() !IOSurfaceLayer { layer.setInstanceVariable("display_ctx", .{ .value = null }); layer.setInstanceVariable("host_size", .{ .value = null }); layer.setInstanceVariable("present_seq", .{ .value = null }); + layer.setInstanceVariable("embedder_presents", .{ .value = null }); return .{ .layer = layer }; } @@ -93,6 +94,16 @@ fn hostSize(layer: objc.Object) u64 { return @as(u64, @intFromPtr(layer.getInstanceVariable("host_size").value)) >> 3; } +/// Records whether the embedder presents the frames of a new host size +/// (`Metal.setEmbedderPresents`, ghostty_surface_set_host_present). Main +/// thread. +pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { + // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, + // never retained or released. + const stored: usize = @as(usize, @intFromBool(enabled)) << 3; + self.layer.setInstanceVariable("embedder_presents", .{ .value = @ptrFromInt(stored) }); +} + /// Sets the layer's `contents` to the provided IOSurface. /// /// Makes sure to do so on the main thread to avoid visual artifacts. @@ -273,6 +284,7 @@ fn getSubclass() error{ObjCFailed}!objc.Class { if (!subclass.addIvar("display_ctx")) return error.ObjCFailed; if (!subclass.addIvar("host_size")) return error.ObjCFailed; if (!subclass.addIvar("present_seq")) return error.ObjCFailed; + if (!subclass.addIvar("embedder_presents")) return error.ObjCFailed; subclass.replaceMethod("display", struct { fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { @@ -307,3 +319,94 @@ fn getSubclass() error{ObjCFailed}!objc.Class { return subclass; } + +fn testSurface(width: c_int, height: c_int) !*IOSurface { + return try IOSurface.init(.{ + .width = width, + .height = height, + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = null, + }); +} + +/// The width of the frame the layer shows; null while it shows none. +fn testShownWidth(self: *const IOSurfaceLayer) ?usize { + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return surface.getWidth(); +} + +fn testIsMainThread() bool { + return objc.getClass("NSThread").?.msgSend(bool, "isMainThread", .{}); +} + +test "host present: a frame of a new host size on main keeps the shown frame while the embedder presents sizes" { + const testing = std.testing; + // setSurface runs the main-thread present at once only on the main thread. + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + // A frame of the new size that reached the main thread (queued before the + // embedder took over size changes): the embedder shows a new size with its + // bounds in its own transaction, so this frame does not replace the shown one. + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&layer)); +} + +test "host present: a frame of a new host size on main is shown by default" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); +} + +test "host present: a frame of a new host size on main is shown when the layer's bounds already have that size" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + // The embedder gave the view its new bounds without a present (a hidden + // surface, a frame that came too late): the new frame fits them. + layer.layer.setProperty("contentsScale", @as(f64, 1)); + layer.layer.setProperty("bounds", macos.graphics.Rect{ + .origin = .{ .x = 0, .y = 0 }, + .size = .{ .width = 120, .height = 60 }, + }); + + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); +} From 62df0adde7a028681e298c9a821782312502fe3d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:26:02 +0000 Subject: [PATCH 2/8] renderer + embedded: the embedder presents a new host size with its bounds (ghostty_surface_set_host_present, ghostty_surface_host_frame) cmux-next (frame owner refactor S4, ready-or-keep): for every terminal in every refresh, the view's bounds and the layer's pixels change together, or neither changes. With the renderer showing new sizes itself, a new-size frame went to main by dispatch_async and setSurfaceCallback took any frame of the host size, so it landed in the old bounds 2-4 refreshes after the geometry (cmux films P2). The waiting present (G1) cost main 4-9 ms per drag frame. Opt-in, macOS host-sized surfaces only; the default does not change. - ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER): setHostDrawableSize asks for no display pass (a first frame still does); a display pass never waits (displayWhileEmbedderPresents); the renderer still draws each new size at once with cells rebuilt for it and keeps that frame (Metal.fresh, now with its present sequence), but setSurfaceCallback refuses a frame of another size than the shown one unless the layer's bounds already have its size. Frames at the shown size still go off main. Back to GHOSTTY_HOST_PRESENT_RENDERER, a frame drawn for the host size is shown at once. - ghostty_surface_host_frame(surface, present) -> ghostty_host_frame_e: NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD. With present, a READY frame becomes the layer's contents in the caller's transaction (IOSurfaceLayer.setSurfacePresented), ordered by the present sequence under the Core Animation lock, so a frame that reaches main later never replaces it. Never waits, never draws. Counted in host_present_calls, host_fresh_presents and host_late_presents. - publishFresh keeps only the newest frame by present sequence; a host size already on screen disarms publishing (no per-frame publish at rest). - ghostty.h parity tests for both enums (lib/enum.zig checkGhosttyHEnum). Chromium does the same for a resize: the browser compositor shows the new size only with a frame the renderer submitted for it, and keeps the old surface until then (fallback surface). --- NEXT.md | 1 + include/ghostty.h | 65 +++++++++++++ src/apprt.zig | 1 + src/apprt/embedded.zig | 21 ++++ src/apprt/structs.zig | 26 +++++ src/renderer/Metal.zig | 132 ++++++++++++++++++++++---- src/renderer/metal/IOSurfaceLayer.zig | 52 +++++++++- 7 files changed, 275 insertions(+), 23 deletions(-) diff --git a/NEXT.md b/NEXT.md index 4bf01f0fe..73979c68b 100644 --- a/NEXT.md +++ b/NEXT.md @@ -123,6 +123,7 @@ thread. New tab and split surfaces do not inherit the IO fields. | embedded: a host-size present that never waits | `ghostty_surface_present_host_frame_ready` is `ghostty_surface_present_host_frame` without the bounded wait (`Metal.presentHostSizedWait(false)`): it presents the renderer thread's frame at the host size in the caller's Core Animation transaction only if that frame is already published, otherwise it keeps the last frame (counted in `host_late_presents`) and the new frame lands through `setSurface` when it completes. cmux-next measured the waiting present at up to 4 ms of main per drag frame (main p95 20.5 -> 24.1 ms, more dropped frames), so it calls only this one, from one observer just before the commit. | | renderer: a content-only frame goes to the screen without the main thread | A frame of a host-sized surface whose drawable size is the host size and equals the frame the layer shows (no geometry change pending) is presented from the GPU completion thread in its own `CATransaction` (`IOSurfaceLayer.setSurfaceOffMain`), not by a `dispatch_async` to main: a terminal echo reaches the screen while the app's main thread is busy (cmux-next G9). A frame for a new size still goes through main with its geometry. Frames are numbered in completion order (`Metal.present_seq`) and both paths check the number under the Core Animation lock, so a late main-thread present never replaces a newer frame. Counted in `host_off_main_presents`. Covered by cmux-next `TerminalEchoOffMainTests`. | | input: one compiled link regex per pattern for the whole process | `input/link_regex.zig`: surfaces and their renderers share each compiled link regex, counted by its users; up to 16 unused patterns stay compiled. A new surface compiled the URL regex twice on the main thread (Surface `DerivedConfig` and the renderer `link.Set`, about 0.5 ms each: cmux workspace switch trace, 6.3 ms of `onig_compile` per switch). Oniguruma 6.9.9 search never writes to a `regex_t`, so one regex serves every thread. | +| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main; a first frame presents itself. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | Next in the stack (tracked in the design): presentation callbacks for frame-exact acknowledgment, Kitty image replay after a snapshot, and a diff --git a/include/ghostty.h b/include/ghostty.h index 7393795fc..10ed42fe8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1492,6 +1492,71 @@ GHOSTTY_API bool ghostty_surface_present_host_frame(ghostty_surface_t); // the last frame (counted in host_late_presents) and the new frame lands when // it completes. Never blocks the main thread. GHOSTTY_API bool ghostty_surface_present_host_frame_ready(ghostty_surface_t); +// macOS, host-sized surfaces: who shows the frame of a new host size. +typedef enum { + // The renderer (default): see ghostty_surface_set_host_sized. + GHOSTTY_HOST_PRESENT_RENDERER = 0, + // The embedder, with ghostty_surface_host_frame, in the Core Animation + // transaction that gives the view its new bounds, so a commit never + // carries new pixels in the old bounds or old pixels in the new bounds. + // A new size asks for no display pass and a display pass never waits. + // The renderer still draws each new size at once on its renderer thread + // (cells rebuilt for it, the reflow preview included) and keeps that frame + // for the embedder, but never shows a frame of another size than the one + // the layer shows, unless the view's bounds already have that size (the + // embedder set them without a present). A frame at the shown size still + // reaches the screen off the main thread, and a first frame (nothing + // shown yet) presents itself. ghostty_surface_draw (a synchronous draw the + // embedder asks for) still shows its frame at once. Going back to the + // renderer shows a frame drawn for the host size at once. + GHOSTTY_HOST_PRESENT_EMBEDDER = 1, +} ghostty_host_present_e; +// Default GHOSTTY_HOST_PRESENT_RENDERER. No effect on iOS or on a surface +// that is not host-sized. Main thread. +GHOSTTY_API void ghostty_surface_set_host_present(ghostty_surface_t, + ghostty_host_present_e); +// What ghostty_surface_host_frame found for the host size (the size of the +// last ghostty_surface_set_size). +typedef enum { + // Nothing to keep: not host-sized, iOS, or no frame shown yet (the first + // frame presents itself). Give the view its new bounds now. + GHOSTTY_HOST_FRAME_NONE = 0, + // The surface does not draw (occluded). No frame comes until it is shown + // again (ghostty_surface_set_occlusion true), which arms the present for + // its host size. + GHOSTTY_HOST_FRAME_HIDDEN = 1, + // The layer shows a frame at the host size: nothing is left to present. + GHOSTTY_HOST_FRAME_SHOWN = 2, + // present false: the renderer drew the host size's frame; it is not shown + // yet. A call with present true shows it. + GHOSTTY_HOST_FRAME_READY = 3, + // present true: that frame is the layer's contents now, in the caller's + // Core Animation transaction. Give the view its new bounds in the same + // transaction. + GHOSTTY_HOST_FRAME_PRESENTED = 4, + // Not drawn yet: the layer keeps the frame it shows. Keep the view's old + // bounds and call again before a later commit. + GHOSTTY_HOST_FRAME_PENDING = 5, + // Frame updates are held (synchronized output, a snapshot restore): no + // frame can come now, and the layer keeps the frame it shows. + GHOSTTY_HOST_FRAME_HELD = 6, +} ghostty_host_frame_e; +// macOS, a surface whose new host sizes the embedder presents +// (ghostty_surface_set_host_present): what it can show for its host size. +// With present false the layer does not change (a query: for example to +// present a group of surfaces together or not at all). With present true, +// a READY frame becomes the layer's contents in the caller's Core Animation +// transaction (PRESENTED); call it after every surface of the transaction +// got its size (their renderers draw in parallel) and before the commit, for +// example from a layer that Core Animation displays in that commit. A frame +// that reaches the main thread later never replaces a newer one. Never waits +// and never draws. Counted in host_present_calls (present true), +// host_fresh_presents (PRESENTED) and host_late_presents (PENDING or HELD +// with present true). A view whose bounds were given without a present +// shows the new frame when it is drawn; until then, or while the bounds stay +// old, call again until it returns SHOWN, NONE or HIDDEN. Main thread. +GHOSTTY_API ghostty_host_frame_e ghostty_surface_host_frame(ghostty_surface_t, + bool present); GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); // Fills the grid metrics. Returns false while a resize of an unlocked grid // is in flight; with a host-locked grid (ghostty_surface_set_grid) the diff --git a/src/apprt.zig b/src/apprt.zig index c30f658ac..1c389221d 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -39,6 +39,7 @@ pub const SurfaceIoMode = structs.SurfaceIoMode; pub const SurfaceGrid = structs.SurfaceGrid; pub const SurfaceSnapshotPhase = structs.SurfaceSnapshotPhase; pub const SurfaceLocalHistoryResult = structs.SurfaceLocalHistoryResult; +pub const HostPresent = structs.HostPresent; pub const HostFrame = structs.HostFrame; /// The implementation to use for the app runtime. This is comptime chosen diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d1562db91..bbfe18b7c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -2215,6 +2215,27 @@ pub const CAPI = struct { return api.presentHostSized(); } + /// Who shows the frame of a new host size: the renderer (default) or the + /// embedder (ghostty_surface_host_frame). See ghostty.h. + export fn ghostty_surface_set_host_present(surface: *Surface, mode_raw: c_int) void { + if (comptime builtin.os.tag != .macos) return; + const mode = std.enums.fromInt(apprt.HostPresent, mode_raw) orelse return; + const api = &surface.core_surface.renderer.api; + if (comptime !@hasDecl(@TypeOf(api.*), "setEmbedderPresents")) return; + api.setEmbedderPresents(mode == .embedder); + } + + /// What the embedder can show for the host size; with present, shows the + /// frame the renderer drew for it in the caller's Core Animation + /// transaction. See ghostty.h. + export fn ghostty_surface_host_frame(surface: *Surface, present: bool) apprt.HostFrame { + if (comptime builtin.os.tag != .macos) return .none; + const api = &surface.core_surface.renderer.api; + if (comptime !@hasDecl(@TypeOf(api.*), "hostFrame")) return .none; + if (present) _ = api.counters.present_calls.fetchAdd(1, .monotonic); + return api.hostFrame(present); + } + /// Draw a reflow preview while the locked grid does not fit the view. /// See ghostty.h. export fn ghostty_surface_set_reflow_preview(surface: *Surface, enabled: bool) void { diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 8f08163d8..5ec3b8c55 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -322,6 +322,25 @@ pub const SurfaceLocalHistoryResult = enum(c_int) { mismatch = 1, }; +/// Who shows the frame of a new host size of a host-sized macOS surface +/// (ghostty_surface_set_host_present). +/// +/// If this is changed, you must also update ghostty.h +/// (ghostty_host_present_e). +pub const HostPresent = enum(c_int) { + /// The renderer (default). + renderer = 0, + /// The embedder, with ghostty_surface_host_frame. + embedder = 1, +}; + +test "ghostty.h HostPresent" { + try @import("../lib/enum.zig").checkGhosttyHEnum( + HostPresent, + "GHOSTTY_HOST_PRESENT_", + ); +} + /// What an embedder that presents host sizes can show for a host-sized macOS /// surface (ghostty_surface_set_host_present, ghostty_surface_host_frame). /// @@ -348,6 +367,13 @@ pub const HostFrame = enum(c_int) { held = 6, }; +test "ghostty.h HostFrame" { + try @import("../lib/enum.zig").checkGhosttyHEnum( + HostFrame, + "GHOSTTY_HOST_FRAME_", + ); +} + test "ghostty.h SurfaceLocalHistoryResult" { try @import("../lib/enum.zig").checkGhosttyHEnum( SurfaceLocalHistoryResult, diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 1c1077c14..e376fd249 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -102,7 +102,8 @@ pub const HostFrameCounters = struct { display_passes: std.atomic.Value(u64) = .init(0), /// Frames presented from the completion thread (content only, `setSurfaceOffMain`). off_main_presents: std.atomic.Value(u64) = .init(0), - /// `ghostty_surface_present_host_frame` calls (the embedder's own + /// `ghostty_surface_present_host_frame` calls and + /// `ghostty_surface_host_frame` calls that present (the embedder's own /// pre-commit hook, for a layer whose display pass does not run). present_calls: std.atomic.Value(u64) = .init(0), main_draws: std.atomic.Value(u64) = .init(0), @@ -121,10 +122,20 @@ const FreshFrame = struct { mutex: std.Io.Mutex = .init, /// Retained; null after a take or before the first fresh frame. surface: ?*IOSurface = null, + /// The present sequence number (`Metal.present_seq`) of `surface`. + surface_seq: u64 = 0, /// Bumped on each publish; the display pass waits on it (futex). seq: std.atomic.Value(u32) = .init(0), }; +/// A fresh frame taken for a present (`takeFresh`): retained, the caller +/// releases `surface`. +const TakenFrame = struct { + surface: *IOSurface, + /// Its present sequence number (`Metal.present_seq`). + seq: u64, +}; + /// How long after a new host size the display pass waits for the renderer /// thread's frame at that size. The renderer thread starts it when the size /// is set (during layout), so the wait overlaps layout and the other @@ -284,6 +295,13 @@ pub fn setHostDrawableSize(self: *Metal, enabled: bool, width_px: u32, height_px self.fresh.armed.store(value != 0, .release); self.layer.setHostSize(value); self.layer.layer.setProperty("needsDisplayOnBoundsChange", value == 0); + // An embedder that presents host sizes shows this size's frame itself + // (`hostFrame`), with its bounds: no display pass draws, presents or + // waits for it. A first frame (nothing shown yet) still draws in the next + // display pass, as it does by default. Only the main thread makes the + // layer show nothing, so this read needs no lock. + if (value != 0 and self.embedder_presents.load(.acquire) and + self.layer.layer.getProperty(?*anyopaque, "contents") != null) return; // One synchronous frame at the new target size (or at the bounds again). self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{}); } @@ -291,11 +309,19 @@ pub fn setHostDrawableSize(self: *Metal, enabled: bool, width_px: u32, height_px /// macOS host-sized surfaces (`ghostty_surface_set_host_present`): with /// `enabled`, the embedder shows each frame of a new host size itself /// (`hostFrame`), in the Core Animation transaction that changes the view's -/// bounds. Main thread. +/// bounds. Frames at the shown size still go to the screen off the main +/// thread, and a first frame (nothing shown yet) still presents itself. +/// Main thread. pub fn setEmbedderPresents(self: *Metal, enabled: bool) void { if (comptime builtin.os.tag != .macos) return; - self.embedder_presents.store(enabled, .release); + if (self.embedder_presents.swap(enabled, .acq_rel) == enabled) return; self.layer.setEmbedderPresents(enabled); + // Back to the renderer: a frame it drew for the host size while the + // embedder owned size changes goes to the screen now. One still drawing + // presents itself when it completes. + if (enabled) return; + const host = self.host_size.load(.acquire); + if (host != 0) _ = self.presentFresh(@intCast(host >> 32), @intCast(host & 0xffff_ffff)); } /// iOS only: detach the IOSurfaceLayer from the embedder's view before @@ -329,7 +355,9 @@ pub fn loopEnter(self: *Metal) void { fn displayCallback(renderer: *Renderer) align(8) void { _ = renderer.api.counters.display_passes.fetchAdd(1, .monotonic); if (comptime builtin.os.tag == .macos) { - if (renderer.api.presentHostSized()) return; + if (renderer.api.embedder_presents.load(.acquire)) { + if (renderer.api.displayWhileEmbedderPresents()) return; + } else if (renderer.api.presentHostSized()) return; } _ = renderer.api.counters.main_draws.fetchAdd(1, .monotonic); renderer.drawFrame(true) catch |err| { @@ -424,12 +452,12 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { if (sync) { self.layer.setSurfaceSync(target.surface); } else { + const seq = self.present_seq.fetchAdd(1, .monotonic) + 1; if (comptime builtin.os.tag == .macos) { if (target.cells_fresh and self.fresh.armed.load(.acquire) and target.size_generation == self.fresh.generation.load(.acquire) and - self.host_size.load(.acquire) != 0) self.publishFresh(target.surface); + self.host_size.load(.acquire) != 0) self.publishFresh(target.surface, seq); } - const seq = self.present_seq.fetchAdd(1, .monotonic) + 1; // A content-only frame of a host-sized surface goes to the screen from // this (completion) thread: a terminal echo does not wait for main. if (comptime builtin.os.tag == .macos) { @@ -442,16 +470,19 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { } } -/// Keeps `surface` as the newest fresh frame and wakes a display pass that -/// waits for it. GPU completion thread. -fn publishFresh(self: *Metal, surface: *IOSurface) void { +/// Keeps `surface`, the frame of present sequence `seq`, as the newest fresh +/// frame and wakes a display pass that waits for it. A frame older than the +/// one kept (completions that raced) is not kept. GPU completion thread. +fn publishFresh(self: *Metal, surface: *IOSurface, seq: u64) void { const io = global.io(); - surface.retain(); const old = old: { self.fresh.mutex.lockUncancelable(io); defer self.fresh.mutex.unlock(io); + if (self.fresh.surface != null and seq <= self.fresh.surface_seq) return; + surface.retain(); const old = self.fresh.surface; self.fresh.surface = surface; + self.fresh.surface_seq = seq; break :old old; }; if (old) |o| o.release(); @@ -474,7 +505,7 @@ fn dropFresh(self: *Metal) void { /// The fresh frame if it has `width` x `height` pixels (retained; the caller /// releases it). A fresh frame of another size stays for its own pass. -fn takeFresh(self: *Metal, width: usize, height: usize) ?*IOSurface { +fn takeFresh(self: *Metal, width: usize, height: usize) ?TakenFrame { const io = global.io(); self.fresh.mutex.lockUncancelable(io); defer self.fresh.mutex.unlock(io); @@ -482,7 +513,28 @@ fn takeFresh(self: *Metal, width: usize, height: usize) ?*IOSurface { if (surface.getWidth() != width or surface.getHeight() != height) return null; self.fresh.surface = null; self.fresh.armed.store(false, .release); - return surface; + return .{ .surface = surface, .seq = self.fresh.surface_seq }; +} + +/// Whether a fresh frame of `width` x `height` pixels waits (`takeFresh` +/// would take it). Main thread. +fn hasFresh(self: *Metal, width: usize, height: usize) bool { + const io = global.io(); + self.fresh.mutex.lockUncancelable(io); + defer self.fresh.mutex.unlock(io); + const surface = self.fresh.surface orelse return false; + return surface.getWidth() == width and surface.getHeight() == height; +} + +/// Shows the fresh frame of `width` x `height` pixels in the caller's Core +/// Animation transaction, unless the layer shows a newer frame. Returns +/// whether it did. Main thread. +fn presentFresh(self: *Metal, width: usize, height: usize) bool { + const taken = self.takeFresh(width, height) orelse return false; + defer taken.surface.release(); + if (!self.layer.setSurfacePresented(taken.surface, taken.seq)) return false; + _ = self.counters.fresh_presents.fetchAdd(1, .monotonic); + return true; } /// The display pass of a host-sized surface (main thread): present the @@ -511,7 +563,7 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() == width and shown_surface.getHeight() == height) { // The renderer thread's frame at this size already landed. - if (self.takeFresh(width, height)) |surface| surface.release(); + if (self.takeFresh(width, height)) |taken| taken.surface.release(); return true; } @@ -519,9 +571,9 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { const set_at = self.host_size_set_at orelse std.Io.Timestamp.now(io, .awake); while (true) { const seq = self.fresh.seq.load(.acquire); - if (self.takeFresh(width, height)) |surface| { - defer surface.release(); - self.layer.setSurfaceSync(surface); + if (self.takeFresh(width, height)) |taken| { + defer taken.surface.release(); + self.layer.setSurfaceSync(taken.surface); _ = self.counters.fresh_presents.fetchAdd(1, .monotonic); return true; } @@ -545,12 +597,50 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { return true; } -/// What the embedder can show for the host size (`ghostty_surface_host_frame`). -/// Main thread. +/// What an embedder that presents host sizes can show for the host size +/// (`ghostty_surface_host_frame`); with `show`, a frame the renderer drew for +/// it becomes the layer's contents in the caller's Core Animation +/// transaction, where the embedder gives the view its new bounds. Never +/// waits and never draws. Main thread. pub fn hostFrame(self: *Metal, show: bool) apprt.HostFrame { - _ = self; - _ = show; - return .none; + if (comptime builtin.os.tag != .macos) return .none; + const host = self.host_size.load(.acquire); + if (host == 0) return .none; + // Hidden: the renderer draws when shown again (a reveal arms the present). + if (!self.visible_hint.load(.acquire)) return .hidden; + const width: usize = @intCast(host >> 32); + const height: usize = @intCast(host & 0xffff_ffff); + // Nothing shown: the first frame presents itself. + const shown = self.layer.shownSize() orelse return .none; + if (shown.width == width and shown.height == height) { + // A frame at this size is on screen (a first frame, a frame that fit + // bounds the embedder had set, or an earlier present): a kept fresh + // frame is not newer, and no frame needs to publish until the next + // size (frames at this size go off main). + if (self.takeFresh(width, height)) |taken| taken.surface.release(); + self.fresh.armed.store(false, .release); + return .shown; + } + if (!show) { + if (self.hasFresh(width, height)) return .ready; + return if (self.frames_held.load(.acquire)) .held else .pending; + } + if (self.presentFresh(width, height)) return .presented; + // The layer keeps its frame; the new one waits, published, for the next call. + _ = self.counters.late.fetchAdd(1, .monotonic); + return if (self.frames_held.load(.acquire)) .held else .pending; +} + +/// A display pass while the embedder presents host sizes (Core Animation runs +/// one on its own, for example for a new contents scale): it never waits and +/// never shows a frame of another size than the one shown; the embedder does, +/// with the view's bounds (`hostFrame`). Returns false when the pass should +/// draw itself: not host-sized, or nothing shown yet (the first frame). +fn displayWhileEmbedderPresents(self: *Metal) bool { + if (self.host_size.load(.acquire) == 0) return false; + // Hidden: nothing to draw now, and drawing would rebuild the released swap chain. + if (!self.visible_hint.load(.acquire)) return true; + return self.layer.shownSize() != null; } /// The host size generation a frame drawn now belongs to (Target.size_generation). diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 8fa586f11..4e26998e1 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -95,8 +95,8 @@ fn hostSize(layer: objc.Object) u64 { } /// Records whether the embedder presents the frames of a new host size -/// (`Metal.setEmbedderPresents`, ghostty_surface_set_host_present). Main -/// thread. +/// (`Metal.setEmbedderPresents`, ghostty_surface_set_host_present), which +/// `setSurfaceCallback` checks frames against. Main thread. pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, // never retained or released. @@ -104,6 +104,37 @@ pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { self.layer.setInstanceVariable("embedder_presents", .{ .value = @ptrFromInt(stored) }); } +/// The value `setEmbedderPresents` stored. +fn embedderPresents(layer: objc.Object) bool { + return @intFromPtr(layer.getInstanceVariable("embedder_presents").value) != 0; +} + +/// The pixel size of the frame the layer shows, or null while it shows +/// none. Read under the Core Animation lock: a frame presented off the main +/// thread (`setSurfaceOffMain`) can replace the shown one. +pub fn shownSize(self: *IOSurfaceLayer) ?struct { width: usize, height: usize } { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return .{ .width = surface.getWidth(), .height = surface.getHeight() }; +} + +/// Shows `surface`, the frame of present sequence `seq`, in the caller's +/// Core Animation transaction (the embedder's, which gives the view its new +/// bounds), unless the layer shows a newer frame (`setSurface`, +/// `setSurfaceOffMain`). Returns whether it did. Main thread. +pub fn setSurfacePresented(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) bool { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + if (seq <= presentSeq(self.layer)) return false; + setPresentSeq(self.layer, seq); + self.layer.setProperty("contents", surface); + return true; +} + /// Sets the layer's `contents` to the provided IOSurface. /// /// Makes sure to do so on the main thread to avoid visual artifacts. @@ -249,6 +280,23 @@ fn setSurfaceCallback( CATransaction.msgSend(void, objc.sel("lock"), .{}); defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); if (block.seq != 0 and block.seq <= presentSeq(layer)) return; + + // An embedder that presents host sizes shows a frame of another size + // than the shown one itself, in the transaction that gives the view its + // bounds (`Metal.hostFrame`): such a frame shows here only if the bounds + // already have its size (the embedder set them without a present), so a + // commit never carries new pixels in the old bounds. A first frame + // (nothing shown) presents itself. + if (embedderPresents(layer)) embedder: { + const shown = layer.getProperty(?*anyopaque, "contents") orelse break :embedder; + const shown_surface: *IOSurface = @ptrCast(shown); + if (shown_surface.getWidth() == surface.getWidth() and + shown_surface.getHeight() == surface.getHeight()) break :embedder; + const bounds_width: usize = @intFromFloat(bounds.size.width * scale); + const bounds_height: usize = @intFromFloat(bounds.size.height * scale); + if (bounds_width != surface.getWidth() or bounds_height != surface.getHeight()) return; + } + if (block.seq != 0) setPresentSeq(layer, block.seq); layer.setProperty("contents", surface); } From e999522d39f7630067ca01290495170465aaf8fe Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:55:27 +0000 Subject: [PATCH 3/8] renderer: test the review cases of the embedder present: off-main frames, refused presents, the old present calls (red) Review of the embedder host present (62df0adde) found three gaps. These tests fail on 62df0adde: - an off-main frame at the presented size commits in its own transaction while the embedder's present (and the view's new bounds) is not committed yet, so a commit can carry the new pixels before the new bounds; - a present refused because the layer shows a newer frame leaves publishing disarmed, so no later frame of the host size can be presented; - ghostty_surface_present_host_frame(_ready) in the embedder mode still presents with setSurfaceSync (no present sequence), so a late frame replaces it. --- src/renderer/Metal.zig | 57 +++++++++++++++++++++++++++ src/renderer/metal/IOSurfaceLayer.zig | 35 ++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index e376fd249..c7a51de64 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1138,3 +1138,60 @@ test "host present: back to the renderer, a frame drawn for the host size goes t metal.setEmbedderPresents(false); try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); } + +test "host present: a refused present keeps the next frame of the host size publishing" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const newer = try testSurface(80, 60); + defer newer.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + // The layer shows a newer frame (of the old size) than the one drawn for the + // host size, so the present refuses it. + try testing.expect(metal.layer.setSurfacePresented(newer, 50)); + + try testing.expectEqual(HostFrame.pending, metal.hostFrame(true)); + // The next frame at the host size must publish for the next present. + try testing.expect(metal.fresh.armed.load(.acquire)); +} + +test "host present: the old present calls follow the embedder's ordered present" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const stale = try testSurface(120, 60); + defer stale.release(); + const fresh = try testSurface(120, 60); + defer fresh.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + var stale_target = testTarget(&metal, stale); + stale_target.cells_fresh = false; + try metal.present(stale_target, false); + try metal.present(testTarget(&metal, fresh), false); + + // ghostty_surface_present_host_frame(_ready) while the embedder presents sizes. + try testing.expect(metal.presentHostSizedWait(true)); + try metal.layer.setSurface(stale, 1); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(fresh)), contents); +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 4e26998e1..f4ce023e3 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -458,3 +458,38 @@ test "host present: a frame of a new host size on main is shown when the layer's try layer.setSurface(drawn, 1); try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); } + +test "host present: an off-main frame never joins a present the embedder has not committed yet" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const presented = try testSurface(120, 60); + defer presented.release(); + const next = try testSurface(120, 60); + defer next.release(); + const after = try testSurface(120, 60); + defer after.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + // The embedder showed the new size; its transaction (with the view's new + // bounds) is not committed yet. + try testing.expect(layer.setSurfacePresented(presented, 5)); + // A newer frame at that size completes: an off-main transaction would + // commit it, alone, before the embedder's bounds. It goes through main. + try testing.expect(!layer.setSurfaceOffMain(next, 6)); + const contents = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(presented)), contents); + // On main (after the embedder's commit) it shows, and frames go off main again. + try layer.setSurface(next, 6); + try testing.expect(layer.setSurfaceOffMain(after, 7)); + const later = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(after)), later); +} From 5a9907c455836bc740bd8c2564bb0f8531fc130a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:56:37 +0000 Subject: [PATCH 4/8] renderer: review fixes of the embedder present: no off-main commit ahead of an embedder present, a refused present re-arms, the old present calls follow it - IOSurfaceLayer.setSurfaceOffMain: while the layer shows the embedder's present (embedder_seq, set by setSurfacePresented), a completed frame does not commit in its own transaction, which could reach the screen before the embedder's transaction with the view's new bounds. It goes through main (setSurface), after that commit; the next frames go off main again. - Metal.presentFresh: a present refused because the layer shows a newer frame re-arms publishing, so the next frame of the host size can be presented. - Metal.presentHostSizedWait (ghostty_surface_present_host_frame and _ready) in the embedder mode is hostFrame(true): no wait, ordered by the present sequence. The default mode does not change. --- NEXT.md | 2 +- include/ghostty.h | 7 +++++-- src/renderer/Metal.zig | 12 +++++++++++- src/renderer/metal/IOSurfaceLayer.zig | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/NEXT.md b/NEXT.md index 73979c68b..6f7e1f770 100644 --- a/NEXT.md +++ b/NEXT.md @@ -123,7 +123,7 @@ thread. New tab and split surfaces do not inherit the IO fields. | embedded: a host-size present that never waits | `ghostty_surface_present_host_frame_ready` is `ghostty_surface_present_host_frame` without the bounded wait (`Metal.presentHostSizedWait(false)`): it presents the renderer thread's frame at the host size in the caller's Core Animation transaction only if that frame is already published, otherwise it keeps the last frame (counted in `host_late_presents`) and the new frame lands through `setSurface` when it completes. cmux-next measured the waiting present at up to 4 ms of main per drag frame (main p95 20.5 -> 24.1 ms, more dropped frames), so it calls only this one, from one observer just before the commit. | | renderer: a content-only frame goes to the screen without the main thread | A frame of a host-sized surface whose drawable size is the host size and equals the frame the layer shows (no geometry change pending) is presented from the GPU completion thread in its own `CATransaction` (`IOSurfaceLayer.setSurfaceOffMain`), not by a `dispatch_async` to main: a terminal echo reaches the screen while the app's main thread is busy (cmux-next G9). A frame for a new size still goes through main with its geometry. Frames are numbered in completion order (`Metal.present_seq`) and both paths check the number under the Core Animation lock, so a late main-thread present never replaces a newer frame. Counted in `host_off_main_presents`. Covered by cmux-next `TerminalEchoOffMainTests`. | | input: one compiled link regex per pattern for the whole process | `input/link_regex.zig`: surfaces and their renderers share each compiled link regex, counted by its users; up to 16 unused patterns stay compiled. A new surface compiled the URL regex twice on the main thread (Surface `DerivedConfig` and the renderer `link.Set`, about 0.5 ms each: cmux workspace switch trace, 6.3 ms of `onig_compile` per switch). Oniguruma 6.9.9 search never writes to a `regex_t`, so one regex serves every thread. | -| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main; a first frame presents itself. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | +| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, except while the layer shows an embedder present that may not be committed yet (they go through main, after that commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | Next in the stack (tracked in the design): presentation callbacks for frame-exact acknowledgment, Kitty image replay after a snapshot, and a diff --git a/include/ghostty.h b/include/ghostty.h index 10ed42fe8..7ca553380 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1507,8 +1507,11 @@ typedef enum { // embedder set them without a present). A frame at the shown size still // reaches the screen off the main thread, and a first frame (nothing // shown yet) presents itself. ghostty_surface_draw (a synchronous draw the - // embedder asks for) still shows its frame at once. Going back to the - // renderer shows a frame drawn for the host size at once. + // embedder asks for) still shows its frame at once. In this mode + // ghostty_surface_present_host_frame(_ready) are ghostty_surface_host_frame + // with present true (no wait). A frame completed while the layer shows an + // embedder present not committed yet goes through the main thread. Going + // back to the renderer shows a frame drawn for the host size at once. GHOSTTY_HOST_PRESENT_EMBEDDER = 1, } ghostty_host_present_e; // Default GHOSTTY_HOST_PRESENT_RENDERER. No effect on iOS or on a surface diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index c7a51de64..3ade7040b 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -532,7 +532,12 @@ fn hasFresh(self: *Metal, width: usize, height: usize) bool { fn presentFresh(self: *Metal, width: usize, height: usize) bool { const taken = self.takeFresh(width, height) orelse return false; defer taken.surface.release(); - if (!self.layer.setSurfacePresented(taken.surface, taken.seq)) return false; + if (!self.layer.setSurfacePresented(taken.surface, taken.seq)) { + // Not shown (the layer shows a newer frame): the next frame of this + // size publishes for the next present. + self.fresh.armed.store(true, .release); + return false; + } _ = self.counters.fresh_presents.fetchAdd(1, .monotonic); return true; } @@ -552,6 +557,11 @@ pub fn presentHostSized(self: *Metal) bool { /// `presentHostSized`; with `wait` false it never blocks: it presents the fresh frame only if the /// renderer already published it, and otherwise keeps the last frame (counted late). pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { + // An embedder that presents host sizes: the same ordered present as + // `ghostty_surface_host_frame`, which never waits. + if (comptime builtin.os.tag == .macos) { + if (self.embedder_presents.load(.acquire)) return self.hostFrame(true) != .none; + } const host = self.host_size.load(.acquire); if (host == 0) return false; // Hidden: nothing to draw now (the renderer draws when shown again), diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index f4ce023e3..8deb70346 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -38,6 +38,7 @@ pub fn init() !IOSurfaceLayer { layer.setInstanceVariable("host_size", .{ .value = null }); layer.setInstanceVariable("present_seq", .{ .value = null }); layer.setInstanceVariable("embedder_presents", .{ .value = null }); + layer.setInstanceVariable("embedder_seq", .{ .value = null }); return .{ .layer = layer }; } @@ -131,10 +132,26 @@ pub fn setSurfacePresented(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); if (seq <= presentSeq(self.layer)) return false; setPresentSeq(self.layer, seq); + setEmbedderSeq(self.layer, seq); self.layer.setProperty("contents", surface); return true; } +/// The present sequence of the embedder's last present (`setSurfacePresented`). +/// While the layer still shows that frame, its transaction (with the view's +/// new bounds) may not be committed yet, so no frame goes to the screen off +/// the main thread: the next one goes through main, after that commit. +/// Read and written under the Core Animation lock. +fn embedderSeq(layer: objc.Object) u64 { + return @as(u64, @intFromPtr(layer.getInstanceVariable("embedder_seq").value)) >> 3; +} + +fn setEmbedderSeq(layer: objc.Object, seq: u64) void { + // Shifted like `setHostSize`: the object-typed ivar holds an 8-aligned value, never retained. + const stored: usize = @intCast(seq << 3); + layer.setInstanceVariable("embedder_seq", .{ .value = @ptrFromInt(stored) }); +} + /// Sets the layer's `contents` to the provided IOSurface. /// /// Makes sure to do so on the main thread to avoid visual artifacts. @@ -206,6 +223,11 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; if (seq <= presentSeq(self.layer)) return true; + // The shown frame is the embedder's present, maybe not committed yet: a + // transaction committed here could carry new pixels before the view's new + // bounds. The frame goes through main (`setSurface`), after that commit. + const embedder_seq = embedderSeq(self.layer); + if (embedderPresents(self.layer) and embedder_seq != 0 and presentSeq(self.layer) == embedder_seq) return false; setPresentSeq(self.layer, seq); self.layer.setProperty("contents", surface); return true; @@ -333,6 +355,7 @@ fn getSubclass() error{ObjCFailed}!objc.Class { if (!subclass.addIvar("host_size")) return error.ObjCFailed; if (!subclass.addIvar("present_seq")) return error.ObjCFailed; if (!subclass.addIvar("embedder_presents")) return error.ObjCFailed; + if (!subclass.addIvar("embedder_seq")) return error.ObjCFailed; subclass.replaceMethod("display", struct { fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { From 1ebff70925023e39c0d67c44bd289e16dfbbf74f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:10:58 +0000 Subject: [PATCH 5/8] renderer: test that a main-thread frame after an embedder present keeps frames on main until the embedder committed (red) Second review of the embedder present (5a9907c45): the off-main gate opened again at the first main-thread present after the embedder's. CFRunLoop runs main-queue blocks before the before-waiting observers, so a frame queued by the renderer can show in main's open transaction before the embedder's commit; the next frame then went off main and its own transaction could reach the screen ahead of the view's new bounds. The same after a frame shown because the bounds already fit. Seam with no effect yet: IOSurfaceLayer.setEmbedderCommitted (the embedder's 'my commit is done'). The existing gate test now reports the commit before it expects frames off main again. --- src/renderer/metal/IOSurfaceLayer.zig | 72 ++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 8deb70346..6dc020532 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -137,6 +137,12 @@ pub fn setSurfacePresented(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) return true; } +/// The embedder committed the transaction of its last present or bounds change +/// (`ghostty_surface_host_frame_committed`). Main thread. +pub fn setEmbedderCommitted(self: *IOSurfaceLayer) void { + _ = self; +} + /// The present sequence of the embedder's last present (`setSurfacePresented`). /// While the layer still shows that frame, its transaction (with the view's /// new bounds) may not be committed yet, so no frame goes to the screen off @@ -510,9 +516,73 @@ test "host present: an off-main frame never joins a present the embedder has not try testing.expect(!layer.setSurfaceOffMain(next, 6)); const contents = layer.layer.getProperty(?*anyopaque, "contents"); try testing.expectEqual(@as(?*anyopaque, @ptrCast(presented)), contents); - // On main (after the embedder's commit) it shows, and frames go off main again. + // On main it shows; once the embedder committed, frames go off main again. try layer.setSurface(next, 6); + layer.setEmbedderCommitted(); try testing.expect(layer.setSurfaceOffMain(after, 7)); const later = layer.layer.getProperty(?*anyopaque, "contents"); try testing.expectEqual(@as(?*anyopaque, @ptrCast(after)), later); } + +test "host present: a main-thread frame after the embedder's present keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const presented = try testSurface(120, 60); + defer presented.release(); + const queued = try testSurface(120, 60); + defer queued.release(); + const next = try testSurface(120, 60); + defer next.release(); + const later = try testSurface(120, 60); + defer later.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + try testing.expect(layer.setSurfacePresented(presented, 5)); + // A main-queue block runs before the embedder's commit (CFRunLoop runs + // main-queue blocks before the before-waiting observers): it shows a + // newer frame at that size in main's open transaction. + try layer.setSurface(queued, 6); + // An off-main commit now would still go out ahead of the embedder's bounds. + try testing.expect(!layer.setSurfaceOffMain(next, 7)); + // The embedder committed: frames go off main again. + layer.setEmbedderCommitted(); + try testing.expect(layer.setSurfaceOffMain(later, 8)); +} + +test "host present: a frame shown because the bounds already fit keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + // The embedder gave the view its new bounds without a present. + layer.layer.setProperty("contentsScale", @as(f64, 1)); + layer.layer.setProperty("bounds", macos.graphics.Rect{ + .origin = .{ .x = 0, .y = 0 }, + .size = .{ .width = 120, .height = 60 }, + }); + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); + // Those bounds are not committed yet either. + try testing.expect(!layer.setSurfaceOffMain(next, 2)); +} From 63c1398a49a9ef82ba888539e79232ede5ed3b9e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:14:51 +0000 Subject: [PATCH 6/8] renderer: the off-main gate waits for the embedder's commit (ghostty_surface_host_frame_committed) Second review of the embedder present (5a9907c45): the gate keyed on the embedder's present sequence opened again at the first main-thread present after it, which can run before the embedder's commit (a main-queue block), so the next frame could commit off main ahead of the view's new bounds. Ghostty cannot see the main thread's commit, so the embedder reports it: in GHOSTTY_HOST_PRESENT_EMBEDDER every main-thread contents change (a present, a frame shown on main, a sync present) marks the layer main-pending under the Core Animation lock, and setSurfaceOffMain refuses while it is set; ghostty_surface_host_frame_committed clears it. Without the call frames go through main (correct, slower). A mode change clears it. ghostty.h now says that the first frame's display pass and ghostty_surface_draw wait as they do by default, and that READY does not reserve the frame. --- NEXT.md | 2 +- include/ghostty.h | 24 ++++++++--- src/apprt/embedded.zig | 9 +++++ src/renderer/Metal.zig | 7 ++++ src/renderer/metal/IOSurfaceLayer.zig | 58 +++++++++++++++++---------- 5 files changed, 73 insertions(+), 27 deletions(-) diff --git a/NEXT.md b/NEXT.md index 6f7e1f770..64aa76183 100644 --- a/NEXT.md +++ b/NEXT.md @@ -123,7 +123,7 @@ thread. New tab and split surfaces do not inherit the IO fields. | embedded: a host-size present that never waits | `ghostty_surface_present_host_frame_ready` is `ghostty_surface_present_host_frame` without the bounded wait (`Metal.presentHostSizedWait(false)`): it presents the renderer thread's frame at the host size in the caller's Core Animation transaction only if that frame is already published, otherwise it keeps the last frame (counted in `host_late_presents`) and the new frame lands through `setSurface` when it completes. cmux-next measured the waiting present at up to 4 ms of main per drag frame (main p95 20.5 -> 24.1 ms, more dropped frames), so it calls only this one, from one observer just before the commit. | | renderer: a content-only frame goes to the screen without the main thread | A frame of a host-sized surface whose drawable size is the host size and equals the frame the layer shows (no geometry change pending) is presented from the GPU completion thread in its own `CATransaction` (`IOSurfaceLayer.setSurfaceOffMain`), not by a `dispatch_async` to main: a terminal echo reaches the screen while the app's main thread is busy (cmux-next G9). A frame for a new size still goes through main with its geometry. Frames are numbered in completion order (`Metal.present_seq`) and both paths check the number under the Core Animation lock, so a late main-thread present never replaces a newer frame. Counted in `host_off_main_presents`. Covered by cmux-next `TerminalEchoOffMainTests`. | | input: one compiled link regex per pattern for the whole process | `input/link_regex.zig`: surfaces and their renderers share each compiled link regex, counted by its users; up to 16 unused patterns stay compiled. A new surface compiled the URL regex twice on the main thread (Surface `DerivedConfig` and the renderer `link.Set`, about 0.5 ms each: cmux workspace switch trace, 6.3 ms of `onig_compile` per switch). Oniguruma 6.9.9 search never writes to a `regex_t`, so one regex serves every thread. | -| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, except while the layer shows an embedder present that may not be committed yet (they go through main, after that commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | +| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, except after a main-thread contents change until the embedder reports its commit (`ghostty_surface_host_frame_committed`: Ghostty cannot see the main thread's commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | Next in the stack (tracked in the design): presentation callbacks for frame-exact acknowledgment, Kitty image replay after a snapshot, and a diff --git a/include/ghostty.h b/include/ghostty.h index 7ca553380..88c011caf 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1507,11 +1507,16 @@ typedef enum { // embedder set them without a present). A frame at the shown size still // reaches the screen off the main thread, and a first frame (nothing // shown yet) presents itself. ghostty_surface_draw (a synchronous draw the - // embedder asks for) still shows its frame at once. In this mode + // embedder asks for) still shows its frame at once; it and the first + // frame's display pass wait on the main thread for a free frame and the + // GPU, as they do by default. In this mode // ghostty_surface_present_host_frame(_ready) are ghostty_surface_host_frame - // with present true (no wait). A frame completed while the layer shows an - // embedder present not committed yet goes through the main thread. Going - // back to the renderer shows a frame drawn for the host size at once. + // with present true (no wait). Once the main thread changed the layer's + // contents (a present, a frame shown on main), every frame goes through the + // main thread until ghostty_surface_host_frame_committed: Ghostty cannot + // see the main thread's commit, and a frame committed off the main thread + // could reach the screen ahead of the view's new bounds. Going back to the + // renderer shows a frame drawn for the host size at once. GHOSTTY_HOST_PRESENT_EMBEDDER = 1, } ghostty_host_present_e; // Default GHOSTTY_HOST_PRESENT_RENDERER. No effect on iOS or on a surface @@ -1557,9 +1562,18 @@ typedef enum { // host_fresh_presents (PRESENTED) and host_late_presents (PENDING or HELD // with present true). A view whose bounds were given without a present // shows the new frame when it is drawn; until then, or while the bounds stay -// old, call again until it returns SHOWN, NONE or HIDDEN. Main thread. +// old, call again until it returns SHOWN, NONE or HIDDEN. READY does not +// reserve the frame: a hide can drop it before the present (HIDDEN then). +// Main thread. GHOSTTY_API ghostty_host_frame_e ghostty_surface_host_frame(ghostty_surface_t, bool present); +// The embedder's transaction that carried its last present or bounds change +// was committed (for example: called from the next commit's pass). Frames at +// the shown size go to the screen off the main thread again, which keeps a +// terminal echo off the main thread. Without the call they go through main +// (correct, slower). No effect outside GHOSTTY_HOST_PRESENT_EMBEDDER. Main +// thread. +GHOSTTY_API void ghostty_surface_host_frame_committed(ghostty_surface_t); GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); // Fills the grid metrics. Returns false while a resize of an unlocked grid // is in flight; with a host-locked grid (ghostty_surface_set_grid) the diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index bbfe18b7c..2b9afe9c0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -2236,6 +2236,15 @@ pub const CAPI = struct { return api.hostFrame(present); } + /// The embedder committed the transaction of its last host frame present or + /// bounds change. See ghostty.h. + export fn ghostty_surface_host_frame_committed(surface: *Surface) void { + if (comptime builtin.os.tag != .macos) return; + const api = &surface.core_surface.renderer.api; + if (comptime !@hasDecl(@TypeOf(api.*), "hostFrameCommitted")) return; + api.hostFrameCommitted(); + } + /// Draw a reflow preview while the locked grid does not fit the view. /// See ghostty.h. export fn ghostty_surface_set_reflow_preview(surface: *Surface, enabled: bool) void { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 3ade7040b..394715dff 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -641,6 +641,13 @@ pub fn hostFrame(self: *Metal, show: bool) apprt.HostFrame { return if (self.frames_held.load(.acquire)) .held else .pending; } +/// The embedder committed the transaction of its last present or bounds change +/// (`ghostty_surface_host_frame_committed`). Main thread. +pub fn hostFrameCommitted(self: *Metal) void { + if (comptime builtin.os.tag != .macos) return; + self.layer.setEmbedderCommitted(); +} + /// A display pass while the embedder presents host sizes (Core Animation runs /// one on its own, for example for a new contents scale): it never waits and /// never shows a frame of another size than the one shown; the embedder does, diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 6dc020532..52de8c855 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -38,7 +38,7 @@ pub fn init() !IOSurfaceLayer { layer.setInstanceVariable("host_size", .{ .value = null }); layer.setInstanceVariable("present_seq", .{ .value = null }); layer.setInstanceVariable("embedder_presents", .{ .value = null }); - layer.setInstanceVariable("embedder_seq", .{ .value = null }); + layer.setInstanceVariable("main_pending", .{ .value = null }); return .{ .layer = layer }; } @@ -103,6 +103,8 @@ pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { // never retained or released. const stored: usize = @as(usize, @intFromBool(enabled)) << 3; self.layer.setInstanceVariable("embedder_presents", .{ .value = @ptrFromInt(stored) }); + // A mode change starts with nothing of the embedder's pending. + self.setEmbedderCommitted(); } /// The value `setEmbedderPresents` stored. @@ -132,30 +134,36 @@ pub fn setSurfacePresented(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); if (seq <= presentSeq(self.layer)) return false; setPresentSeq(self.layer, seq); - setEmbedderSeq(self.layer, seq); + setMainPending(self.layer, true); self.layer.setProperty("contents", surface); return true; } /// The embedder committed the transaction of its last present or bounds change -/// (`ghostty_surface_host_frame_committed`). Main thread. +/// (`ghostty_surface_host_frame_committed`): frames at the shown size go to the +/// screen off the main thread again. Main thread. pub fn setEmbedderCommitted(self: *IOSurfaceLayer) void { - _ = self; + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + setMainPending(self.layer, false); } -/// The present sequence of the embedder's last present (`setSurfacePresented`). -/// While the layer still shows that frame, its transaction (with the view's -/// new bounds) may not be committed yet, so no frame goes to the screen off -/// the main thread: the next one goes through main, after that commit. -/// Read and written under the Core Animation lock. -fn embedderSeq(layer: objc.Object) u64 { - return @as(u64, @intFromPtr(layer.getInstanceVariable("embedder_seq").value)) >> 3; +/// Whether, while the embedder presents host sizes, the main thread changed +/// the layer's contents in a transaction the embedder has not reported +/// committed (`setEmbedderCommitted`). Ghostty cannot see the main thread's +/// commit: until the embedder reports it, a frame committed off the main +/// thread in its own transaction could reach the screen ahead of the view's +/// new bounds, so frames go through main. Read and written under the Core +/// Animation lock. +fn mainPending(layer: objc.Object) bool { + return @intFromPtr(layer.getInstanceVariable("main_pending").value) != 0; } -fn setEmbedderSeq(layer: objc.Object, seq: u64) void { - // Shifted like `setHostSize`: the object-typed ivar holds an 8-aligned value, never retained. - const stored: usize = @intCast(seq << 3); - layer.setInstanceVariable("embedder_seq", .{ .value = @ptrFromInt(stored) }); +fn setMainPending(layer: objc.Object, pending: bool) void { + // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, never retained. + const stored: usize = @as(usize, @intFromBool(pending)) << 3; + layer.setInstanceVariable("main_pending", .{ .value = @ptrFromInt(stored) }); } /// Sets the layer's `contents` to the provided IOSurface. @@ -197,6 +205,14 @@ pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) ! /// /// Does not ensure this happens on the main thread. pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { + // Main thread (a display pass, ghostty_surface_draw): with the embedder + // presenting host sizes, frames go through main until its commit. + if (embedderPresents(self.layer)) { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + setMainPending(self.layer, true); + } self.layer.setProperty("contents", surface); } @@ -229,11 +245,10 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; if (seq <= presentSeq(self.layer)) return true; - // The shown frame is the embedder's present, maybe not committed yet: a - // transaction committed here could carry new pixels before the view's new - // bounds. The frame goes through main (`setSurface`), after that commit. - const embedder_seq = embedderSeq(self.layer); - if (embedderPresents(self.layer) and embedder_seq != 0 and presentSeq(self.layer) == embedder_seq) return false; + // The main thread changed the contents in a transaction the embedder has + // not reported committed: a transaction committed here could reach the + // screen ahead of the view's new bounds. The frame goes through main. + if (embedderPresents(self.layer) and mainPending(self.layer)) return false; setPresentSeq(self.layer, seq); self.layer.setProperty("contents", surface); return true; @@ -326,6 +341,7 @@ fn setSurfaceCallback( } if (block.seq != 0) setPresentSeq(layer, block.seq); + if (embedderPresents(layer)) setMainPending(layer, true); layer.setProperty("contents", surface); } @@ -361,7 +377,7 @@ fn getSubclass() error{ObjCFailed}!objc.Class { if (!subclass.addIvar("host_size")) return error.ObjCFailed; if (!subclass.addIvar("present_seq")) return error.ObjCFailed; if (!subclass.addIvar("embedder_presents")) return error.ObjCFailed; - if (!subclass.addIvar("embedder_seq")) return error.ObjCFailed; + if (!subclass.addIvar("main_pending")) return error.ObjCFailed; subclass.replaceMethod("display", struct { fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { From beab6a1e7addae118a0612e1fa3a02d1cad59caf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:39:29 +0000 Subject: [PATCH 7/8] renderer: test the third review cases of the off-main gate (red) Review of 63c1398a4: - a frame of the shown size shown on main closed the gate, so terminal echo after a resize stayed on main until the next commit report (red); - the mode started with the gate open, though a change the main thread made before it may still be in its open transaction (red); - a sync present (ghostty_surface_draw) of a new size closes the gate (coverage, passes now) and one at the shown size keeps it open (red). The off-main control test and the bounds-fit test now report the embedder's commit first, since the mode starts closed. --- src/renderer/Metal.zig | 2 + src/renderer/metal/IOSurfaceLayer.zig | 100 ++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 394715dff..8d3b76f83 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1013,6 +1013,8 @@ test "host present: a frame at the shown size goes to the screen off main while metal.layer.setSurfaceSync(shown); metal.setEmbedderPresents(true); metal.setHostDrawableSize(true, 120, 60); + // The embedder committed its last change: frames at the shown size may go off main. + metal.hostFrameCommitted(); // A content-only frame (a terminal echo) needs no geometry change. try metal.present(testTarget(&metal, drawn), false); diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index 52de8c855..ce968b5c0 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -591,6 +591,7 @@ test "host present: a frame shown because the bounds already fit keeps frames on layer.setSurfaceSync(shown); layer.setHostSize((120 << 32) | 60); layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); // The embedder gave the view its new bounds without a present. layer.layer.setProperty("contentsScale", @as(f64, 1)); layer.layer.setProperty("bounds", macos.graphics.Rect{ @@ -602,3 +603,102 @@ test "host present: a frame shown because the bounds already fit keeps frames on // Those bounds are not committed yet either. try testing.expect(!layer.setSurfaceOffMain(next, 2)); } + +test "host present: a frame at the shown size shown on main keeps frames going off main" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const queued = try testSurface(120, 60); + defer queued.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + // A frame of the shown size on main changes no geometry: terminal echo + // after it still goes off main. + try layer.setSurface(queued, 1); + try testing.expect(layer.setSurfaceOffMain(next, 2)); + const contents = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(next)), contents); +} + +test "host present: the mode starts with frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const first = try testSurface(120, 60); + defer first.release(); + const later = try testSurface(120, 60); + defer later.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + + // A change the main thread made before the mode may still be in its open + // transaction. + layer.setEmbedderPresents(true); + try testing.expect(!layer.setSurfaceOffMain(first, 1)); + layer.setEmbedderCommitted(); + try testing.expect(layer.setSurfaceOffMain(later, 2)); +} + +test "host present: a sync present of a new size keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + // ghostty_surface_draw on main at the new size. + layer.setSurfaceSync(drawn); + try testing.expect(!layer.setSurfaceOffMain(next, 1)); +} + +test "host present: a sync present at the shown size keeps frames going off main" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + layer.setSurfaceSync(drawn); + try testing.expect(layer.setSurfaceOffMain(next, 1)); +} From 9f88e48ee7113eb4a9826a21999c6f2761f67fbc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:42:53 +0000 Subject: [PATCH 8/8] renderer: the off-main gate closes only for a shown-size change, starts closed, and an off-main commit holds the CA lock Third review of the embedder present (63c1398a4): - An off-main transaction in GHOSTTY_HOST_PRESENT_EMBEDDER now takes [CATransaction lock] before begin and commits under it, and commits nothing when a check refuses. It can then never run inside the embedder's present pass (a layout or display pass of main's commit, which holds the lock), so it never ships a present or a frame main set after its checks, for this surface or another one in the window. ghostty.h now says the embedder presents and sets bounds under the lock or in such a pass. - Only a change of the shown size (a present, a first frame, a new-size frame shown on main or by a sync present) closes the gate; a frame of the shown size on main keeps echo off main. - The mode starts with the gate closed (a change main made before it may be in its open transaction); leaving it opens the gate. The default mode does not change. --- NEXT.md | 2 +- include/ghostty.h | 20 +++++++---- src/renderer/metal/IOSurfaceLayer.zig | 50 ++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/NEXT.md b/NEXT.md index 64aa76183..3e3dd36dc 100644 --- a/NEXT.md +++ b/NEXT.md @@ -123,7 +123,7 @@ thread. New tab and split surfaces do not inherit the IO fields. | embedded: a host-size present that never waits | `ghostty_surface_present_host_frame_ready` is `ghostty_surface_present_host_frame` without the bounded wait (`Metal.presentHostSizedWait(false)`): it presents the renderer thread's frame at the host size in the caller's Core Animation transaction only if that frame is already published, otherwise it keeps the last frame (counted in `host_late_presents`) and the new frame lands through `setSurface` when it completes. cmux-next measured the waiting present at up to 4 ms of main per drag frame (main p95 20.5 -> 24.1 ms, more dropped frames), so it calls only this one, from one observer just before the commit. | | renderer: a content-only frame goes to the screen without the main thread | A frame of a host-sized surface whose drawable size is the host size and equals the frame the layer shows (no geometry change pending) is presented from the GPU completion thread in its own `CATransaction` (`IOSurfaceLayer.setSurfaceOffMain`), not by a `dispatch_async` to main: a terminal echo reaches the screen while the app's main thread is busy (cmux-next G9). A frame for a new size still goes through main with its geometry. Frames are numbered in completion order (`Metal.present_seq`) and both paths check the number under the Core Animation lock, so a late main-thread present never replaces a newer frame. Counted in `host_off_main_presents`. Covered by cmux-next `TerminalEchoOffMainTests`. | | input: one compiled link regex per pattern for the whole process | `input/link_regex.zig`: surfaces and their renderers share each compiled link regex, counted by its users; up to 16 unused patterns stay compiled. A new surface compiled the URL regex twice on the main thread (Surface `DerivedConfig` and the renderer `link.Set`, about 0.5 ms each: cmux workspace switch trace, 6.3 ms of `onig_compile` per switch). Oniguruma 6.9.9 search never writes to a `regex_t`, so one regex serves every thread. | -| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, except after a main-thread contents change until the embedder reports its commit (`ghostty_surface_host_frame_committed`: Ghostty cannot see the main thread's commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | +| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, holding the Core Animation lock across their commit, except from the mode's start and after a main-thread change of the shown size until the embedder reports its commit (`ghostty_surface_host_frame_committed`: Ghostty cannot see the main thread's commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). | Next in the stack (tracked in the design): presentation callbacks for frame-exact acknowledgment, Kitty image replay after a snapshot, and a diff --git a/include/ghostty.h b/include/ghostty.h index 88c011caf..f613a56f2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1511,12 +1511,14 @@ typedef enum { // frame's display pass wait on the main thread for a free frame and the // GPU, as they do by default. In this mode // ghostty_surface_present_host_frame(_ready) are ghostty_surface_host_frame - // with present true (no wait). Once the main thread changed the layer's - // contents (a present, a frame shown on main), every frame goes through the - // main thread until ghostty_surface_host_frame_committed: Ghostty cannot + // with present true (no wait). From the start of the mode, and after the + // main thread changed the size of the frame the layer shows (a present, a + // first frame, a new-size frame shown on main), every frame goes through + // the main thread until ghostty_surface_host_frame_committed: Ghostty cannot // see the main thread's commit, and a frame committed off the main thread - // could reach the screen ahead of the view's new bounds. Going back to the - // renderer shows a frame drawn for the host size at once. + // could reach the screen ahead of the view's new bounds. An off-main commit + // holds [CATransaction lock] across its commit. Going back to the renderer + // shows a frame drawn for the host size at once. GHOSTTY_HOST_PRESENT_EMBEDDER = 1, } ghostty_host_present_e; // Default GHOSTTY_HOST_PRESENT_RENDERER. No effect on iOS or on a surface @@ -1564,11 +1566,15 @@ typedef enum { // shows the new frame when it is drawn; until then, or while the bounds stay // old, call again until it returns SHOWN, NONE or HIDDEN. READY does not // reserve the frame: a hide can drop it before the present (HIDDEN then). -// Main thread. +// Call it with present true, and give the view its bounds, while holding +// [CATransaction lock], or inside a Core Animation layout or display pass of +// the commit (which holds it): then no frame commits off the main thread in +// between, for this surface or another one in the window. Main thread. GHOSTTY_API ghostty_host_frame_e ghostty_surface_host_frame(ghostty_surface_t, bool present); // The embedder's transaction that carried its last present or bounds change -// was committed (for example: called from the next commit's pass). Frames at +// was committed (for example: called after each commit while the embedder +// presents host sizes; a call with nothing pending does nothing). Frames at // the shown size go to the screen off the main thread again, which keeps a // terminal echo off the main thread. Without the call they go through main // (correct, slower). No effect outside GHOSTTY_HOST_PRESENT_EMBEDDER. Main diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index ce968b5c0..04b428bfb 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -102,9 +102,22 @@ pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, // never retained or released. const stored: usize = @as(usize, @intFromBool(enabled)) << 3; + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); self.layer.setInstanceVariable("embedder_presents", .{ .value = @ptrFromInt(stored) }); - // A mode change starts with nothing of the embedder's pending. - self.setEmbedderCommitted(); + // The mode starts closed: a change the main thread made before it may + // still be in its open transaction. Leaving it, nothing is pending. + setMainPending(self.layer, enabled); +} + +/// Whether showing `surface` changes the size of the frame the layer shows +/// (or shows a first frame): only such a change can leave the view's bounds +/// behind. Call under the Core Animation lock. +fn changesShownSize(layer: objc.Object, surface: *IOSurface) bool { + const shown = layer.getProperty(?*anyopaque, "contents") orelse return true; + const shown_surface: *IOSurface = @ptrCast(shown); + return shown_surface.getWidth() != surface.getWidth() or shown_surface.getHeight() != surface.getHeight(); } /// The value `setEmbedderPresents` stored. @@ -206,12 +219,13 @@ pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) ! /// Does not ensure this happens on the main thread. pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { // Main thread (a display pass, ghostty_surface_draw): with the embedder - // presenting host sizes, frames go through main until its commit. + // presenting host sizes, a new size goes with main's commit, so frames go + // through main until the embedder reports it. if (embedderPresents(self.layer)) { const CATransaction = objc.getClass("CATransaction").?; CATransaction.msgSend(void, objc.sel("lock"), .{}); defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); - setMainPending(self.layer, true); + if (changesShownSize(self.layer, surface)) setMainPending(self.layer, true); } self.layer.setProperty("contents", surface); } @@ -236,6 +250,28 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const host = hostSize(self.layer); if (host == 0 or (host >> 32) != width or (host & 0xffff_ffff) != height) return false; const CATransaction = objc.getClass("CATransaction").?; + if (embedderPresents(self.layer)) { + // The embedder presents host sizes: the Core Animation lock is held + // across this transaction's commit, so it can never run inside the + // embedder's present pass (which holds the lock: a layout or display + // pass of main's commit) or ship a contents change main made after the + // checks below. Nothing commits when a check refuses. + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return false; + const shown_surface: *IOSurface = @ptrCast(shown); + if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; + if (seq <= presentSeq(self.layer)) return true; + // The main thread changed the shown size in a transaction the + // embedder has not reported committed: a transaction committed here + // could reach the screen ahead of the view's new bounds. + if (mainPending(self.layer)) return false; + CATransaction.msgSend(void, objc.sel("begin"), .{}); + defer CATransaction.msgSend(void, objc.sel("commit"), .{}); + setPresentSeq(self.layer, seq); + self.layer.setProperty("contents", surface); + return true; + } CATransaction.msgSend(void, objc.sel("begin"), .{}); defer CATransaction.msgSend(void, objc.sel("commit"), .{}); // The Core Animation lock orders this against `setSurfaceCallback` on main. @@ -245,9 +281,7 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; if (seq <= presentSeq(self.layer)) return true; - // The main thread changed the contents in a transaction the embedder has - // not reported committed: a transaction committed here could reach the - // screen ahead of the view's new bounds. The frame goes through main. + // The mode turned on after the check above: the embedder path's gate. if (embedderPresents(self.layer) and mainPending(self.layer)) return false; setPresentSeq(self.layer, seq); self.layer.setProperty("contents", surface); @@ -341,7 +375,7 @@ fn setSurfaceCallback( } if (block.seq != 0) setPresentSeq(layer, block.seq); - if (embedderPresents(layer)) setMainPending(layer, true); + if (embedderPresents(layer) and changesShownSize(layer, surface)) setMainPending(layer, true); layer.setProperty("contents", surface); }