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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
101 changes: 101 additions & 0 deletions Sources/App/StartupBreadcrumbLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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<String> = [
"timestamp",
"event",
"pid",
"bundleIdentifier",
"appVersion",
"build"
]

static func append(_ event: String, fields: [String: String] = [:]) {
guard isEnabled else { return }

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 {
let payloadKey = reservedFieldKeys.contains(key) ? "custom_\(key)" : key
payload[payloadKey] = sanitized(value)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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() }
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]))
} catch {
logger.fault("cmux startup breadcrumb failed: \(String(describing: error), privacy: .public)")
}
Comment thread
lawrencecchen marked this conversation as resolved.
}

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 == "com.cmuxterm.app.debug"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}

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 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")
.replacingOccurrences(of: "\r", with: "\\r")
if flattened.count <= maxLength {
return flattened
}
return String(flattened.prefix(maxLength)) + "...<truncated>"
}
}
69 changes: 67 additions & 2 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -1507,26 +1526,37 @@ 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
}

// If the user already confirmed via the Cmd+Q shortcut warning dialog
// (handleQuitShortcutWarning), skip the check to avoid a second alert.
if isQuitWarningConfirmed {
closeAllWebInspectorsBeforeAppTeardown()
StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "confirmed"])
return .terminateNow
}

// Respect the "Warn Before Quit" setting even when Cmd+Q arrives via
// the Cmd+Tab app switcher, bypassing handleCustomShortcut.
guard QuitWarningSettings.isEnabled() else {
closeAllWebInspectorsBeforeAppTeardown()
StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "warningDisabled"])
return .terminateNow
}

Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -1583,6 +1617,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
ghosttyCrashBreadcrumbTask = nil
notificationStore?.clearAll()
GhosttyCrashBreadcrumb.markCleanExit()
StartupBreadcrumbLog.append("appDelegate.willTerminate.complete")
enableSuddenTerminationIfNeeded()
}

Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
Loading
Loading