Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ae395c5
renderer: suspend hidden frame rebuilds
azooz2003-bit Jul 9, 2026
54f80a5
Merge fork main for renderer visibility backport
azooz2003-bit Jul 9, 2026
96b82f4
test: use pinned PageList row access
azooz2003-bit Jul 9, 2026
51c2b82
Merge pinned PageList test compatibility
azooz2003-bit Jul 9, 2026
2f6bb16
renderer: avoid duplicate visibility regain render
azooz2003-bit Jul 10, 2026
4c814d5
Merge exact-once reveal rendering fix
azooz2003-bit Jul 10, 2026
d4bdeb7
renderer: expose content-free activity events
azooz2003-bit Jul 10, 2026
a413452
Merge renderer activity instrumentation
azooz2003-bit Jul 10, 2026
ddb63e6
Merge remote-tracking branch 'origin/main' into perf-render-visibilit…
azooz2003-bit Jul 10, 2026
228cdec
test: cover reveal draw submission failures
azooz2003-bit Jul 10, 2026
3d134e9
renderer: require confirmed reveal draw submission
azooz2003-bit Jul 10, 2026
97d8b86
test: require forced visibility regain draw
azooz2003-bit Jul 10, 2026
530dea5
renderer: force visibility regain submission
azooz2003-bit Jul 10, 2026
bbf43bb
test: align renderer failure trace semantics
azooz2003-bit Jul 10, 2026
804fcec
test: treat selection activity as opaque
azooz2003-bit Jul 10, 2026
0e2335c
test: prove reveal survives repeated submission failure
azooz2003-bit Jul 10, 2026
0b1a10f
renderer: retain reveals across app queue backpressure
azooz2003-bit Jul 10, 2026
1903d99
test: prove draw failures retain prepared damage
azooz2003-bit Jul 10, 2026
5ba4701
renderer: retain prepared damage after draw errors
azooz2003-bit Jul 10, 2026
a6e1be3
test: expose visibility drift after mailbox errors
azooz2003-bit Jul 11, 2026
de4e06a
renderer: reconcile visibility after drain errors
azooz2003-bit Jul 11, 2026
cbc1b20
test: retain reveal after drain errors
azooz2003-bit Jul 11, 2026
5917ad6
renderer: retain recovered visibility transitions
azooz2003-bit Jul 11, 2026
c66c787
Merge VT screen tail export with renderer performance fixes
azooz2003-bit Jul 13, 2026
b858832
perf: cap embedded surface scrollback
azooz2003-bit Jul 14, 2026
14d4b0e
Merge remote-tracking branch 'origin/main' into perf-typing-lag-scrol…
azooz2003-bit Jul 14, 2026
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
24 changes: 24 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,20 @@ typedef enum {

typedef void (*ghostty_io_write_cb)(void*, const char*, uintptr_t);

// Content-free renderer activity events emitted only when a surface installs
// ghostty_renderer_event_cb. Begin/end pairs run on the renderer thread.
typedef enum {
GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_BEGIN = 0,
GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END = 1,
GHOSTTY_RENDERER_EVENT_DRAW_FRAME_BEGIN = 2,
GHOSTTY_RENDERER_EVENT_DRAW_FRAME_END = 3,
} ghostty_renderer_event_e;

// The userdata is ghostty_surface_config_s.userdata. The callback must be
// thread-safe and must not block the renderer thread.
typedef void (*ghostty_renderer_event_cb)(void* userdata,
ghostty_renderer_event_e event);

typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
Expand All @@ -489,6 +503,7 @@ typedef struct {
ghostty_surface_io_mode_e io_mode;
ghostty_io_write_cb io_write_cb;
void* io_write_userdata;
ghostty_renderer_event_cb renderer_event_cb;
} ghostty_surface_config_s;

typedef struct {
Expand Down Expand Up @@ -1142,9 +1157,18 @@ GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new();

GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t,
const ghostty_surface_config_s*);
// cmux fork: create a surface with an embedder-owned scrollback upper bound
// without changing ghostty_surface_config_s's public ABI. A zero limit inherits
// the configured scrollback-limit; a nonzero limit can only lower it.
GHOSTTY_API ghostty_surface_t ghostty_surface_new_with_scrollback_limit(
ghostty_app_t,
const ghostty_surface_config_s*,
size_t scrollback_limit_bytes);
GHOSTTY_API void ghostty_surface_free(ghostty_surface_t);
GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t);
GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t);
// Returns the embedder limit passed at construction, or zero when inherited.
GHOSTTY_API size_t ghostty_surface_scrollback_limit_bytes(ghostty_surface_t);
GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
Expand Down
59 changes: 59 additions & 0 deletions src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ focused_surface: ?*Surface = null,
/// this is a blocking queue so if it is full you will get errors (or block).
mailbox: Mailbox.Queue,

/// Set only when a renderer could not enqueue `redraw_surface`. The app
/// consumes this after draining mailbox capacity, avoiding a surface scan on
/// ordinary app ticks.
redraw_retry_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),

/// The set of font GroupCache instances shared by surfaces with the
/// same font configuration.
font_grid_set: font.SharedGridSet,
Expand Down Expand Up @@ -360,6 +365,18 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
},
}
}

// A renderer may have failed to enqueue `redraw_surface` while this queue
// was full. Notify only after capacity is available, and retain the surface
// registry lock so a concurrent embedded-surface deletion cannot invalidate
// a renderer thread during notification.
if (takeRedrawRetryRequest(&self.redraw_retry_requested)) {
self.lockSurfaceRegistry();
defer self.unlockSurfaceRegistry();
for (self.surfaces.items) |surface| {
surface.core().appMailboxDrained();
}
}
}

pub fn closeSurface(self: *App, surface: *Surface) void {
Expand Down Expand Up @@ -663,10 +680,13 @@ pub const Mailbox = struct {

rt_app: *apprt.App,
mailbox: *Queue,
redraw_retry_requested: *std.atomic.Value(bool),

/// Send a message to the surface.
pub fn push(self: Mailbox, msg: Message, timeout: Queue.Timeout) Queue.Size {
const redraw = std.meta.activeTag(msg) == .redraw_surface;
const result = self.mailbox.push(msg, timeout);
recordRejectedRedraw(self.redraw_retry_requested, redraw, result);

// Wake up our app loop
self.rt_app.wakeup();
Expand All @@ -675,6 +695,18 @@ pub const Mailbox = struct {
}
};

fn recordRejectedRedraw(
retry_requested: *std.atomic.Value(bool),
redraw: bool,
queue_size: Mailbox.Queue.Size,
) void {
if (redraw and queue_size == 0) retry_requested.store(true, .release);
}

fn takeRedrawRetryRequest(retry_requested: *std.atomic.Value(bool)) bool {
return retry_requested.swap(false, .acq_rel);
}

// Wasm API.
pub const Wasm = if (!builtin.target.isWasm()) struct {} else struct {
const wasm = @import("os/wasm.zig");
Expand Down Expand Up @@ -722,6 +754,33 @@ fn testAction(_: *apprt.App, _: apprt.Target.C, _: apprt.Action.C) callconv(.c)
return true;
}

test "full app mailbox retains redraw retry until drain" {
var queue: Mailbox.Queue = .{};
var retry_requested = std.atomic.Value(bool).init(false);

for (0..64) |_| {
try std.testing.expect(queue.push(.{ .open_config = {} }, .instant) > 0);
}

const redraw: Message = .{
.redraw_surface = @ptrFromInt(@alignOf(apprt.Surface)),
};
const rejected = queue.push(redraw, .instant);
try std.testing.expectEqual(0, rejected);
recordRejectedRedraw(
&retry_requested,
std.meta.activeTag(redraw) == .redraw_surface,
rejected,
);
try std.testing.expect(retry_requested.load(.acquire));

var drained: usize = 0;
while (queue.pop()) |_| drained += 1;
try std.testing.expectEqual(64, drained);
try std.testing.expect(takeRedrawRetryRequest(&retry_requested));
try std.testing.expect(!takeRedrawRetryRequest(&retry_requested));
}

test "surface registry mutations are serialized" {
if (comptime !@hasField(apprt.App, "opts")) return error.SkipZigTest;
if (comptime !@hasField(apprt.Surface, "app")) return error.SkipZigTest;
Expand Down
25 changes: 23 additions & 2 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,11 @@ pub fn init(
};

// Create our terminal grid with the initial size
const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox };
const app_mailbox: App.Mailbox = .{
.rt_app = rt_app,
.mailbox = &app.mailbox,
.redraw_retry_requested = &app.redraw_retry_requested,
};
var renderer_impl = try Renderer.init(alloc, .{
.config = try .init(alloc, config),
.font_grid = font_grid,
Expand All @@ -583,13 +587,19 @@ pub fn init(
errdefer alloc.destroy(mutex);

// Create the renderer thread
const renderer_instrumentation: rendererpkg.Instrumentation =
if (comptime @hasDecl(apprt.runtime.Surface, "rendererInstrumentation"))
rt_surface.rendererInstrumentation()
else
.{};
var render_thread = try rendererpkg.Thread.init(
alloc,
config,
rt_surface,
&self.renderer,
&self.renderer_state,
app_mailbox,
renderer_instrumentation,
);
errdefer render_thread.deinit();

Expand Down Expand Up @@ -883,6 +893,13 @@ pub fn deinit(self: *Surface) void {
log.info("surface closed addr={x}", .{@intFromPtr(self)});
}

/// Signal that the app-thread mailbox has capacity for a retained renderer
/// submission. This is safe from the app thread while the surface remains in
/// the app's locked surface registry.
pub fn appMailboxDrained(self: *Surface) void {
self.renderer_thread.appMailboxDrained();
}

/// Close this surface. This will trigger the runtime to start the
/// close process, which should ultimately deinitialize this surface.
pub fn close(self: *Surface) void {
Expand All @@ -893,7 +910,11 @@ pub fn close(self: *Surface) void {
inline fn surfaceMailbox(self: *Surface) Mailbox {
return .{
.surface = self,
.app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox },
.app = .{
.rt_app = self.rt_app,
.mailbox = &self.app.mailbox,
.redraw_retry_requested = &self.app.redraw_retry_requested,
},
};
}

Expand Down
90 changes: 85 additions & 5 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,17 @@ pub const App = struct {
}

/// Create a new surface for the app.
fn newSurface(self: *App, opts: Surface.Options) !*Surface {
fn newSurface(
self: *App,
opts: Surface.Options,
scrollback_limit_bytes: usize,
) !*Surface {
// Grab a surface allocation because we're going to need it.
var surface = try self.core_app.alloc.create(Surface);
errdefer self.core_app.alloc.destroy(surface);

// Create the surface
try surface.init(self, opts);
try surface.init(self, opts, scrollback_limit_bytes);
errdefer surface.deinit();

return surface;
Expand Down Expand Up @@ -425,6 +429,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 Surface = struct {
app: *App,
Expand All @@ -439,6 +444,8 @@ pub const Surface = struct {
io_mode: IoMode = .exec,
io_write_cb: ?IoWriteCallback = null,
io_write_userdata: ?*anyopaque = null,
renderer_event_cb: ?RendererEventCallback = null,
scrollback_limit_bytes: usize = 0,

/// 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 @@ -494,9 +501,18 @@ pub const Surface = struct {

/// Userdata passed to io_write_cb.
io_write_userdata: ?*anyopaque = null,

/// Optional content-free renderer activity callback. This receives the
/// surface `userdata` and runs synchronously on the renderer thread.
renderer_event_cb: ?RendererEventCallback = null,
};

pub fn init(self: *Surface, app: *App, opts: Options) !void {
pub fn init(
self: *Surface,
app: *App,
opts: Options,
scrollback_limit_bytes: usize,
) !void {
self.* = .{
.app = app,
.platform = try .init(opts.platform_tag, opts.platform),
Expand All @@ -512,6 +528,8 @@ pub const Surface = struct {
.io_mode = opts.io_mode,
.io_write_cb = opts.io_write_cb,
.io_write_userdata = opts.io_write_userdata,
.renderer_event_cb = opts.renderer_event_cb,
.scrollback_limit_bytes = scrollback_limit_bytes,
};

// Add ourselves to the list of surfaces on the app.
Expand All @@ -521,6 +539,10 @@ pub const Surface = struct {
// Shallow copy the config so that we can modify it.
var config = try apprt.surface.newConfig(app.core_app, &app.config, opts.context);
defer config.deinit();
config.@"scrollback-limit" = effectiveScrollbackLimit(
config.@"scrollback-limit",
scrollback_limit_bytes,
);

// If we have a working directory from the options then we set it.
if (opts.working_directory) |c_wd| {
Expand Down Expand Up @@ -630,6 +652,36 @@ pub const Surface = struct {
}
}

/// Applies an optional embedder cap without ever raising the user's
/// configured lower scrollback limit.
fn effectiveScrollbackLimit(configured: usize, embedder_cap: usize) usize {
if (embedder_cap == 0) return configured;
return @min(configured, embedder_cap);
}

test "embedded surface scrollback cap inherits when unset" {
try std.testing.expectEqual(@as(usize, 120), @sizeOf(Options));
try std.testing.expectEqual(
@as(usize, 50_000_000),
effectiveScrollbackLimit(50_000_000, 0),
);
}

test "embedded surface scrollback cap only lowers configured limit" {
try std.testing.expectEqual(
@as(usize, 8_388_608),
effectiveScrollbackLimit(50_000_000, 8_388_608),
);
try std.testing.expectEqual(
@as(usize, 2_000_000),
effectiveScrollbackLimit(2_000_000, 8_388_608),
);
try std.testing.expectEqual(
@as(usize, 0),
effectiveScrollbackLimit(0, 8_388_608),
);
}

pub fn deinit(self: *Surface) void {
// Shut down our inspector
self.freeInspector();
Expand Down Expand Up @@ -713,6 +765,13 @@ pub const Surface = struct {
return self.io_write_userdata;
}

pub fn rendererInstrumentation(self: *const Surface) renderer.Instrumentation {
return .{
.callback = self.renderer_event_cb,
.userdata = self.userdata,
};
}

pub fn getTitle(self: *Surface) ?[:0]const u8 {
return self.title;
}
Expand Down Expand Up @@ -1022,6 +1081,7 @@ pub const Surface = struct {
.io_mode = self.io_mode,
.io_write_cb = self.io_write_cb,
.io_write_userdata = self.io_write_userdata,
.renderer_event_cb = self.renderer_event_cb,
};
}

Expand Down Expand Up @@ -1626,7 +1686,20 @@ pub const CAPI = struct {
app: *App,
opts: *const apprt.Surface.Options,
) ?*Surface {
return surface_new_(app, opts) catch |err| {
return surface_new_(app, opts, 0) catch |err| {
log.err("error initializing surface err={}", .{err});
return null;
};
}

/// Create a surface with an embedder-owned upper bound for scrollback
/// while preserving the byte layout of Surface.Options.
export fn ghostty_surface_new_with_scrollback_limit(
app: *App,
opts: *const apprt.Surface.Options,
scrollback_limit_bytes: usize,
) ?*Surface {
return surface_new_(app, opts, scrollback_limit_bytes) catch |err| {
log.err("error initializing surface err={}", .{err});
return null;
};
Expand All @@ -1635,8 +1708,9 @@ pub const CAPI = struct {
fn surface_new_(
app: *App,
opts: *const apprt.Surface.Options,
scrollback_limit_bytes: usize,
) !*Surface {
return try app.newSurface(opts.*);
return try app.newSurface(opts.*, scrollback_limit_bytes);
}

export fn ghostty_surface_free(ptr: *Surface) void {
Expand All @@ -1653,6 +1727,12 @@ pub const CAPI = struct {
return surface.app;
}

/// Returns the separate embedder cap so inherited surface creation can
/// preserve it without adding a field to Surface.Options.
export fn ghostty_surface_scrollback_limit_bytes(surface: *Surface) usize {
return surface.scrollback_limit_bytes;
}

/// Returns the config to use for surfaces that inherit from this one.
export fn ghostty_surface_inherited_config(
surface: *Surface,
Expand Down
Loading
Loading