Skip to content
Closed
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
255 changes: 41 additions & 214 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -672,10 +672,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
/// Durable navigation links that arrived before startup restore registered
/// their target workspaces.
var pendingStartupNavigationURLRequests: [CmuxNavigationURLRequest] = []
private var sessionAutosaveTimer: DispatchSourceTimer?
private var sessionAutosaveTickInFlight = false
private var sessionAutosaveDeferredRetryPending = false
private var processDetectedSessionSaveGeneration: UInt64 = 0
private let sessionPersistenceQueue = DispatchQueue(
label: "com.cmuxterm.app.sessionPersistence",
qos: .utility
Expand All @@ -690,6 +686,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
store: sessionSnapshotStore,
queue: sessionPersistenceQueue
)
private lazy var sessionAutosaveCoordinator = SessionAutosaveCoordinator(
isTerminatingApp: { [weak self] in self?.isTerminatingApp ?? true },
isStartupSessionRestorePending: { [weak self] in
guard let self else { return true }
return !self.didAttemptStartupSessionRestore
},
fingerprint: { [weak self] restorableAgentIndex, surfaceResumeBindingIndex in
self?.sessionAutosaveFingerprint(
includeScrollback: false,
restorableAgentIndex: restorableAgentIndex,
surfaceResumeBindingIndex: surfaceResumeBindingIndex
)
},
save: { [weak self] restorableAgentIndex, surfaceResumeBindingIndex in
guard let self else { return false }
return self.saveSessionSnapshot(
includeScrollback: false,
restorableAgentIndex: restorableAgentIndex,
surfaceResumeBindingIndex: surfaceResumeBindingIndex
)
}
)
/// Accessibility window-hierarchy cache (CmuxWindowing); composition-root
/// owned. The `NSApplication` AX swizzle forwards to it behind
/// ``AccessibilityWindowCaching``.
Expand All @@ -708,9 +726,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) {
launchServicesRegistrationQueue.async(execute: work)
}
private var lastSessionAutosaveFingerprint: Int?
private var lastSessionAutosavePersistedAt: Date = .distantPast
private var lastTypingActivityAt: TimeInterval = 0
var didHandleExplicitOpenIntentAtStartup = false
private var didScheduleInitialMainWindowBootstrap = false
var shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false
Expand Down Expand Up @@ -760,7 +775,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var didDisableSuddenTermination = false
/// Owns the per-window command-palette state.
let commandPaletteWindowStore = CommandPaletteWindowStore()
private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65
private let mainThreadHangWatchdog: MainThreadHangWatchdog

var updateViewModel: UpdateStateModel {
Expand Down Expand Up @@ -1741,7 +1755,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
PresenceHeartbeatClient.shared.appWillTerminate()
connectivityInvalidationSubscriberCoordinator.appWillTerminate()
closeAllWebInspectorsBeforeAppTeardown()
stopSessionAutosaveTimer()
sessionAutosaveCoordinator.stop()
CloudVMActionLauncher.shared.terminateAll()
CmuxSSHURLProcessLauncher.shared.terminateAll()
MobileHostService.shared.stop()
Expand Down Expand Up @@ -1829,7 +1843,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
lastVisibleFrameFitTopologySignature = MainWindowVisibleFrameFitCore()
.trustedTopologySignature(of: currentDisplayGeometries().available)
prepareStartupSessionSnapshotIfNeeded()
startSessionAutosaveTimerIfNeeded()
sessionAutosaveCoordinator.startIfNeeded()
#if DEBUG
setupJumpUnreadUITestIfNeeded()
terminalCmdClickUITestCoordinator.startIfNeeded()
Expand Down Expand Up @@ -2580,35 +2594,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
&& abs(lhsStd.size.height - rhsStd.size.height) <= tolerance
}

private func startSessionAutosaveTimerIfNeeded() {
guard sessionAutosaveTimer == nil else { return }
let env = ProcessInfo.processInfo.environment
guard !isRunningUnderXCTest(env) else { return }

let timer = DispatchSource.makeTimerSource(queue: .main)
let interval = SessionPersistencePolicy.autosaveInterval
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
timer.setEventHandler { [weak self] in
guard let self,
Self.shouldRunSessionAutosaveTick(
isTerminatingApp: self.isTerminatingApp,
isStartupSessionRestorePending: !self.didAttemptStartupSessionRestore
) else {
return
}
self.runSessionAutosaveTick(source: "timer")
}
sessionAutosaveTimer = timer
timer.resume()
}

private func stopSessionAutosaveTimer() {
sessionAutosaveTimer?.cancel()
sessionAutosaveTimer = nil
sessionAutosaveTickInFlight = false
sessionAutosaveDeferredRetryPending = false
}

private func installLifecycleSnapshotObserversIfNeeded() {
guard !didInstallLifecycleSnapshotObservers else { return }
didInstallLifecycleSnapshotObservers = true
Expand Down Expand Up @@ -2914,7 +2899,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
isTerminatingApp: Bool,
isStartupSessionRestorePending: Bool
) -> Bool {
!isTerminatingApp && !isStartupSessionRestorePending
SessionAutosaveCoordinator.shouldRunSessionAutosaveTick(
isTerminatingApp: isTerminatingApp,
isStartupSessionRestorePending: isStartupSessionRestorePending
)
}

nonisolated static func shouldSaveSessionSnapshotOnApplicationResign(isTerminatingApp _: Bool) -> Bool {
Expand All @@ -2923,147 +2911,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
false
}

private func remainingSessionAutosaveTypingQuietPeriod(
nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime
) -> TimeInterval? {
guard lastTypingActivityAt > 0 else { return nil }
let elapsed = nowUptime - lastTypingActivityAt
guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil }
return Self.sessionAutosaveTypingQuietPeriod - elapsed
}

private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) {
guard delay.isFinite, delay > 0 else { return }
guard !sessionAutosaveDeferredRetryPending else { return }
sessionAutosaveDeferredRetryPending = true
sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.sessionAutosaveDeferredRetryPending = false
self.runSessionAutosaveTick(source: "typingQuietRetry")
}
}
}

private func runSessionAutosaveTick(source: String) {
guard Self.shouldRunSessionAutosaveTick(
isTerminatingApp: isTerminatingApp,
isStartupSessionRestorePending: !didAttemptStartupSessionRestore
) else {
return
}
guard !sessionAutosaveTickInFlight else { return }
if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() {
#if DEBUG
cmuxDebugLog(
"session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " +
"retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))"
)
#endif
scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod)
return
}

sessionAutosaveTickInFlight = true
let generation = nextProcessDetectedSessionSaveGeneration()
Task { @MainActor in await self.finishSessionAutosaveTick(source: source, generation: generation) }
}

private func finishSessionAutosaveTick(source: String, generation: UInt64) async {
#if DEBUG
let timingStart = CmuxTypingTiming.start()
let phaseStart = ProcessInfo.processInfo.systemUptime
var loadMs: Double = 0
var fingerprintMs: Double = 0
var saveMs: Double = 0
defer {
sessionAutosaveTickInFlight = false
let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0
CmuxTypingTiming.logBreakdown(
path: "session.autosaveTick.phase",
totalMs: totalMs,
thresholdMs: 2.0,
parts: [
// loadMs is await wall time on a detached utility task, not
// main-thread blocking; fingerprintMs and saveMs are the
// synchronous main-thread portions.
("loadMs", loadMs),
("fingerprintMs", fingerprintMs),
("saveMs", saveMs),
],
extra: "source=\(source)"
)
CmuxTypingTiming.logDuration(
path: "session.autosaveTick",
startedAt: timingStart,
extra: "source=\(source)"
)
}
#else
defer { sessionAutosaveTickInFlight = false }
#endif

let now = Date()
#if DEBUG
let loadStart = ProcessInfo.processInfo.systemUptime
#endif
let resumeIndexes = await ProcessDetectedResumeIndexes.load()
#if DEBUG
loadMs = (ProcessInfo.processInfo.systemUptime - loadStart) * 1000.0
let fingerprintStart = ProcessInfo.processInfo.systemUptime
#endif
guard !isTerminatingApp,
isCurrentProcessDetectedSessionSaveGeneration(generation) else {
#if DEBUG
cmuxDebugLog(
"session.save.skipped reason=stale_process_detected_scan includeScrollback=0 source=\(source)"
)
#endif
return
}
let autosaveFingerprint = sessionAutosaveFingerprint(
includeScrollback: false,
restorableAgentIndex: resumeIndexes.restorableAgentIndex,
surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex
)
#if DEBUG
fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0
#endif
if Self.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: isTerminatingApp,
includeScrollback: false,
previousFingerprint: lastSessionAutosaveFingerprint,
currentFingerprint: autosaveFingerprint,
lastPersistedAt: lastSessionAutosavePersistedAt,
now: now
) {
#if DEBUG
cmuxDebugLog(
"session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)"
)
#endif
return
}

#if DEBUG
let saveStart = ProcessInfo.processInfo.systemUptime
#endif
let didSave = saveSessionSnapshot(
includeScrollback: false,
restorableAgentIndex: resumeIndexes.restorableAgentIndex,
surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex
)
#if DEBUG
saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0
#endif
guard didSave else { return }
updateSessionAutosaveSaveState(
includeScrollback: false,
persistedAt: now,
fingerprint: autosaveFingerprint
)
}

@discardableResult
private func saveSessionSnapshotUsingCachedProcessDetectedIndexes(
includeScrollback: Bool,
Expand Down Expand Up @@ -3106,12 +2953,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
removeWhenEmpty: Bool = false,
preserveManualRestoreBackupOnMissingPrimary: Bool = false
) {
let generation = nextProcessDetectedSessionSaveGeneration()
let generation = sessionAutosaveCoordinator.nextProcessDetectedSaveGeneration()
Task { @MainActor [weak self] in
let resumeIndexes = await ProcessDetectedResumeIndexes.load()
guard let self,
!self.isTerminatingApp,
self.isCurrentProcessDetectedSessionSaveGeneration(generation) else { return }
self.sessionAutosaveCoordinator.isCurrentProcessDetectedSaveGeneration(generation) else { return }
_ = self.saveSessionSnapshot(
includeScrollback: includeScrollback,
removeWhenEmpty: removeWhenEmpty,
Expand All @@ -3122,18 +2969,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

@discardableResult
private func nextProcessDetectedSessionSaveGeneration() -> UInt64 {
processDetectedSessionSaveGeneration &+= 1
return processDetectedSessionSaveGeneration
}

private func isCurrentProcessDetectedSessionSaveGeneration(_ generation: UInt64) -> Bool {
generation == processDetectedSessionSaveGeneration
}

fileprivate func recordTypingActivity() {
lastTypingActivityAt = ProcessInfo.processInfo.systemUptime
sessionAutosaveCoordinator.recordTypingActivity()
}

nonisolated static func shouldWriteSessionSnapshotSynchronously(
Expand All @@ -3152,25 +2989,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
now: Date,
maximumAutosaveSkippableInterval: TimeInterval = 60
) -> Bool {
guard !isTerminatingApp,
!includeScrollback,
let previousFingerprint,
let currentFingerprint,
previousFingerprint == currentFingerprint else {
return false
}

return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval
}

private func updateSessionAutosaveSaveState(
includeScrollback: Bool,
persistedAt: Date,
fingerprint: Int?
) {
guard !isTerminatingApp, !includeScrollback else { return }
lastSessionAutosaveFingerprint = fingerprint
lastSessionAutosavePersistedAt = persistedAt
SessionAutosaveCoordinator.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: isTerminatingApp,
includeScrollback: includeScrollback,
previousFingerprint: previousFingerprint,
currentFingerprint: currentFingerprint,
lastPersistedAt: lastPersistedAt,
now: now,
maximumAutosaveSkippableInterval: maximumAutosaveSkippableInterval
)
}

private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) {
Expand Down Expand Up @@ -16389,7 +16216,7 @@ private extension NSWindow {
CmuxTypingTiming.logEventDelay(path: "window.sendEvent", event: event)
}
#endif
// recordTypingActivity must run in all builds so runSessionAutosaveTick
// recordTypingActivity runs in all builds so the autosave coordinator
// can honor the typing quiet period in release.
if event.type == .keyDown, let app = AppDelegate.shared, cmuxCloseFocusedTerminalFindForEscape(event: event, appDelegate: app) { return }
if event.type == .keyDown { AppDelegate.shared?.recordTypingActivity() }
Expand Down
Loading