From a82637b53aa434fa5c8bc8360c58561d7d48a8e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:38:23 -0700 Subject: [PATCH 01/10] crash: resolve sentry directories on the init thread Sentry initialization already ran on a separate thread, but the cache and state directory resolution happened on the main thread before spawning it. On macOS the cache dir resolution calls NSFileManager URLForDirectory:inDomain:appropriateForURL:create:error: which takes multiple milliseconds and was the single largest cost in global.init. All directory resolution now happens on the init thread. before: 2967us-4018us after: 30us-70us (env map snapshot + thread spawn) global.init total drops from ~3.4-5.0ms to ~0.4-1.0ms. --- src/crash/sentry.zig | 97 +++++++++++++++++++++++++++++--------------- src/global.zig | 8 ++-- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 8b6471f16..4181b5c1d 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -56,52 +56,55 @@ pub threadlocal var thread_state: ?ThreadState = null; /// crash reports and logs, but we only store them locally (see Transport). /// It is up to the user to grab the logs and manually send them to us /// (or to their own Sentry instance) if they want to. -pub fn init(gpa: Allocator, environ_map: *const std.process.Environ.Map) !void { - if (comptime !build_options.sentry) return; +pub fn init(gpa: Allocator, environ_map: std.process.Environ.Map) !void { + if (comptime !build_options.sentry) { + var map = environ_map; + map.deinit(); + return; + } // Not supported on Windows currently, doesn't build. - if (comptime builtin.os.tag == .windows) return; + if (comptime builtin.os.tag == .windows) { + var map = environ_map; + map.deinit(); + return; + } // Must only start once assert(init_thread == null); - // Get our directories. - var single_threaded: std.Io.Threaded = .init_single_threaded; - defer single_threaded.deinit(); - var fba: std.heap.FixedBufferAllocator = .init(&dir_mem); - - state_dir_ = state_dir: { - const dir = try crash.defaultDir(single_threaded.io(), gpa, environ_map); - defer gpa.free(dir.path); - break :state_dir try fba.allocator().dupe(u8, dir.path); - }; - errdefer state_dir_ = null; - - const cache_dir = cache_dir: { - const dir = try cacheDir(single_threaded.io(), gpa, environ_map); - defer gpa.free(dir); - break :cache_dir try fba.allocator().dupe(u8, dir); - }; - cache_dir_ = cache_dir; - errdefer cache_dir_ = null; - - // We use a thread for initializing Sentry because initialization takes - // ~2k ns on my M3 Max. That's not a LOT of time but it's enough to be - // 90% of our pre-App startup time. Everything Sentry is doing initially - // is safe to do on a separate thread and fast enough that its very - // likely to be done before a crash occurs. - const thr = try std.Thread.spawn( + // We use a thread for initializing Sentry because initialization is + // slow enough to matter for process startup: resolving our directories + // can take multiple milliseconds on macOS (Apple APIs) and Sentry's + // own init does disk I/O. Everything Sentry is doing initially is safe + // to do on a separate thread and fast enough that its very likely to + // be done before a crash occurs. + // + // The environ map is a snapshot owned by the thread (and freed there), + // so it is safe against concurrent mutations of the process environment + // (e.g. ensureLocale on the main thread). + const thr = std.Thread.spawn( .{}, initThread, - .{cache_dir}, - ); + .{ gpa, environ_map }, + ) catch |err| { + var map = environ_map; + map.deinit(); + return err; + }; + // Naming the thread from here only works on some platforms (e.g. + // Linux). On Darwin the thread names itself in initThread. + var single_threaded: std.Io.Threaded = .init_single_threaded; + defer single_threaded.deinit(); thr.setName(single_threaded.io(), "sentry-init") catch {}; + init_thread = thr; } -fn initThread(cache_dir: []const u8) !void { - if (comptime !build_options.sentry) return; +fn initThread(gpa: Allocator, environ_map_: std.process.Environ.Map) !void { + var environ_map = environ_map_; + defer environ_map.deinit(); // Right now, on Darwin, `std.Thread.setName` can only name the current // thread, and we have no way to get the current thread from within it, @@ -110,6 +113,34 @@ fn initThread(cache_dir: []const u8) !void { internal_os.macos.pthread_setname_np(&"sentry-init".*); } + // Get our directories. + var single_threaded: std.Io.Threaded = .init_single_threaded; + defer single_threaded.deinit(); + var fba: std.heap.FixedBufferAllocator = .init(&dir_mem); + + state_dir_ = state_dir: { + const dir = try crash.defaultDir( + single_threaded.io(), + gpa, + &environ_map, + ); + defer gpa.free(dir.path); + break :state_dir try fba.allocator().dupe(u8, dir.path); + }; + errdefer state_dir_ = null; + + const cache_dir = cache_dir: { + const dir = try cacheDir( + single_threaded.io(), + gpa, + &environ_map, + ); + defer gpa.free(dir); + break :cache_dir try fba.allocator().dupe(u8, dir); + }; + cache_dir_ = cache_dir; + errdefer cache_dir_ = null; + const transport = sentry.Transport.init(&Transport.send); // This will crash if the transport was never used so we avoid // that for now. This probably leaks some memory but it'd be very diff --git a/src/global.zig b/src/global.zig index 11aa17b3a..3312039a9 100644 --- a/src/global.zig +++ b/src/global.zig @@ -181,10 +181,10 @@ pub fn init(opts: InitOpts) !void { self.rlimits = .init(); if (build_options.sentry) { - // Initialize our crash reporting. - var environ_map = try self.environ.createMap(self.alloc); - defer environ_map.deinit(); - crash.init(self.alloc, &environ_map) catch |err| { + // Initialize our crash reporting. The environ map snapshot is + // owned by crash.init (it is freed by the init thread). + const environ_map = try self.environ.createMap(self.alloc); + crash.init(self.alloc, environ_map) catch |err| { std.log.warn( "sentry init failed, no crash capture available err={}", .{err}, From 3225e9ebb195b1cc237c7b8d9de3d51c6863cb5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:39:28 -0700 Subject: [PATCH 02/10] macos: cache unified logging loggers per scope The logFn for macOS unified logging created and released an os_log_t logger on every single log call. Loggers are now cached per log scope for the process lifetime via an atomic pointer (a creation race wastes at most one create). Measured on macOS (Apple Silicon) with local timing instrumentation during app launch, the version-info logging block in global.init: before: 1070us-2629us after: 858us-1319us --- src/main_ghostty.zig | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index cd2ef0c17..86e779899 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -140,11 +140,12 @@ fn logFn( .err => .fault, }; - // Initialize a logger. This is slow to do on every operation - // but we shouldn't be logging too much. - const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); - defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); + macosLogger(scope).log( + std.heap.c_allocator, + mac_level, + prefix ++ format, + args, + ); } stderr: { @@ -166,6 +167,37 @@ fn logFn( } } +/// Returns the macOS unified logging logger for the given scope. The +/// logger is created once per scope and cached for the lifetime of the +/// process, because os_log object creation is slow (it shows up in +/// startup profiles when done per log call) and Apple's guidance is to +/// create loggers once and reuse them. +fn macosLogger(comptime scope: @TypeOf(.EnumLiteral)) *macos.os.Log { + const S = struct { + var cached: std.atomic.Value(?*macos.os.Log) = .init(null); + }; + + if (S.cached.load(.acquire)) |v| return v; + + // Create and attempt to store our logger. If we race with another + // thread then we use theirs and release ours. + const created = macos.os.Log.create( + build_config.bundle_id, + @tagName(scope), + ); + if (S.cached.cmpxchgStrong( + null, + created, + .acq_rel, + .acquire, + )) |existing| { + created.release(); + return existing.?; + } + + return created; +} + pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. // From afc79b8ccf4098ba15659578d0fc666c74fb61bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:40:44 -0700 Subject: [PATCH 03/10] font: look up Apple Color Emoji by exact name on macOS The Apple Color Emoji fallback font was discovered with the generic discovery path, which builds a CTFontCollection and runs system-wide font matching. Since we know the exact font we want, we can look it up directly with CTFontCreateWithName instead. --- pkg/macos/text/font.zig | 11 +++++++++ src/font/SharedGridSet.zig | 18 ++++++++++++++ src/font/discovery.zig | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index 6fd2db21a..626b9089b 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -18,6 +18,17 @@ pub const Font = opaque { ) orelse Allocator.Error.OutOfMemory; } + pub fn createWithName(name: *foundation.String, size: f32) Allocator.Error!*Font { + return @as( + ?*Font, + @ptrFromInt(@intFromPtr(c.CTFontCreateWithName( + @ptrCast(name), + size, + null, + ))), + ) orelse Allocator.Error.OutOfMemory; + } + pub fn createForString( self: *Font, str: *foundation.String, diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index b4f602e89..dc7d8dd7c 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -340,6 +340,24 @@ fn collection( // specifying a font-family for emoji. if (comptime builtin.target.os.tag.isDarwin() and Discover != void) apple_emoji: { const disco = try self.discover() orelse break :apple_emoji; + + // Fast path: we know the exact name of the font we want so we + // can look it up directly, which is sometimes significantly faster than + // full discovery (e.g. CoreText). + if (@hasDecl(Discover, "discoverExactFamily")) { + if (try disco.discoverExactFamily( + "Apple Color Emoji", + )) |face| { + _ = try c.addDeferred(self.alloc, face, .{ + .style = .regular, + .fallback = true, + // No size adjustment for emojis. + .size_adjustment = .none, + }); + break :apple_emoji; + } + } + var disco_it = try disco.discover(self.alloc, .{ .family = "Apple Color Emoji", }); diff --git a/src/font/discovery.zig b/src/font/discovery.zig index d1e60fd34..1e44026ce 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -383,6 +383,54 @@ pub const CoreText = struct { }; } + /// Discover a font by its exact name (family, full, or PostScript + /// name). This is significantly faster than `discover` because it + /// avoids the system-wide font matching that CTFontCollection does + /// (which takes multiple milliseconds). This should be preferred + /// when the desired font is known exactly, e.g. system fonts such + /// as Apple Color Emoji. + /// + /// Returns null if no font with this exact family name exists; + /// CoreText fallback fonts are never returned. + pub fn discoverExactFamily( + self: *const CoreText, + family: []const u8, + ) !?DeferredFace { + _ = self; + + const family_str = try macos.foundation.String.createWithBytes( + family, + .utf8, + false, + ); + defer family_str.release(); + + // Create our font. We need a size to initialize it so we use size + // 12 but we will alter the size later (same as DiscoverIterator). + const ct_font = try macos.text.Font.createWithName(family_str, 12); + + // CTFontCreateWithName never returns null: if the requested font + // isn't installed it returns a substitute font. Verify we got + // the family we asked for, otherwise report not found. + const found: bool = found: { + const actual = ct_font.copyFamilyName(); + defer actual.release(); + var buf: [256]u8 = undefined; + const actual_slice = actual.cstring(&buf, .utf8) orelse + break :found false; + break :found std.mem.eql(u8, actual_slice, family); + }; + if (!found) { + ct_font.release(); + return null; + } + + return .{ .ct = .{ + .font = ct_font, + .variations = &.{}, + } }; + } + pub fn discoverFallback( self: *const CoreText, alloc: Allocator, From c454a3bf47cd72945b7f4db3b53f8af332e167c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:42:09 -0700 Subject: [PATCH 04/10] font: support warmup threads The first CoreText font query in a process initializes the system font database, which takes multiple milliseconds (~7ms measured in an isolated process; 2-4ms observed inside Ghostty startup). This cost was previously paid during the first surface's font grid initialization, on the critical path to the first window. App.create can now spawn a background thread that performs the warmup. --- src/App.zig | 27 +++++++++++++++++++++++++++ src/font/discovery.zig | 16 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/App.zig b/src/App.zig index cf7316e8c..8f749890f 100644 --- a/src/App.zig +++ b/src/App.zig @@ -78,9 +78,36 @@ pub fn create(alloc: Allocator) CreateError!*App { var app = try alloc.create(App); errdefer alloc.destroy(app); try app.init(alloc); + + // Warm up system services on background threads. These are + // multi-millisecond one-time costs that would otherwise be paid + // during the first surface's initialization. Doing it here overlaps + // them with the rest of app startup (config, app runtime, window + // creation). + threadedWarmup(); + return app; } +/// Warm up system services whose first use in a process is slow. Each +/// subsystem gets its own background thread so that one slow subsystem +/// doesn't delay the others. Only operations that are safe to run on +/// any thread belong here. +fn threadedWarmup() void { + // If font discovery supports warmup, then we call it. Some font + // mechanisms (e.g. CoreText) have a multi-millisecond one-time cost + // on startup. + if (comptime @hasDecl(font.Discover, "warmup")) { + if (std.Thread.spawn( + .{}, + font.Discover.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("font warmup thread spawn failed err={}", .{err}); + } + } +} + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 1e44026ce..1847693a6 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -350,6 +350,22 @@ pub const CoreText = struct { _ = self; } + /// Warm up the system font registry. + /// + /// The first CoreText query in a process initializes the system font + /// database, which takes multiple milliseconds, while subsequent + /// queries are microseconds. + pub fn warmup() void { + const name = macos.foundation.String.createWithBytes( + "AppleColorEmoji", + .utf8, + false, + ) catch return; + defer name.release(); + const ct_font = macos.text.Font.createWithName(name, 12) catch return; + ct_font.release(); + } + /// Discover fonts from a descriptor. This returns an iterator that can /// be used to build up the deferred fonts. pub fn discover(self: *const CoreText, alloc: Allocator, desc: Descriptor) !DiscoverIterator { From 131b293dbbf426acf57618bc43bef0a4fe260d12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:43:31 -0700 Subject: [PATCH 05/10] renderer/metal: warm up the Metal device machinery at app creation The first Metal device query in a process (MTLCopyAllDevices) takes multiple milliseconds; once the framework is warm, subsequent queries are effectively free (measured ~15ms cold, ~1us warm in isolation). This cost was paid during the first surface's renderer initialization, on the critical path to the first window. Measured on macOS (Apple Silicon) with local timing instrumentation during app launch, first surface renderer initialization: GraphicsAPI.init before: 4227us (device query ~3.5ms) GraphicsAPI.init after: ~900us (device query 20-25us) --- src/App.zig | 12 ++++++++++++ src/renderer/Metal.zig | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/App.zig b/src/App.zig index 8f749890f..15e06a03a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -106,6 +106,18 @@ fn threadedWarmup() void { log.warn("font warmup thread spawn failed err={}", .{err}); } } + + // Same for the renderer's graphics API (e.g. Metal), which pays + // one-time framework initialization costs on first use. + if (comptime @hasDecl(renderer.Renderer.API, "warmup")) { + if (std.Thread.spawn( + .{}, + renderer.Renderer.API.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("renderer warmup thread spawn failed err={}", .{err}); + } + } } /// Initialize the main app instance. This creates the main window, sets diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 6c7432d21..1ad6b812b 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -407,6 +407,23 @@ pub inline fn beginFrame( return try Frame.begin(.{ .queue = self.queue }, renderer, target); } +/// Warm up the Metal device machinery. The first Metal device query in +/// a process takes multiple milliseconds; once warm, subsequent queries +/// are effectively free. Calling this early (e.g. on a background +/// thread at app startup; Metal device queries are thread-safe) moves +/// that one-time cost off the critical path of the first surface's +/// renderer initialization. +/// +/// We deliberately do NOT cache the chosen device: the device set can +/// change at runtime (e.g. an eGPU being plugged in or removed) and +/// chooseDevice prefers removable GPUs, so every renderer init must +/// re-choose. Only the underlying framework initialization is a +/// one-time cost. +pub fn warmup() void { + const device = chooseDevice() catch return; + device.release(); +} + fn chooseDevice() error{NoMetalDevice}!objc.Object { var chosen_device: ?objc.Object = null; From de1336faddbdffc8fb4f58af3597d3f031e2b2d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:44:25 -0700 Subject: [PATCH 06/10] renderer/metal: warm up command queue and shader pipelines Extend the Metal portion of the startup warmup thread to also create (and discard) a command queue and build (and discard) the shader pipelines for both pixel formats we may use (which one is used depends on the blending config). The first command queue for a device and the first render pipeline state creations pay one-time driver setup and shader compilation costs; once warm, the real creations during surface initialization hit driver and OS caches. Measured on macOS (Apple Silicon) with local timing instrumentation during app launch, first surface renderer initialization: queue creation: 717us -> 93us pipeline builds: 1023us -> 347us renderer init total: 2777us -> 1466us --- src/renderer/Metal.zig | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 1ad6b812b..7b59ce500 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -421,7 +421,34 @@ pub inline fn beginFrame( /// one-time cost. pub fn warmup() void { const device = chooseDevice() catch return; - device.release(); + defer device.release(); + + // Create and release a command queue. The first command queue + // created for a device pays additional one-time driver setup + // costs; subsequent creations are much cheaper. + const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); + queue.release(); + + // Build and discard our shader pipelines for both pixel formats we + // may use (which one is used depends on the blending config). The + // first pipeline state creation compiles shaders which is slow; + // once warm, later creations hit driver and OS caches. + inline for (.{ + mtl.MTLPixelFormat.bgra8unorm_srgb, + mtl.MTLPixelFormat.bgra8unorm, + }) |format| { + if (shaders.Shaders.init( + std.heap.c_allocator, + device, + &.{}, + format, + )) |s| { + var s_mut = s; + s_mut.deinit(std.heap.c_allocator); + } else |err| { + log.warn("metal warmup shader init failed err={}", .{err}); + } + } } fn chooseDevice() error{NoMetalDevice}!objc.Object { From db6d20dce1df0614b4903a4c5c5489a384ab8eeb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 15:45:21 -0700 Subject: [PATCH 07/10] apprt/embedded: initialize the TIS keymap lazily The embedded apprt App init created the keyboard layout keymap eagerly, which requires talking to the text input system (TIS). The first TIS call in a process is slow: 6.6ms measured inside ghostty_app_new during app launch (up to ~30ms in a cold process). The keymap is only used for keyboard layout queries (option-as-alt detection, layout change reload), which happen once keyboard events are flowing. By then AppKit has already warmed TIS and the call is effectively free (~0.2us measured warm). So initialize the keymap lazily on first use. If the layout changes before the keymap was ever created, reload is a no-op since lazy init picks up the current layout. Measured on macOS (Apple Silicon) with local timing instrumentation during app launch: embedded app init before: ~6.7ms (keymap 6614us) embedded app init after: ~60us (config clone only) --- src/apprt/embedded.zig | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 4eb63dca4..6bab71e55 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -120,7 +120,13 @@ pub const App = struct { core_app: *CoreApp, opts: Options, - keymap: input.Keymap, + + /// The keyboard layout keymap. This is lazily initialized on first + /// use because creating it requires talking to the text input + /// system (TIS on macOS), and the first such call in a process is + /// slow (multiple milliseconds). It is only needed once keyboard + /// events start flowing, at which point the system is warm. + keymap: ?input.Keymap, /// The configuration for the app. This is owned by this structure. config: Config, @@ -136,19 +142,16 @@ pub const App = struct { var config_clone = try config.clone(alloc); errdefer config_clone.deinit(); - var keymap = try input.Keymap.init(); - errdefer keymap.deinit(); - self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, - .keymap = keymap, + .keymap = null, }; } pub fn terminate(self: *App) void { - self.keymap.deinit(); + if (self.keymap) |*v| v.deinit(); self.config.deinit(); } @@ -208,8 +211,10 @@ pub const App = struct { /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { - // Reload the keymap - try self.keymap.reload(); + // Reload the keymap. If it was never initialized we don't need + // to do anything since lazy initialization will pick up the + // current layout. + if (self.keymap) |*v| try v.reload(); } /// Loads the keyboard layout. @@ -217,13 +222,25 @@ pub const App = struct { /// Kind of expensive so this should be avoided if possible. When I say /// "kind of expensive" I mean that its not something you probably want /// to run on every keypress. - pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + pub fn keyboardLayout(self: *App) input.KeyboardLayout { // We only support keyboard layout detection on macOS. if (comptime builtin.os.tag != .macos) return .unknown; + // Lazily initialize the keymap. + const keymap: *input.Keymap = keymap: { + if (self.keymap == null) { + self.keymap = input.Keymap.init() catch |err| { + log.warn("error initializing keymap err={}", .{err}); + return .unknown; + }; + } + + break :keymap &self.keymap.?; + }; + // Any layout larger than this is not something we can handle. var buf: [256]u8 = undefined; - const id = self.keymap.sourceId(&buf) catch |err| { + const id = keymap.sourceId(&buf) catch |err| { comptime assert(@TypeOf(err) == error{OutOfMemory}); return .unknown; }; From 4b1e02c7c3cf6d6a3548a67d88d08d4962ff67ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 16:51:07 -0700 Subject: [PATCH 08/10] macos: do not load the config errors window when there are no errors Measured on macOS (Apple Silicon) during app launch, via a startup timeline instrumented across the Swift app and libghostty: config apply, errors step: 35.5ms -> 0.1ms main() -> first frame rendered: ~126ms -> ~93ms main() -> window visible: ~193ms -> ~173ms --- .../Settings/ConfigurationErrorsController.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index 9956f7873..1dca58a1c 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -13,7 +13,15 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi @Published var errors: [String] = [] { didSet { if errors.count == 0 { - self.window?.performClose(nil) + // Only close the window if it was ever loaded: accessing + // `window` on an NSWindowController loads the nib (and our + // SwiftUI content view), which takes tens of milliseconds. + // This happens on every app launch via the initial config + // apply, when there are usually no errors and the window + // was never loaded. + if isWindowLoaded { + self.window?.performClose(nil) + } } } } From da745630bed8689365be0ec9a0cfe283a2ed965d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 17:07:04 -0700 Subject: [PATCH 09/10] macos: only check for auto-tabbing when tabbing preference is always windowDidLoad undoes macOS automatic window tabbing by inspecting window.tabGroup. Accessing tabGroup on a fresh window materializes AppKit's tab group machinery, which takes ~15-20ms and is on the critical path of every window creation, including the first window at app launch. AppKit only auto-tabs a fresh window when the system tabbing preference is "always": the tab bar "+" button goes through newWindowForTab which we intercept and route through our own tab logic, so it never auto-tabs. Guard the check on NSWindow.userTabbingPreference == .always so everyone else skips the tab group materialization entirely. Measured on macOS (Apple Silicon) during app launch via the startup timeline instrumentation: windowDidLoad tab group check: 17.8ms -> ~0ms main() -> window visible: median ~173ms -> ~165ms (n=7) --- .../Features/Terminal/TerminalController.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 36b863fc0..587510f09 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1119,7 +1119,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We don't run this logic in fullscreen because in fullscreen this will end up // removing the window and putting it into its own dedicated fullscreen, which is not // the expected or desired behavior of anyone I've found. - if !window.styleMask.contains(.fullScreen) { + // + // We also only run this when the system tabbing preference is "always", + // which is the only scenario AppKit will have auto-tabbed a fresh window + // at this point: the tab bar "+" button goes through newWindowForTab + // which we route through our own tab logic. This check matters because + // accessing `window.tabGroup` materializes the window's tab group + // machinery, which takes ~15-20ms and is otherwise not needed during + // window creation. + if NSWindow.userTabbingPreference == .always, + !window.styleMask.contains(.fullScreen) { // If we have more than 1 window in our tab group we know we're a new window. // Since Ghostty manages tabbing manually this will never be more than one // at this point in the AppKit lifecycle (we add to the group after this). From 931a538a3992c0f33c6647360bd15ff54f0f7a87 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 20:22:28 -0700 Subject: [PATCH 10/10] comments --- src/App.zig | 17 ++--------------- src/renderer/Metal.zig | 6 ------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/App.zig b/src/App.zig index 15e06a03a..28bbd83f1 100644 --- a/src/App.zig +++ b/src/App.zig @@ -79,21 +79,6 @@ pub fn create(alloc: Allocator) CreateError!*App { errdefer alloc.destroy(app); try app.init(alloc); - // Warm up system services on background threads. These are - // multi-millisecond one-time costs that would otherwise be paid - // during the first surface's initialization. Doing it here overlaps - // them with the rest of app startup (config, app runtime, window - // creation). - threadedWarmup(); - - return app; -} - -/// Warm up system services whose first use in a process is slow. Each -/// subsystem gets its own background thread so that one slow subsystem -/// doesn't delay the others. Only operations that are safe to run on -/// any thread belong here. -fn threadedWarmup() void { // If font discovery supports warmup, then we call it. Some font // mechanisms (e.g. CoreText) have a multi-millisecond one-time cost // on startup. @@ -118,6 +103,8 @@ fn threadedWarmup() void { log.warn("renderer warmup thread spawn failed err={}", .{err}); } } + + return app; } /// Initialize the main app instance. This creates the main window, sets diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 7b59ce500..9247454db 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -413,12 +413,6 @@ pub inline fn beginFrame( /// thread at app startup; Metal device queries are thread-safe) moves /// that one-time cost off the critical path of the first surface's /// renderer initialization. -/// -/// We deliberately do NOT cache the chosen device: the device set can -/// change at runtime (e.g. an eGPU being plugged in or removed) and -/// chooseDevice prefers removable GPUs, so every renderer init must -/// re-choose. Only the underlying framework initialization is a -/// one-time cost. pub fn warmup() void { const device = chooseDevice() catch return; defer device.release();