From 64239f9201271866308420f67a492f0819b437ba Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 16:12:59 -0700 Subject: [PATCH 1/6] Fix nightly startup crash --- .github/workflows/nightly.yml | 1 + .github/workflows/release.yml | 1 + Sources/App/StartupBreadcrumbLog.swift | 81 +++++++++++++++++++ Sources/AppDelegate.swift | 69 +++++++++++++++- Sources/GhosttyTerminalView.swift | 37 +++++++-- Sources/cmuxApp.swift | 14 ++++ cmux.xcodeproj/project.pbxproj | 4 + scripts/build-command-palette-nucleo-ffi.sh | 2 + scripts/sign-cmux-bundle.sh | 2 + scripts/smoke-launch-macos-app.sh | 78 ++++++++++++++++++ ...ify-command-palette-nucleo-ffi-artifact.sh | 47 +++++++++++ 11 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 Sources/App/StartupBreadcrumbLog.swift create mode 100755 scripts/smoke-launch-macos-app.sh create mode 100755 scripts/verify-command-palette-nucleo-ffi-artifact.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index dbe8fa55ba74..4786dd605cfd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -477,6 +477,7 @@ jobs: xcrun stapler staple "$app_path" xcrun stapler validate "$app_path" spctl -a -vv --type execute "$app_path" + ./scripts/smoke-launch-macos-app.sh "$app_path" rm -f "$zip_submit" dmg_tmp_dir="$(mktemp -d)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdc8c21a9fa4..69cbb46135dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -330,6 +330,7 @@ jobs: xcrun stapler staple "$APP_PATH" xcrun stapler validate "$APP_PATH" spctl -a -vv --type execute "$APP_PATH" + ./scripts/smoke-launch-macos-app.sh "$APP_PATH" rm -f "$ZIP_SUBMIT" # create-dmg generates a styled drag-to-install DMG create-dmg \ diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift new file mode 100644 index 000000000000..596e8ecea398 --- /dev/null +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -0,0 +1,81 @@ +import Foundation + +enum StartupBreadcrumbLog { + private static let lock = NSLock() + private static let maxFieldLength = 240 + + static func append(_ event: String, fields: [String: String] = [:]) { + guard isEnabled else { return } + + lock.lock() + defer { lock.unlock() } + + var payload: [String: Any] = [ + "timestamp": ISO8601DateFormatter().string(from: Date()), + "event": event, + "pid": ProcessInfo.processInfo.processIdentifier, + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown", + "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + "build": Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + ] + + for (key, value) in fields { + payload[key] = sanitized(value) + } + + do { + let url = logURL + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: url.path) { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + let line = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + try handle.write(contentsOf: Data([0x0A])) + } catch { + NSLog("cmux startup breadcrumb failed: %@", String(describing: error)) + } + } + + private static var isEnabled: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_DISABLE_STARTUP_BREADCRUMBS"] == "1" { + return false + } + if environment["CMUX_STARTUP_BREADCRUMBS"] == "1" { + return true + } + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" + return bundleIdentifier == "com.cmuxterm.app.nightly" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.nightly.") + || bundleIdentifier.hasPrefix("com.cmuxterm.app.dev.") + } + + private static var logURL: URL { + let logsDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("Logs/cmux", isDirectory: true) + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-logs", isDirectory: true) + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "unknown" + let sanitizedBundleIdentifier = sanitized(bundleIdentifier, maxLength: 160) + .replacingOccurrences(of: "/", with: "-") + return logsDirectory.appendingPathComponent("startup-\(sanitizedBundleIdentifier).log") + } + + private static func sanitized(_ value: String, maxLength: Int = maxFieldLength) -> String { + let flattened = value + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + if flattened.count <= maxLength { + return flattened + } + return String(flattened.prefix(maxLength)) + "..." + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f19371fbf113..fc20f54bebac 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1010,14 +1010,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch + StartupBreadcrumbLog.append( + "appDelegate.didFinish.begin", + fields: [ + "xctest": isRunningUnderXCTest ? "1" : "0", + "telemetry": telemetryEnabled ? "1" : "0" + ] + ) AppIconLaunchState.markDidFinishLaunching() if isRunningUnderXCTest { NSApp.setActivationPolicy(.regular) } else { syncActivationPolicy() } + StartupBreadcrumbLog.append("appDelegate.didFinish.activationPolicy.synced") claimAuthCallbackURLSchemes() + StartupBreadcrumbLog.append("appDelegate.didFinish.authSchemes.claimed") // Install the Feed (workstream) store. Separate from the transport // wiring: the store is a plain singleton here, and the socket @@ -1029,6 +1038,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent persistence: WorkstreamPersistence(fileURL: WorkstreamPersistence.defaultFileURL()) ) ) + StartupBreadcrumbLog.append("appDelegate.didFinish.feedStore.installed") Task { @MainActor in await FeedCoordinator.shared.store?.start() #if DEBUG @@ -1090,6 +1100,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = Locale.current _ = NSLocale.preferredLanguages + StartupBreadcrumbLog.append("appDelegate.didFinish.sentry.begin") SentrySDK.start { options in options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" #if DEBUG @@ -1111,10 +1122,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Avoid recursively capturing failed requests from Sentry's own ingestion endpoint. options.enableCaptureFailedRequests = false } + StartupBreadcrumbLog.append("appDelegate.didFinish.sentry.complete") } if telemetryEnabled && !isRunningUnderXCTest { + StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.begin") PostHogAnalytics.shared.startIfNeeded() + StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.complete") } let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" @@ -1125,9 +1139,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !isRunningUnderXCTest { DispatchQueue.main.async { [weak self] in guard let self else { return } + StartupBreadcrumbLog.append("appDelegate.singleInstance.async.begin") self.scheduleLaunchServicesBundleRegistration() + StartupBreadcrumbLog.append("appDelegate.singleInstance.launchServices.scheduled") self.enforceSingleInstance() self.observeDuplicateLaunches() + StartupBreadcrumbLog.append("appDelegate.singleInstance.async.complete") } } else if forceDuplicateLaunchObserver { // Some UI regressions specifically exercise launch-observer behavior while still @@ -1162,7 +1179,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent SystemWideHotkeyController.shared.start() NSApp.servicesProvider = self + StartupBreadcrumbLog.append("appDelegate.didFinish.bootstrap.begin") scheduleInitialMainWindowBootstrap(debugSource: "didFinishLaunching") + StartupBreadcrumbLog.append("appDelegate.didFinish.complete") #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if env["CMUX_UI_TEST_MODE"] == "1" { @@ -1507,12 +1526,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + StartupBreadcrumbLog.append( + "appDelegate.shouldTerminate.begin", + fields: [ + "taggedDev": SocketControlSettings.isTaggedDevBuild() ? "1" : "0", + "quitWarningConfirmed": isQuitWarningConfirmed ? "1" : "0", + "quitWarningEnabled": QuitWarningSettings.isEnabled() ? "1" : "0" + ] + ) isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) // Tagged DEV builds are ephemeral, skip quit confirmation entirely. if SocketControlSettings.isTaggedDevBuild() { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "taggedDev"]) return .terminateNow } @@ -1520,6 +1548,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (handleQuitShortcutWarning), skip the check to avoid a second alert. if isQuitWarningConfirmed { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "confirmed"]) return .terminateNow } @@ -1527,6 +1556,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // the Cmd+Tab app switcher, bypassing handleCustomShortcut. guard QuitWarningSettings.isEnabled() else { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "warningDisabled"]) return .terminateNow } @@ -1551,12 +1581,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldQuit { self.isQuitWarningConfirmed = true self.closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "1"]) } else { // Reset so that the next quit attempt can show the dialog again. self.isTerminatingApp = false + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "0"]) } NSApp.reply(toApplicationShouldTerminate: shouldQuit) } + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.later") return .terminateLater } @@ -1566,6 +1599,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationWillTerminate(_ notification: Notification) { + StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true closeAllWebInspectorsBeforeAppTeardown() _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) @@ -1583,6 +1617,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ghosttyCrashBreadcrumbTask = nil notificationStore?.clearAll() GhosttyCrashBreadcrumb.markCleanExit() + StartupBreadcrumbLog.append("appDelegate.willTerminate.complete") enableSuddenTerminationIfNeeded() } @@ -13783,25 +13818,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } + guard let bundleId = Bundle.main.bundleIdentifier else { + StartupBreadcrumbLog.append("singleInstance.enforce.skip", fields: ["reason": "missingBundleId"]) + return + } let currentPid = ProcessInfo.processInfo.processIdentifier + var terminatedPids: [String] = [] for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { guard app.processIdentifier != currentPid else { continue } + terminatedPids.append(String(app.processIdentifier)) app.terminate() if !app.isTerminated { _ = app.forceTerminate() } } + StartupBreadcrumbLog.append( + "singleInstance.enforce.complete", + fields: [ + "bundleIdentifier": bundleId, + "currentPid": String(currentPid), + "terminatedPids": terminatedPids.joined(separator: ",") + ] + ) } private func observeDuplicateLaunches() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } + guard let bundleId = Bundle.main.bundleIdentifier else { + StartupBreadcrumbLog.append("singleInstance.observe.skip", fields: ["reason": "missingBundleId"]) + return + } let embeddedCLIURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() let currentPid = ProcessInfo.processInfo.processIdentifier + StartupBreadcrumbLog.append( + "singleInstance.observe.install", + fields: [ + "bundleIdentifier": bundleId, + "currentPid": String(currentPid) + ] + ) workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, @@ -13818,6 +13876,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } + StartupBreadcrumbLog.append( + "singleInstance.observe.terminateDuplicate", + fields: [ + "duplicatePid": String(app.processIdentifier), + "duplicateBundleIdentifier": app.bundleIdentifier ?? "nil" + ] + ) app.terminate() if !app.isTerminated { _ = app.forceTerminate() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3103adbda577..7d3446ac476d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1669,6 +1669,8 @@ class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let fallbackAppearanceConfig = GhosttyConfig() + private static let appRegistryLock = NSLock() + private static var appRegistry: [UInt: GhosttyApp] = [:] private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -2001,10 +2003,11 @@ class GhosttyApp { runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque() runtimeConfig.supports_selection_clipboard = true runtimeConfig.wakeup_cb = { userdata in - GhosttyApp.shared.scheduleTick() + GhosttyApp.runtimeApp(from: userdata)?.scheduleTick() } runtimeConfig.action_cb = { app, target, action in - return GhosttyApp.shared.handleAction(target: target, action: action) + guard let runtimeApp = GhosttyApp.runtimeApp(for: app) else { return false } + return runtimeApp.handleAction(target: target, action: action) } // Some GhosttyKit builds import this callback as returning `Void` in Swift even // though the C ABI returns `bool`. Store the C-compatible shim explicitly so the @@ -2094,6 +2097,7 @@ class GhosttyApp { if let created = ghostty_app_new(&runtimeConfig, primaryConfig) { self.app = created self.config = primaryConfig + Self.registerRuntimeApp(self, for: created) } else { #if DEBUG Self.initLog("ghostty_app_new(primary) failed; attempting fallback config") @@ -2151,6 +2155,7 @@ class GhosttyApp { self.app = created self.config = fallbackConfig + Self.registerRuntimeApp(self, for: created) } // Notify observers that a usable config is available (initial load). @@ -3612,7 +3617,7 @@ class GhosttyApp { scope: .app ) DispatchQueue.main.async { - GhosttyApp.shared.applyBackgroundToKeyWindow() + self.applyBackgroundToKeyWindow() } case GHOSTTY_ACTION_COLOR_KIND_FOREGROUND: applyDefaultBackground( @@ -3753,6 +3758,26 @@ class GhosttyApp { return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } + private static func runtimeApp(from userdata: UnsafeMutableRawPointer?) -> GhosttyApp? { + guard let userdata else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + private static func registerRuntimeApp(_ runtimeApp: GhosttyApp, for app: ghostty_app_t) { + let key = UInt(bitPattern: app) + appRegistryLock.lock() + appRegistry[key] = runtimeApp + appRegistryLock.unlock() + } + + private static func runtimeApp(for app: ghostty_app_t?) -> GhosttyApp? { + guard let app else { return nil } + let key = UInt(bitPattern: app) + appRegistryLock.lock() + defer { appRegistryLock.unlock() } + return appRegistry[key] + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -3805,7 +3830,7 @@ class GhosttyApp { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") performOnMain { - GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") + self.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } @@ -3824,7 +3849,7 @@ class GhosttyApp { scope: .app ) DispatchQueue.main.async { - GhosttyApp.shared.applyBackgroundToKeyWindow() + self.applyBackgroundToKeyWindow() } return true } @@ -4108,7 +4133,7 @@ class GhosttyApp { "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { - GhosttyApp.shared.reloadSurfaceConfiguration(target.target.surface, soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")") + self.reloadSurfaceConfiguration(target.target.surface, soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")") surfaceView.terminalSurface?.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() surfaceView.terminalSurface?.forceRefresh(reason: "surface.reloadConfig") return true diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e9e0cd6f0d0f..6844f3ac82e3 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -23,21 +23,30 @@ struct cmuxApp: App { } init() { + StartupBreadcrumbLog.append("app.init.begin") UITestLaunchManifest.applyIfPresent() + StartupBreadcrumbLog.append("app.init.uiTestManifest.applied") if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + StartupBreadcrumbLog.append("app.init.blockUntaggedDebugLaunch") Self.terminateForMissingLaunchTag() } Self.configureGhosttyEnvironment() + StartupBreadcrumbLog.append("app.init.ghosttyEnvironment.configured") _ = KeyboardShortcutSettings.settingsFileStore + StartupBreadcrumbLog.append("app.init.keyboardShortcuts.loaded") // Apply saved language preference before any UI loads LanguageSettings.apply(LanguageSettings.languageAtLaunch) + StartupBreadcrumbLog.append("app.init.language.applied") let startupAppearance = AppearanceSettings.resolvedMode() Self.applyAppearance(startupAppearance, duringLaunch: true) + StartupBreadcrumbLog.append("app.init.appearance.applied", fields: ["mode": startupAppearance.rawValue]) + StartupBreadcrumbLog.append("app.init.tabManager.begin") _tabManager = StateObject(wrappedValue: TabManager()) + StartupBreadcrumbLog.append("app.init.tabManager.complete") // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { @@ -56,13 +65,18 @@ struct cmuxApp: App { let bundleID = Bundle.main.bundleIdentifier if !SocketControlSettings.isDebugLikeBundleIdentifier(bundleID) && !SocketControlSettings.isStagingBundleIdentifier(bundleID) { + StartupBreadcrumbLog.append("app.init.keychainMigration.begin") SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults) + StartupBreadcrumbLog.append("app.init.keychainMigration.complete") } migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults) + StartupBreadcrumbLog.append("app.init.sidebarDefaults.migrated") // UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance // callbacks (e.g. `.onAppear`) are delayed or skipped. + StartupBreadcrumbLog.append("app.init.delegate.configure.begin") appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + StartupBreadcrumbLog.append("app.init.delegate.configured") } private static func terminateForMissingLaunchTag() -> Never { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index caa3d5f73f49..cb057a98b5c8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; A5001450A5001450A5001450 /* FilePreviewNativeBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001451A5001451A5001451 /* FilePreviewNativeBackground.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; + D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; @@ -686,6 +687,7 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MainWindowVisibilityController.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; + D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+CompatibilityTypes.swift"; sourceTree = ""; }; @@ -1227,6 +1229,7 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */, 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */, A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */, + D35B71010000000000000002 /* StartupBreadcrumbLog.swift */, A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, @@ -1949,6 +1952,7 @@ 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */, 2F0C06000000000000000002 /* MainWindowVisibilityController.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, + D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/scripts/build-command-palette-nucleo-ffi.sh b/scripts/build-command-palette-nucleo-ffi.sh index da386e3d6f46..e40bcaf02e54 100755 --- a/scripts/build-command-palette-nucleo-ffi.sh +++ b/scripts/build-command-palette-nucleo-ffi.sh @@ -103,6 +103,8 @@ else lipo -create -output "${SOURCE_LIB}" "${libs[@]}" fi +/usr/bin/install_name_tool -id "@rpath/${LIB_NAME}" "${SOURCE_LIB}" + if [ -z "${TARGET_BUILD_DIR:-}" ]; then echo "built ${SOURCE_LIB}" exit 0 diff --git a/scripts/sign-cmux-bundle.sh b/scripts/sign-cmux-bundle.sh index a129eaf4d7bb..4372622aad06 100755 --- a/scripts/sign-cmux-bundle.sh +++ b/scripts/sign-cmux-bundle.sh @@ -26,6 +26,7 @@ # notarized macOS 26 Tahoe rejects with errno 163. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ $# -lt 3 ]]; then echo "usage: $0 " >&2 @@ -87,6 +88,7 @@ echo "==> signing main bundle" echo "==> verifying" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" +"$SCRIPT_DIR/verify-command-palette-nucleo-ffi-artifact.sh" "$APP_PATH" APP_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.application-identifier" \ /dev/stdin <<<"$(plutil -convert xml1 -o - "$APP_ENTITLEMENTS")" 2>/dev/null || true)" diff --git a/scripts/smoke-launch-macos-app.sh b/scripts/smoke-launch-macos-app.sh new file mode 100755 index 000000000000..7bb9491f517c --- /dev/null +++ b/scripts/smoke-launch-macos-app.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +APP_PATH="$1" +if [[ ! -d "$APP_PATH/Contents" ]]; then + echo "error: app bundle not found at $APP_PATH" >&2 + exit 1 +fi + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INFO_PLIST")" +EXECUTABLE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO_PLIST")" +EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +STARTUP_TIMEOUT_SECONDS="${CMUX_SMOKE_STARTUP_TIMEOUT_SECONDS:-10}" +STABLE_SECONDS="${CMUX_SMOKE_STABLE_SECONDS:-5}" +OPEN_LOG="$(mktemp /tmp/cmux-smoke-open.XXXXXX.log)" +APP_PID="" + +cleanup() { + if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" 2>/dev/null || true + fi + rm -f "$OPEN_LOG" +} +trap cleanup EXIT + +find_app_pid() { + pgrep -f "$EXECUTABLE_PATH" 2>/dev/null | head -n 1 || true +} + +echo "==> smoke launching $APP_PATH" +/usr/bin/open -n -g "$APP_PATH" --args -ApplePersistenceIgnoreState YES >"$OPEN_LOG" 2>&1 & +OPEN_PID=$! + +deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) +while (( SECONDS < deadline )); do + APP_PID="$(find_app_pid)" + if [[ -n "$APP_PID" ]]; then + break + fi + if ! kill -0 "$OPEN_PID" 2>/dev/null; then + wait "$OPEN_PID" || true + fi + sleep 0.2 +done + +if [[ -z "$APP_PID" ]]; then + echo "error: app process did not appear for bundle $BUNDLE_ID within ${STARTUP_TIMEOUT_SECONDS}s" >&2 + if [[ -s "$OPEN_LOG" ]]; then + cat "$OPEN_LOG" >&2 + fi + exit 1 +fi + +for _ in $(seq 1 "$STABLE_SECONDS"); do + sleep 1 + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "error: app process $APP_PID exited during ${STABLE_SECONDS}s launch smoke" >&2 + if [[ -s "$OPEN_LOG" ]]; then + cat "$OPEN_LOG" >&2 + fi + LOG_NAME="$(printf '%s' "$BUNDLE_ID" | sed -E 's/[^A-Za-z0-9._-]/-/g')" + STARTUP_LOG="$HOME/Library/Logs/cmux/startup-${LOG_NAME}.log" + if [[ -f "$STARTUP_LOG" ]]; then + echo "startup breadcrumbs:" >&2 + tail -n 80 "$STARTUP_LOG" >&2 || true + fi + /usr/bin/log show --last 2m --style compact --predicate "process == '$EXECUTABLE_NAME' OR eventMessage CONTAINS '$BUNDLE_ID'" 2>/dev/null | tail -n 160 >&2 || true + exit 1 + fi +done + +echo "==> launch smoke OK: pid $APP_PID stayed alive for ${STABLE_SECONDS}s" diff --git a/scripts/verify-command-palette-nucleo-ffi-artifact.sh b/scripts/verify-command-palette-nucleo-ffi-artifact.sh new file mode 100755 index 000000000000..a895284b4a16 --- /dev/null +++ b/scripts/verify-command-palette-nucleo-ffi-artifact.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +TARGET="$1" +LIB_NAME="libcmux_command_palette_nucleo_ffi.dylib" + +if [[ -d "$TARGET/Contents" ]]; then + DYLIB="$TARGET/Contents/Frameworks/$LIB_NAME" +else + DYLIB="$TARGET" +fi + +if [[ ! -f "$DYLIB" ]]; then + echo "error: missing bundled Nucleo FFI library at $DYLIB" >&2 + exit 1 +fi + +install_names=() +while IFS= read -r install_name; do + install_names+=("$install_name") +done < <(/usr/bin/otool -D "$DYLIB" | awk 'NF && $0 !~ /:$/ { print $1 }') + +if [[ "${#install_names[@]}" -eq 0 ]]; then + echo "error: could not read install name from $DYLIB" >&2 + exit 1 +fi + +for install_name in "${install_names[@]}"; do + if [[ "$install_name" != "@rpath/$LIB_NAME" ]]; then + echo "error: $LIB_NAME has invalid install name: $install_name" >&2 + echo "expected: @rpath/$LIB_NAME" >&2 + exit 1 + fi +done + +if /usr/bin/otool -L "$DYLIB" | grep -E '/Users/runner/work|/Native/CommandPaletteNucleoFFI/target|/target/(aarch64|x86_64)-apple-darwin/' >/dev/null; then + echo "error: $LIB_NAME contains CI/source-tree absolute load paths" >&2 + /usr/bin/otool -L "$DYLIB" >&2 + exit 1 +fi + +echo "Nucleo FFI artifact OK: $DYLIB" From e305c73d814752fa9abf2c0b2f34c11a1c14b61e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 16:39:42 -0700 Subject: [PATCH 2/6] Address launch smoke review feedback --- Sources/App/StartupBreadcrumbLog.swift | 25 +++++++++++++++++++------ Sources/GhosttyTerminalView.swift | 2 ++ scripts/smoke-launch-macos-app.sh | 17 ++++++++++++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift index 596e8ecea398..7410b8724f1f 100644 --- a/Sources/App/StartupBreadcrumbLog.swift +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -1,15 +1,20 @@ +import Darwin import Foundation enum StartupBreadcrumbLog { - private static let lock = NSLock() private static let maxFieldLength = 240 + private static let reservedFieldKeys: Set = [ + "timestamp", + "event", + "pid", + "bundleIdentifier", + "appVersion", + "build" + ] static func append(_ event: String, fields: [String: String] = [:]) { guard isEnabled else { return } - lock.lock() - defer { lock.unlock() } - var payload: [String: Any] = [ "timestamp": ISO8601DateFormatter().string(from: Date()), "event": event, @@ -20,7 +25,8 @@ enum StartupBreadcrumbLog { ] for (key, value) in fields { - payload[key] = sanitized(value) + let payloadKey = reservedFieldKeys.contains(key) ? "custom_\(key)" : key + payload[payloadKey] = sanitized(value) } do { @@ -35,6 +41,12 @@ enum StartupBreadcrumbLog { let line = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) let handle = try FileHandle(forWritingTo: url) defer { try? handle.close() } + guard flock(handle.fileDescriptor, LOCK_EX) == 0 else { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO + throw POSIXError(code) + } + defer { flock(handle.fileDescriptor, LOCK_UN) } + // Startup breadcrumbs are synchronous so the last edge survives immediate launch aborts. try handle.seekToEnd() try handle.write(contentsOf: line) try handle.write(contentsOf: Data([0x0A])) @@ -54,7 +66,8 @@ enum StartupBreadcrumbLog { let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" return bundleIdentifier == "com.cmuxterm.app.nightly" || bundleIdentifier.hasPrefix("com.cmuxterm.app.nightly.") - || bundleIdentifier.hasPrefix("com.cmuxterm.app.dev.") + || bundleIdentifier == "com.cmuxterm.app.debug" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } private static var logURL: URL { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7d3446ac476d..2451d9685ee8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1669,6 +1669,8 @@ class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let fallbackAppearanceConfig = GhosttyConfig() + // Ghostty C callbacks can run while GhosttyApp.shared is still initializing. + // The registry resolves the process-lifetime app instance without re-entering the singleton. private static let appRegistryLock = NSLock() private static var appRegistry: [UInt: GhosttyApp] = [:] private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { diff --git a/scripts/smoke-launch-macos-app.sh b/scripts/smoke-launch-macos-app.sh index 7bb9491f517c..839d2565ed33 100755 --- a/scripts/smoke-launch-macos-app.sh +++ b/scripts/smoke-launch-macos-app.sh @@ -20,6 +20,7 @@ STARTUP_TIMEOUT_SECONDS="${CMUX_SMOKE_STARTUP_TIMEOUT_SECONDS:-10}" STABLE_SECONDS="${CMUX_SMOKE_STABLE_SECONDS:-5}" OPEN_LOG="$(mktemp /tmp/cmux-smoke-open.XXXXXX.log)" APP_PID="" +PREEXISTING_PIDS="$(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true)" cleanup() { if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then @@ -29,17 +30,27 @@ cleanup() { } trap cleanup EXIT -find_app_pid() { - pgrep -f "$EXECUTABLE_PATH" 2>/dev/null | head -n 1 || true +find_new_app_pid() { + local pid + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + if ! printf '%s\n' "$PREEXISTING_PIDS" | grep -Fxq "$pid"; then + printf '%s\n' "$pid" + return 0 + fi + done < <(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true) + return 1 } echo "==> smoke launching $APP_PATH" /usr/bin/open -n -g "$APP_PATH" --args -ApplePersistenceIgnoreState YES >"$OPEN_LOG" 2>&1 & OPEN_PID=$! +# CI-only LaunchServices smoke: open returns before the app process is visible. +# Use bounded polling to wait for registration, then a bounded liveness window. deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) while (( SECONDS < deadline )); do - APP_PID="$(find_app_pid)" + APP_PID="$(find_new_app_pid || true)" if [[ -n "$APP_PID" ]]; then break fi From 391914b842f0660fe813c52671d4254dbe0d72ef Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 16:44:13 -0700 Subject: [PATCH 3/6] Tighten startup breadcrumb logging --- Sources/App/StartupBreadcrumbLog.swift | 4 +++- Sources/GhosttyTerminalView.swift | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift index 7410b8724f1f..1ad3b7520942 100644 --- a/Sources/App/StartupBreadcrumbLog.swift +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -1,8 +1,10 @@ import Darwin import Foundation +import os enum StartupBreadcrumbLog { private static let maxFieldLength = 240 + private nonisolated static let logger = Logger(subsystem: "com.cmuxterm.app", category: "StartupBreadcrumbLog") private static let reservedFieldKeys: Set = [ "timestamp", "event", @@ -51,7 +53,7 @@ enum StartupBreadcrumbLog { try handle.write(contentsOf: line) try handle.write(contentsOf: Data([0x0A])) } catch { - NSLog("cmux startup breadcrumb failed: %@", String(describing: error)) + logger.fault("cmux startup breadcrumb failed: \(String(describing: error), privacy: .public)") } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2451d9685ee8..e18029337be6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1669,8 +1669,9 @@ class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let fallbackAppearanceConfig = GhosttyConfig() - // Ghostty C callbacks can run while GhosttyApp.shared is still initializing. - // The registry resolves the process-lifetime app instance without re-entering the singleton. + // SAFETY: Ghostty C callbacks can run while GhosttyApp.shared is still initializing. + // cmux owns one process-lifetime GhosttyApp, so the registry avoids singleton re-entry + // without adding a teardown path for a ghostty_app_t that is never freed/recreated. private static let appRegistryLock = NSLock() private static var appRegistry: [UInt: GhosttyApp] = [:] private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { From b76b500ddbb4f2fe62138890d04103fdcb35bedd Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 17:03:04 -0700 Subject: [PATCH 4/6] Harden Ghostty startup callback routing --- Sources/GhosttyTerminalView.swift | 21 ++++++++++++++++++++- scripts/smoke-launch-macos-app.sh | 30 +++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e18029337be6..208b0e800385 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1674,6 +1674,7 @@ class GhosttyApp { // without adding a teardown path for a ghostty_app_t that is never freed/recreated. private static let appRegistryLock = NSLock() private static var appRegistry: [UInt: GhosttyApp] = [:] + private static var initializingRuntimeApp: GhosttyApp? private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -2009,7 +2010,7 @@ class GhosttyApp { GhosttyApp.runtimeApp(from: userdata)?.scheduleTick() } runtimeConfig.action_cb = { app, target, action in - guard let runtimeApp = GhosttyApp.runtimeApp(for: app) else { return false } + guard let runtimeApp = GhosttyApp.runtimeAppForActionCallback(app) else { return false } return runtimeApp.handleAction(target: target, action: action) } // Some GhosttyKit builds import this callback as returning `Void` in Swift even @@ -2097,6 +2098,9 @@ class GhosttyApp { } // Create app + Self.setInitializingRuntimeApp(self) + defer { Self.setInitializingRuntimeApp(nil) } + if let created = ghostty_app_new(&runtimeConfig, primaryConfig) { self.app = created self.config = primaryConfig @@ -3773,6 +3777,12 @@ class GhosttyApp { appRegistryLock.unlock() } + private static func setInitializingRuntimeApp(_ runtimeApp: GhosttyApp?) { + appRegistryLock.lock() + initializingRuntimeApp = runtimeApp + appRegistryLock.unlock() + } + private static func runtimeApp(for app: ghostty_app_t?) -> GhosttyApp? { guard let app else { return nil } let key = UInt(bitPattern: app) @@ -3781,6 +3791,15 @@ class GhosttyApp { return appRegistry[key] } + private static func runtimeAppForActionCallback(_ app: ghostty_app_t?) -> GhosttyApp? { + if let registered = runtimeApp(for: app) { + return registered + } + appRegistryLock.lock() + defer { appRegistryLock.unlock() } + return initializingRuntimeApp + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || diff --git a/scripts/smoke-launch-macos-app.sh b/scripts/smoke-launch-macos-app.sh index 839d2565ed33..fa1e0d39b937 100755 --- a/scripts/smoke-launch-macos-app.sh +++ b/scripts/smoke-launch-macos-app.sh @@ -21,6 +21,7 @@ STABLE_SECONDS="${CMUX_SMOKE_STABLE_SECONDS:-5}" OPEN_LOG="$(mktemp /tmp/cmux-smoke-open.XXXXXX.log)" APP_PID="" PREEXISTING_PIDS="$(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true)" +DEBUG_LOGS="${CMUX_SMOKE_DEBUG_LOGS:-0}" cleanup() { if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then @@ -42,6 +43,25 @@ find_new_app_pid() { return 1 } +dump_open_log() { + if [[ ! -s "$OPEN_LOG" ]]; then + return + fi + if [[ "$DEBUG_LOGS" == "1" ]]; then + cat "$OPEN_LOG" >&2 + else + echo "open launcher output captured (set CMUX_SMOKE_DEBUG_LOGS=1 to print)" >&2 + fi +} + +dump_system_log() { + if [[ "$DEBUG_LOGS" == "1" ]]; then + /usr/bin/log show --last 2m --style compact --predicate "process == '$EXECUTABLE_NAME' OR eventMessage CONTAINS '$BUNDLE_ID'" 2>/dev/null | tail -n 160 >&2 || true + else + echo "system log capture skipped (set CMUX_SMOKE_DEBUG_LOGS=1 to print)" >&2 + fi +} + echo "==> smoke launching $APP_PATH" /usr/bin/open -n -g "$APP_PATH" --args -ApplePersistenceIgnoreState YES >"$OPEN_LOG" 2>&1 & OPEN_PID=$! @@ -62,9 +82,7 @@ done if [[ -z "$APP_PID" ]]; then echo "error: app process did not appear for bundle $BUNDLE_ID within ${STARTUP_TIMEOUT_SECONDS}s" >&2 - if [[ -s "$OPEN_LOG" ]]; then - cat "$OPEN_LOG" >&2 - fi + dump_open_log exit 1 fi @@ -72,16 +90,14 @@ for _ in $(seq 1 "$STABLE_SECONDS"); do sleep 1 if ! kill -0 "$APP_PID" 2>/dev/null; then echo "error: app process $APP_PID exited during ${STABLE_SECONDS}s launch smoke" >&2 - if [[ -s "$OPEN_LOG" ]]; then - cat "$OPEN_LOG" >&2 - fi + dump_open_log LOG_NAME="$(printf '%s' "$BUNDLE_ID" | sed -E 's/[^A-Za-z0-9._-]/-/g')" STARTUP_LOG="$HOME/Library/Logs/cmux/startup-${LOG_NAME}.log" if [[ -f "$STARTUP_LOG" ]]; then echo "startup breadcrumbs:" >&2 tail -n 80 "$STARTUP_LOG" >&2 || true fi - /usr/bin/log show --last 2m --style compact --predicate "process == '$EXECUTABLE_NAME' OR eventMessage CONTAINS '$BUNDLE_ID'" 2>/dev/null | tail -n 160 >&2 || true + dump_system_log exit 1 fi done From 0fb89e65c21820673901d113935e4905d584a30b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 17:17:45 -0700 Subject: [PATCH 5/6] Fix smoke verification edge cases --- scripts/smoke-launch-macos-app.sh | 2 +- scripts/verify-command-palette-nucleo-ffi-artifact.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-launch-macos-app.sh b/scripts/smoke-launch-macos-app.sh index fa1e0d39b937..9f9943a78d1a 100755 --- a/scripts/smoke-launch-macos-app.sh +++ b/scripts/smoke-launch-macos-app.sh @@ -18,7 +18,7 @@ EXECUTABLE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" STARTUP_TIMEOUT_SECONDS="${CMUX_SMOKE_STARTUP_TIMEOUT_SECONDS:-10}" STABLE_SECONDS="${CMUX_SMOKE_STABLE_SECONDS:-5}" -OPEN_LOG="$(mktemp /tmp/cmux-smoke-open.XXXXXX.log)" +OPEN_LOG="$(mktemp -t cmux-smoke-open.XXXXXX)" APP_PID="" PREEXISTING_PIDS="$(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true)" DEBUG_LOGS="${CMUX_SMOKE_DEBUG_LOGS:-0}" diff --git a/scripts/verify-command-palette-nucleo-ffi-artifact.sh b/scripts/verify-command-palette-nucleo-ffi-artifact.sh index a895284b4a16..94af4fc7bcc0 100755 --- a/scripts/verify-command-palette-nucleo-ffi-artifact.sh +++ b/scripts/verify-command-palette-nucleo-ffi-artifact.sh @@ -38,7 +38,7 @@ for install_name in "${install_names[@]}"; do fi done -if /usr/bin/otool -L "$DYLIB" | grep -E '/Users/runner/work|/Native/CommandPaletteNucleoFFI/target|/target/(aarch64|x86_64)-apple-darwin/' >/dev/null; then +if /usr/bin/otool -L "$DYLIB" | awk 'NR > 1 && NF { print $1 }' | grep -E '/Users/runner/work|/Native/CommandPaletteNucleoFFI/target|/target/(aarch64|x86_64)-apple-darwin/' >/dev/null; then echo "error: $LIB_NAME contains CI/source-tree absolute load paths" >&2 /usr/bin/otool -L "$DYLIB" >&2 exit 1 From 7ee42f6f853f1e2924a0dd2d4d6d8e01ca023f19 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 18 May 2026 17:33:37 -0700 Subject: [PATCH 6/6] Address final launch review feedback --- Sources/App/StartupBreadcrumbLog.swift | 11 ++++++++--- Sources/GhosttyTerminalView.swift | 9 ++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift index 1ad3b7520942..21d25957cb08 100644 --- a/Sources/App/StartupBreadcrumbLog.swift +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -78,12 +78,17 @@ enum StartupBreadcrumbLog { .appendingPathComponent("Logs/cmux", isDirectory: true) ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("cmux-logs", isDirectory: true) - let bundleIdentifier = Bundle.main.bundleIdentifier ?? "unknown" - let sanitizedBundleIdentifier = sanitized(bundleIdentifier, maxLength: 160) - .replacingOccurrences(of: "/", with: "-") + let sanitizedBundleIdentifier = logFileComponent(Bundle.main.bundleIdentifier ?? "unknown") return logsDirectory.appendingPathComponent("startup-\(sanitizedBundleIdentifier).log") } + private static func logFileComponent(_ value: String) -> String { + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-") + return sanitized(value, maxLength: 160).unicodeScalars.map { scalar in + allowed.contains(scalar) ? String(scalar) : "-" + }.joined() + } + private static func sanitized(_ value: String, maxLength: Int = maxFieldLength) -> String { let flattened = value .replacingOccurrences(of: "\n", with: "\\n") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 208b0e800385..669f7203b82b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3792,11 +3792,14 @@ class GhosttyApp { } private static func runtimeAppForActionCallback(_ app: ghostty_app_t?) -> GhosttyApp? { - if let registered = runtimeApp(for: app) { - return registered - } appRegistryLock.lock() defer { appRegistryLock.unlock() } + if let app { + let key = UInt(bitPattern: app) + if let registered = appRegistry[key] { + return registered + } + } return initializingRuntimeApp }