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) + } } } } 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). 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/App.zig b/src/App.zig index cf7316e8c..28bbd83f1 100644 --- a/src/App.zig +++ b/src/App.zig @@ -78,6 +78,32 @@ pub fn create(alloc: Allocator) CreateError!*App { var app = try alloc.create(App); errdefer alloc.destroy(app); try app.init(alloc); + + // 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}); + } + } + + // 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}); + } + } + return app; } 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; }; 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/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..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 { @@ -383,6 +399,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, 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}, 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. // diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 6c7432d21..9247454db 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -407,6 +407,44 @@ 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. +pub fn warmup() void { + const device = chooseDevice() catch return; + 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 { var chosen_device: ?objc.Object = null;