Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ typedef enum {
} ghostty_surface_io_mode_e;

typedef void (*ghostty_io_write_cb)(void*, const char*, uintptr_t);
// cmux fork: completion for one explicitly tokened render. The callback runs
// only after that command buffer completed and its IOSurface was assigned to
// the renderer layer on the main thread.
typedef void (*ghostty_render_presented_cb)(void*, uint64_t);

// Content-free renderer activity events emitted only when a surface installs
// ghostty_renderer_event_cb. Begin/end pairs run on the renderer thread.
Expand Down Expand Up @@ -504,6 +508,8 @@ typedef struct {
ghostty_io_write_cb io_write_cb;
void* io_write_userdata;
ghostty_renderer_event_cb renderer_event_cb;
ghostty_render_presented_cb render_presented_cb;
void* render_presented_userdata;
} ghostty_surface_config_s;

typedef struct {
Expand Down Expand Up @@ -1191,6 +1197,11 @@ GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
// cmux fork: delete when upstream exposes a synchronous render tick for
// embedders that drive rendering from a platform display callback.
GHOSTTY_API void ghostty_surface_render_now(ghostty_surface_t);
// cmux fork: submit a forced render associated with `token`. When successful,
// the surface's render_presented_cb fires after the exact rendered IOSurface
// reaches the renderer CALayer. A failed or size-discarded render has no callback.
GHOSTTY_API void ghostty_surface_render_now_with_token(ghostty_surface_t,
uint64_t token);
GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
Expand Down
33 changes: 32 additions & 1 deletion src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ pub const IoMode = enum(c_int) {

pub const IoWriteCallback = *const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void;
pub const RendererEventCallback = renderer.InstrumentationCallback;
pub const RenderPresentedCallback = *const fn (?*anyopaque, u64) callconv(.c) void;

pub const Surface = struct {
app: *App,
Expand All @@ -447,6 +448,8 @@ pub const Surface = struct {
io_write_userdata: ?*anyopaque = null,
renderer_event_cb: ?RendererEventCallback = null,
scrollback_limit_bytes: usize = 0,
render_presented_cb: ?RenderPresentedCallback = null,
render_presented_userdata: ?*anyopaque = null,

/// The current title of the surface. The embedded apprt saves this so
/// that getTitle works without the implementer needing to save it.
Expand Down Expand Up @@ -506,6 +509,13 @@ pub const Surface = struct {
/// Optional content-free renderer activity callback. This receives the
/// surface `userdata` and runs synchronously on the renderer thread.
renderer_event_cb: ?RendererEventCallback = null,

/// Callback invoked after an explicitly tokened render reaches the
/// platform renderer layer.
render_presented_cb: ?RenderPresentedCallback = null,

/// Userdata passed to render_presented_cb.
render_presented_userdata: ?*anyopaque = null,
};

pub fn init(
Expand All @@ -531,6 +541,8 @@ pub const Surface = struct {
.io_write_userdata = opts.io_write_userdata,
.renderer_event_cb = opts.renderer_event_cb,
.scrollback_limit_bytes = scrollback_limit_bytes,
.render_presented_cb = opts.render_presented_cb,
.render_presented_userdata = opts.render_presented_userdata,
};

// Add ourselves to the list of surfaces on the app.
Expand Down Expand Up @@ -661,7 +673,7 @@ pub const Surface = struct {
}

test "embedded surface scrollback cap inherits when unset" {
try std.testing.expectEqual(@as(usize, 120), @sizeOf(Options));
try std.testing.expectEqual(@as(usize, 136), @sizeOf(Options));
try std.testing.expectEqual(
@as(usize, 50_000_000),
effectiveScrollbackLimit(50_000_000, 0),
Expand Down Expand Up @@ -898,6 +910,19 @@ pub const Surface = struct {
self.core_surface.renderer_thread.renderNow();
}

pub fn renderNowWithToken(self: *Surface, token: u64) void {
const callback = self.render_presented_cb orelse {
self.renderNow();
return;
};
self.core_surface.applyPendingResizeIfNeeded();
self.core_surface.renderer_thread.renderNowWithPresentation(.{
.callback = callback,
.userdata = self.render_presented_userdata,
.token = token,
});
}

pub fn updateContentScale(self: *Surface, x: f64, y: f64) void {
// We are an embedded API so the caller can send us all sorts of
// garbage. We want to make sure that the float values are valid
Expand Down Expand Up @@ -2067,6 +2092,12 @@ pub const CAPI = struct {
surface.renderNow();
}

/// Force a render whose exact layer presentation is acknowledged with the
/// caller-provided token.
export fn ghostty_surface_render_now_with_token(surface: *Surface, token: u64) void {
surface.renderNowWithToken(token);
}

/// Update the size of a surface. This will trigger resize notifications
/// to the pty and the renderer.
export fn ghostty_surface_set_size(surface: *Surface, w: u32, h: u32) void {
Expand Down
9 changes: 9 additions & 0 deletions src/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ pub const Padding = size.Padding;
pub const cursorStyle = cursor.style;
pub const lib = @import("lib/main.zig");

/// Completion attached to one forced embedder render. Graphics backends that
/// support it invoke the callback only after the exact frame is presented to
/// the platform layer.
pub const FramePresentation = struct {
callback: *const fn (?*anyopaque, u64) callconv(.c) void,
userdata: ?*anyopaque,
token: u64,
};

/// The implementation to use for the renderer. This is comptime chosen
/// so that every build has exactly one renderer implementation.
pub const Renderer = switch (build_config.renderer) {
Expand Down
34 changes: 33 additions & 1 deletion src/renderer/Metal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,27 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
}
}

/// Present one explicitly tokened frame. iOS acknowledges only after the
/// exact IOSurface passes the main-thread layer size guard and is assigned.
pub inline fn presentWithPresentation(
self: *Metal,
target: Target,
sync: bool,
presentation: rendererpkg.FramePresentation,
) !void {
switch (comptime builtin.os.tag) {
.ios => try self.layer.setSurfaceWithPresentation(target.surface, presentation),
else => {
if (sync) {
self.layer.setSurfaceSync(target.surface);
presentation.callback(presentation.userdata, presentation.token);
} else {
try self.layer.setSurfaceWithPresentation(target.surface, presentation);
}
},
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Present the last presented target again. (noop for Metal)
pub inline fn presentLastTarget(self: *Metal) !void {
_ = self;
Expand Down Expand Up @@ -418,7 +439,18 @@ pub inline fn beginFrame(
/// The target is presented via the provided renderer's API when completed.
target: *Target,
) !Frame {
return try Frame.begin(.{ .queue = self.queue }, renderer, target);
return try Frame.begin(.{ .queue = self.queue }, renderer, target, null);
}

/// Begin a frame whose exact CALayer assignment must be acknowledged to the
/// embedder after GPU completion.
pub inline fn beginFrameWithPresentation(
self: *const Metal,
renderer: *Renderer,
target: *Target,
presentation: rendererpkg.FramePresentation,
) !Frame {
return try Frame.begin(.{ .queue = self.queue }, renderer, target, presentation);
}

fn chooseDevice() error{NoMetalDevice}!objc.Object {
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/OpenGL.zig
Original file line number Diff line number Diff line change
Expand Up @@ -457,5 +457,16 @@ pub inline fn beginFrame(
target: *Target,
) !Frame {
_ = self;
return try Frame.begin(.{}, renderer, target);
return try Frame.begin(.{}, renderer, target, null);
}

/// Begin a frame whose successful presentation acknowledges an opaque token.
pub inline fn beginFrameWithPresentation(
self: *const OpenGL,
renderer: *Renderer,
target: *Target,
presentation: rendererpkg.FramePresentation,
) !Frame {
_ = self;
return try Frame.begin(.{}, renderer, target, presentation);
}
29 changes: 29 additions & 0 deletions src/renderer/Thread.zig
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,35 @@ pub fn renderNow(self: *Thread) void {
_ = self.drawFrame(true);
}

/// Force a new frame and attach an exact platform-presentation completion.
/// iOS keeps Metal completion asynchronous even though `sync=true` is used to
/// force allocation of a fresh swap-chain target.
pub fn renderNowWithPresentation(
self: *Thread,
presentation: rendererpkg.FramePresentation,
) void {
self.enterExternalDrainMode();
_ = self.drainMailbox() catch |err| fallback: {
log.err("renderNowWithPresentation: error draining mailbox err={}", .{err});
break :fallback MailboxDrainResult{};
};

self.notifySelectionChanged();

self.renderer.updateFrame(
self.state,
self.effectiveCursorBlinkVisible(),
) catch |err| {
log.warn("renderNowWithPresentation: error updating frame err={}", .{err});
return;
};

self.renderer.drawFrameWithPresentation(true, presentation) catch |err| switch (err) {
error.Timeout => log.warn("renderNowWithPresentation: frame acquire timeout", .{}),
else => log.warn("renderNowWithPresentation: error drawing err={}", .{err}),
};
}

/// Drain the renderer mailbox once, applying every queued message.
///
/// cmux iOS fork: a public seam over the private `drainMailbox` so a producer
Expand Down
23 changes: 22 additions & 1 deletion src/renderer/generic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
pub fn drawFrame(
self: *Self,
sync: bool,
) !void {
return self.drawFrameWithOptionalPresentation(sync, null);
}

/// Draw a forced frame whose backend presentation carries an opaque
/// completion token to the embedder.
pub fn drawFrameWithPresentation(
self: *Self,
sync: bool,
presentation: renderer.FramePresentation,
) !void {
return self.drawFrameWithOptionalPresentation(sync, presentation);
}

fn drawFrameWithOptionalPresentation(
self: *Self,
sync: bool,
presentation: ?renderer.FramePresentation,
) !void {
// const start = std.time.Instant.now() catch unreachable;
// const start_micro = std.time.microTimestamp();
Expand Down Expand Up @@ -1697,7 +1715,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}

// Get a frame context from the graphics API.
var frame_ctx = try self.api.beginFrame(self, &frame.target);
var frame_ctx = if (presentation) |value|
try self.api.beginFrameWithPresentation(self, &frame.target, value)
else
try self.api.beginFrame(self, &frame.target);
Comment on lines +1718 to +1721

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Presentation Token Gets Dropped

When ghostty_surface_render_now_with_token reaches a renderer backend without beginFrameWithPresentation, this branch still draws the frame but falls back to beginFrame and discards the callback payload. The caller sees a successful tokened render request, but no render_presented_cb can fire after presentation, so embedders waiting for that exact token can hang or keep stale pixels visible.

defer frame_ctx.complete(sync);

{
Expand Down
25 changes: 21 additions & 4 deletions src/renderer/metal/Frame.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const Target = @import("Target.zig");
const RenderPass = @import("RenderPass.zig");

const Health = @import("../../renderer.zig").Health;
const FramePresentation = @import("../../renderer.zig").FramePresentation;

const log = std.log.scoped(.metal);

Expand All @@ -35,6 +36,7 @@ pub fn begin(
renderer: *Renderer,
/// The target is presented via the provided renderer's API when completed.
target: *Target,
presentation: ?FramePresentation,
) !Self {
const buffer = opts.queue.msgSend(
objc.Object,
Expand All @@ -49,6 +51,9 @@ pub fn begin(
.renderer = renderer,
.target = target,
.sync = false,
.presentation_callback = if (presentation) |value| value.callback else null,
.presentation_userdata = if (presentation) |value| value.userdata else null,
.presentation_token = if (presentation) |value| value.token else 0,
},
&bufferCompleted,
);
Expand All @@ -61,6 +66,9 @@ const CompletionBlock = objc.Block(struct {
renderer: *Renderer,
target: *Target,
sync: bool,
presentation_callback: ?*const fn (?*anyopaque, u64) callconv(.c) void,
presentation_userdata: ?*anyopaque,
presentation_token: u64,
}, .{
objc.c.id, // MTLCommandBuffer
}, void);
Expand All @@ -80,10 +88,19 @@ fn bufferCompleted(

// If the frame is healthy, present it.
if (health == .healthy) {
block.renderer.api.present(
block.target.*,
block.sync,
) catch |err| {
const result = if (block.presentation_callback) |callback|
block.renderer.api.presentWithPresentation(
block.target.*,
block.sync,
.{
.callback = callback,
.userdata = block.presentation_userdata,
.token = block.presentation_token,
},
)
else
block.renderer.api.present(block.target.*, block.sync);
result catch |err| {
log.err("Failed to present render target: err={}", .{err});
};
}
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/metal/IOSurfaceLayer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const objc = @import("objc");
const macos = @import("macos");

const IOSurface = macos.iosurface.IOSurface;
const FramePresentation = @import("../../renderer.zig").FramePresentation;

const log = std.log.scoped(.IOSurfaceLayer);

Expand Down Expand Up @@ -74,6 +75,16 @@ pub fn detachFromHostIfDisplayCallbackOwned(
///
/// Makes sure to do so on the main thread to avoid visual artifacts.
pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void {
return self.setSurfaceWithPresentation(surface, null);
}

/// Sets the layer contents and acknowledges the exact token after the size
/// guard succeeds. This callback runs on main in the same block as assignment.
pub inline fn setSurfaceWithPresentation(
self: *IOSurfaceLayer,
surface: *IOSurface,
presentation: ?FramePresentation,
) !void {
// We retain the surface to make sure it's not GC'd
// before we can set it as the contents of the layer.
//
Expand All @@ -86,6 +97,9 @@ pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void {
var block = SetSurfaceBlock.init(.{
.layer = self.layer.value,
.surface = surface,
.presentation_callback = if (presentation) |value| value.callback else null,
.presentation_userdata = if (presentation) |value| value.userdata else null,
.presentation_token = if (presentation) |value| value.token else 0,
}, &setSurfaceCallback);

// We check if we're on the main thread and run the block directly if so.
Expand Down Expand Up @@ -114,6 +128,9 @@ pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void {
const SetSurfaceBlock = objc.Block(struct {
layer: objc.c.id,
surface: *IOSurface,
presentation_callback: ?*const fn (?*anyopaque, u64) callconv(.c) void,
presentation_userdata: ?*anyopaque,
presentation_token: u64,
}, .{}, void);

const DetachFromHostBlock = objc.Block(struct {
Expand Down Expand Up @@ -149,6 +166,9 @@ fn setSurfaceCallback(
}

layer.setProperty("contents", surface);
if (block.presentation_callback) |callback| {
callback(block.presentation_userdata, block.presentation_token);
}
}

fn detachFromHostCallback(
Expand Down
Loading
Loading