Add workspace stress profiling and reduce switch churn (#1218)
* Add workspace stress profiling and reduce switch churn * Address workspace stress review feedback
This commit is contained in:
parent
d732a2c1b1
commit
f95a32ea52
4 changed files with 426 additions and 23 deletions
|
|
@ -93,6 +93,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
|
||||
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
|
||||
|
|
@ -238,6 +239,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
|
|
@ -472,6 +474,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
|
||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */,
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */,
|
||||
);
|
||||
|
|
@ -711,6 +714,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
|
||||
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1331,6 +1331,7 @@ struct ContentView: View {
|
|||
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||
@State private var didApplyUITestSidebarSelection = false
|
||||
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
|
||||
@State private var titlebarThemeGeneration: UInt64 = 0
|
||||
@State private var sidebarDraggedTabId: UUID?
|
||||
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
|
|
@ -2881,6 +2882,8 @@ struct ContentView: View {
|
|||
retiringWorkspaceId = nil
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2888,6 +2891,7 @@ struct ContentView: View {
|
|||
let generation = workspaceHandoffGeneration
|
||||
retiringWorkspaceId = oldSelectedId
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
|
|
@ -2903,6 +2907,36 @@ struct ContentView: View {
|
|||
}
|
||||
#endif
|
||||
|
||||
workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
|
||||
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
|
||||
if delay > 0 {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(delay))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
let completed = await MainActor.run { () -> Bool in
|
||||
guard workspaceHandoffGeneration == generation else { return false }
|
||||
guard retiringWorkspaceId != nil else { return false }
|
||||
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog(
|
||||
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
|
||||
)
|
||||
} else {
|
||||
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
|
||||
}
|
||||
#endif
|
||||
completeWorkspaceHandoff(reason: "ready")
|
||||
return true
|
||||
}
|
||||
if completed { return }
|
||||
}
|
||||
}
|
||||
|
||||
workspaceHandoffFallbackTask = Task { [generation] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 150_000_000)
|
||||
|
|
@ -2922,9 +2956,20 @@ struct ContentView: View {
|
|||
completeWorkspaceHandoff(reason: reason)
|
||||
}
|
||||
|
||||
private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool {
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true }
|
||||
if let focusedPanelId = workspace.focusedPanelId,
|
||||
workspace.browserPanel(for: focusedPanelId) != nil {
|
||||
return true
|
||||
}
|
||||
return workspace.hasLoadedTerminalSurface()
|
||||
}
|
||||
|
||||
private func completeWorkspaceHandoff(reason: String) {
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
let retiring = retiringWorkspaceId
|
||||
|
||||
// Hide portal-hosted views for the retiring workspace BEFORE clearing
|
||||
|
|
@ -7221,6 +7266,9 @@ struct VerticalTabsSidebar: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
let workspaceCount = tabManager.tabs.count
|
||||
let canCloseWorkspace = workspaceCount > 1
|
||||
|
||||
VStack(spacing: 0) {
|
||||
GeometryReader { proxy in
|
||||
ScrollView {
|
||||
|
|
@ -7237,7 +7285,12 @@ struct VerticalTabsSidebar: View {
|
|||
tab: tab,
|
||||
index: index,
|
||||
isActive: tabManager.selectedTabId == tab.id,
|
||||
tabCount: tabManager.tabs.count,
|
||||
workspaceShortcutDigit: WorkspaceShortcutMapper.commandDigitForWorkspace(
|
||||
at: index,
|
||||
workspaceCount: workspaceCount
|
||||
),
|
||||
canCloseWorkspace: canCloseWorkspace,
|
||||
accessibilityWorkspaceCount: workspaceCount,
|
||||
unreadCount: notificationStore.unreadCount(forTabId: tab.id),
|
||||
latestNotificationText: {
|
||||
guard showsSidebarNotificationMessage,
|
||||
|
|
@ -9506,7 +9559,9 @@ private struct TabItemView: View, Equatable {
|
|||
lhs.tab === rhs.tab &&
|
||||
lhs.index == rhs.index &&
|
||||
lhs.isActive == rhs.isActive &&
|
||||
lhs.tabCount == rhs.tabCount &&
|
||||
lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit &&
|
||||
lhs.canCloseWorkspace == rhs.canCloseWorkspace &&
|
||||
lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount &&
|
||||
lhs.unreadCount == rhs.unreadCount &&
|
||||
lhs.latestNotificationText == rhs.latestNotificationText &&
|
||||
lhs.rowSpacing == rhs.rowSpacing &&
|
||||
|
|
@ -9522,7 +9577,9 @@ private struct TabItemView: View, Equatable {
|
|||
@ObservedObject var tab: Tab
|
||||
let index: Int
|
||||
let isActive: Bool
|
||||
let tabCount: Int
|
||||
let workspaceShortcutDigit: Int?
|
||||
let canCloseWorkspace: Bool
|
||||
let accessibilityWorkspaceCount: Int
|
||||
let unreadCount: Int
|
||||
let latestNotificationText: String?
|
||||
let rowSpacing: CGFloat
|
||||
|
|
@ -9625,12 +9682,8 @@ private struct TabItemView: View, Equatable {
|
|||
usesInvertedActiveForeground ? 1.0 : 0.9
|
||||
}
|
||||
|
||||
private var workspaceShortcutDigit: Int? {
|
||||
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
|
||||
}
|
||||
|
||||
private var showCloseButton: Bool {
|
||||
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
}
|
||||
|
||||
private var workspaceShortcutLabel: String? {
|
||||
|
|
@ -10267,7 +10320,7 @@ private struct TabItemView: View, Equatable {
|
|||
}
|
||||
|
||||
private var accessibilityTitle: String {
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)")
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)")
|
||||
}
|
||||
|
||||
private func moveBy(_ delta: Int) {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,33 @@ class TabManager: ObservableObject {
|
|||
private static var nextPortOrdinal: Int = 0
|
||||
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
|
||||
@Published var selectedTabId: UUID? {
|
||||
willSet {
|
||||
#if DEBUG
|
||||
guard newValue != selectedTabId else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
|
||||
if debugPreparedWorkspaceSwitchTarget == newValue {
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
} else {
|
||||
let trigger = (debugPendingWorkspaceSwitchTarget == newValue
|
||||
? debugPendingWorkspaceSwitchTrigger
|
||||
: nil) ?? "direct"
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugBeginWorkspaceSwitch(
|
||||
trigger: trigger,
|
||||
from: selectedTabId,
|
||||
to: newValue
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
sentryBreadcrumb("workspace.switch", data: [
|
||||
|
|
@ -731,6 +758,9 @@ class TabManager: ObservableObject {
|
|||
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
||||
private var debugWorkspaceSwitchId: UInt64 = 0
|
||||
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
|
||||
private var debugPendingWorkspaceSwitchTrigger: String?
|
||||
private var debugPendingWorkspaceSwitchTarget: UUID?
|
||||
private var debugPreparedWorkspaceSwitchTarget: UUID?
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -930,6 +960,9 @@ class TabManager: ObservableObject {
|
|||
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
|
||||
}
|
||||
if select {
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
||||
#endif
|
||||
selectedTabId = newWorkspace.id
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -1507,6 +1540,9 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
func selectWorkspace(_ workspace: Workspace) {
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
|
||||
#endif
|
||||
selectedTabId = workspace.id
|
||||
}
|
||||
|
||||
|
|
@ -2093,6 +2129,9 @@ class TabManager: ObservableObject {
|
|||
// Keep selected-surface intent stable across selectedTabId didSet async restore.
|
||||
lastFocusedPanelByTab[tabId] = surfaceId
|
||||
}
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("focus", to: tabId)
|
||||
#endif
|
||||
selectedTabId = tabId
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -2160,13 +2199,7 @@ class TabManager: ObservableObject {
|
|||
let nextIndex = (currentIndex + 1) % tabs.count
|
||||
#if DEBUG
|
||||
let nextId = tabs[nextIndex].id
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
|
||||
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId)
|
||||
#endif
|
||||
activateWorkspaceCycleHotWindow()
|
||||
selectedTabId = tabs[nextIndex].id
|
||||
|
|
@ -2178,13 +2211,7 @@ class TabManager: ObservableObject {
|
|||
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
||||
#if DEBUG
|
||||
let prevId = tabs[prevIndex].id
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
|
||||
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId)
|
||||
#endif
|
||||
activateWorkspaceCycleHotWindow()
|
||||
selectedTabId = tabs[prevIndex].id
|
||||
|
|
@ -2257,6 +2284,40 @@ class TabManager: ObservableObject {
|
|||
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
|
||||
}
|
||||
|
||||
private func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) {
|
||||
guard selectedTabId != target else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
debugPendingWorkspaceSwitchTrigger = trigger
|
||||
debugPendingWorkspaceSwitchTarget = target
|
||||
}
|
||||
|
||||
private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) {
|
||||
guard from != to else {
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugPreparedWorkspaceSwitchTarget = nil
|
||||
return
|
||||
}
|
||||
debugPendingWorkspaceSwitchTrigger = nil
|
||||
debugPendingWorkspaceSwitchTarget = nil
|
||||
debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to)
|
||||
debugPreparedWorkspaceSwitchTarget = to
|
||||
}
|
||||
|
||||
private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) {
|
||||
debugWorkspaceSwitchCounter &+= 1
|
||||
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
||||
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
||||
dlog(
|
||||
"ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " +
|
||||
"from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " +
|
||||
"hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
||||
)
|
||||
}
|
||||
|
||||
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(5))
|
||||
|
|
@ -2269,6 +2330,9 @@ class TabManager: ObservableObject {
|
|||
|
||||
func selectTab(at index: Int) {
|
||||
guard index >= 0 && index < tabs.count else { return }
|
||||
#if DEBUG
|
||||
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
|
||||
#endif
|
||||
selectedTabId = tabs[index].id
|
||||
}
|
||||
|
||||
|
|
|
|||
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
282
cmuxTests/WorkspaceStressProfileTests.swift
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceStressProfileTests: XCTestCase {
|
||||
private struct StressConfig {
|
||||
let workspaceCount: Int
|
||||
let tabsPerWorkspace: Int
|
||||
let switchPasses: Int
|
||||
let createP95BudgetMs: Double?
|
||||
let switchP95BudgetMs: Double?
|
||||
|
||||
static func current(environment: [String: String] = ProcessInfo.processInfo.environment) -> StressConfig {
|
||||
StressConfig(
|
||||
workspaceCount: parseInt(environment["CMUX_WORKSPACE_STRESS_WORKSPACES"], default: 48, minimum: 2),
|
||||
tabsPerWorkspace: parseInt(environment["CMUX_WORKSPACE_STRESS_TABS_PER_WORKSPACE"], default: 10, minimum: 1),
|
||||
switchPasses: parseInt(environment["CMUX_WORKSPACE_STRESS_SWITCH_PASSES"], default: 6, minimum: 1),
|
||||
createP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_CREATE_P95_BUDGET_MS"]),
|
||||
switchP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_SWITCH_P95_BUDGET_MS"])
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?, default defaultValue: Int, minimum: Int) -> Int {
|
||||
guard let value, let parsed = Int(value) else { return defaultValue }
|
||||
return max(minimum, parsed)
|
||||
}
|
||||
|
||||
private static func parseDouble(_ value: String?) -> Double? {
|
||||
guard let value, let parsed = Double(value) else { return nil }
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimedSample {
|
||||
let label: String
|
||||
let elapsedMs: Double
|
||||
}
|
||||
|
||||
private struct TimingSummary {
|
||||
let count: Int
|
||||
let averageMs: Double
|
||||
let medianMs: Double
|
||||
let p95Ms: Double
|
||||
let maxMs: Double
|
||||
let totalMs: Double
|
||||
|
||||
init(samples: [TimedSample]) {
|
||||
let sorted = samples.map(\.elapsedMs).sorted()
|
||||
count = sorted.count
|
||||
totalMs = sorted.reduce(0, +)
|
||||
averageMs = count > 0 ? totalMs / Double(count) : 0
|
||||
medianMs = Self.percentile(0.50, in: sorted)
|
||||
p95Ms = Self.percentile(0.95, in: sorted)
|
||||
maxMs = sorted.last ?? 0
|
||||
}
|
||||
|
||||
private static func percentile(_ percentile: Double, in sortedValues: [Double]) -> Double {
|
||||
guard !sortedValues.isEmpty else { return 0 }
|
||||
let clamped = min(max(percentile, 0), 1)
|
||||
let index = Int((Double(sortedValues.count - 1) * clamped).rounded(.up))
|
||||
return sortedValues[min(sortedValues.count - 1, max(0, index))]
|
||||
}
|
||||
}
|
||||
|
||||
func testWorkspaceCreationAndSwitchingStressProfile() {
|
||||
let config = StressConfig.current()
|
||||
let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey)
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
defer {
|
||||
if let welcomeWasShown {
|
||||
UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
|
||||
var creationSamples: [TimedSample] = []
|
||||
var populationSamples: [TimedSample] = []
|
||||
var switchSamples: [TimedSample] = []
|
||||
var switchDispatchSamples: [TimedSample] = []
|
||||
var switchFirstDrainSamples: [TimedSample] = []
|
||||
var switchUnfocusSamples: [TimedSample] = []
|
||||
var switchSecondDrainSamples: [TimedSample] = []
|
||||
|
||||
let manager = timed("workspace-000-create", collectInto: &creationSamples) {
|
||||
TabManager()
|
||||
}
|
||||
|
||||
guard let bootstrapWorkspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected bootstrap workspace")
|
||||
return
|
||||
}
|
||||
|
||||
timed("workspace-000-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: bootstrapWorkspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
for workspaceIndex in 1..<config.workspaceCount {
|
||||
let workspace = timed("workspace-\(label(for: workspaceIndex))-create", collectInto: &creationSamples) {
|
||||
manager.addWorkspace(
|
||||
select: true,
|
||||
eagerLoadTerminal: false,
|
||||
autoWelcomeIfNeeded: false
|
||||
)
|
||||
}
|
||||
|
||||
settleWorkspaceSelection(manager)
|
||||
|
||||
timed("workspace-\(label(for: workspaceIndex))-populate", collectInto: &populationSamples) {
|
||||
populate(workspace: workspace, tabsPerWorkspace: config.tabsPerWorkspace)
|
||||
}
|
||||
settleWorkspaceSelection(manager)
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, config.workspaceCount)
|
||||
XCTAssertTrue(manager.tabs.allSatisfy { $0.panels.count == config.tabsPerWorkspace })
|
||||
|
||||
for pass in 0..<config.switchPasses {
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-next-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-next-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectNextTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for switchIndex in 0..<manager.tabs.count {
|
||||
timed("pass-\(label(for: pass))-prev-\(label(for: switchIndex))", collectInto: &switchSamples) {
|
||||
timed("pass-\(label(for: pass))-prev-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
|
||||
manager.selectPreviousTab()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
}
|
||||
timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
|
||||
drainMainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertNotNil(manager.selectedWorkspace)
|
||||
|
||||
let creationSummary = TimingSummary(samples: creationSamples)
|
||||
let populationSummary = TimingSummary(samples: populationSamples)
|
||||
let switchSummary = TimingSummary(samples: switchSamples)
|
||||
let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples)
|
||||
let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples)
|
||||
let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples)
|
||||
let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples)
|
||||
|
||||
let report = [
|
||||
"Workspace stress config workspaces=\(config.workspaceCount) tabsPerWorkspace=\(config.tabsPerWorkspace) switchPasses=\(config.switchPasses)",
|
||||
reportLine(title: "create", summary: creationSummary, slowest: slowest(creationSamples)),
|
||||
reportLine(title: "populate", summary: populationSummary, slowest: slowest(populationSamples)),
|
||||
reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)),
|
||||
reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)),
|
||||
reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)),
|
||||
reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)),
|
||||
reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples))
|
||||
].joined(separator: "\n")
|
||||
|
||||
print(report)
|
||||
let attachment = XCTAttachment(string: report)
|
||||
attachment.name = "workspace-stress-profile"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
|
||||
if let createP95BudgetMs = config.createP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
creationSummary.p95Ms,
|
||||
createP95BudgetMs,
|
||||
"Workspace creation p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
if let switchP95BudgetMs = config.switchP95BudgetMs {
|
||||
XCTAssertLessThanOrEqual(
|
||||
switchSummary.p95Ms,
|
||||
switchP95BudgetMs,
|
||||
"Workspace switch p95 exceeded budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
|
||||
guard tabsPerWorkspace > 0 else { return }
|
||||
while workspace.panels.count < tabsPerWorkspace {
|
||||
let created = workspace.newTerminalSurfaceInFocusedPane(focus: false)
|
||||
guard created != nil else {
|
||||
XCTFail("Expected terminal tab creation to succeed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settleWorkspaceSelection(_ manager: TabManager) {
|
||||
drainMainQueue()
|
||||
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
|
||||
drainMainQueue()
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
let deadline = Date(timeIntervalSinceNow: 1.0)
|
||||
var drained = false
|
||||
DispatchQueue.main.async {
|
||||
drained = true
|
||||
}
|
||||
while !drained {
|
||||
if Date() >= deadline {
|
||||
XCTFail("Timed out draining main queue")
|
||||
return
|
||||
}
|
||||
let sliceDeadline = min(deadline, Date(timeIntervalSinceNow: 0.001))
|
||||
_ = RunLoop.main.run(mode: .default, before: sliceDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func timed<T>(
|
||||
_ label: String,
|
||||
collectInto samples: inout [TimedSample],
|
||||
operation: () -> T
|
||||
) -> T {
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
let value = operation()
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
|
||||
samples.append(TimedSample(label: label, elapsedMs: elapsedMs))
|
||||
return value
|
||||
}
|
||||
|
||||
private func slowest(_ samples: [TimedSample], count: Int = 5) -> String {
|
||||
samples
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.elapsedMs == rhs.elapsedMs {
|
||||
return lhs.label < rhs.label
|
||||
}
|
||||
return lhs.elapsedMs > rhs.elapsedMs
|
||||
}
|
||||
.prefix(count)
|
||||
.map { "\($0.label)=\(formatMs($0.elapsedMs))" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func reportLine(title: String, summary: TimingSummary, slowest: String) -> String {
|
||||
[
|
||||
"\(title):",
|
||||
"count=\(summary.count)",
|
||||
"avg=\(formatMs(summary.averageMs))",
|
||||
"median=\(formatMs(summary.medianMs))",
|
||||
"p95=\(formatMs(summary.p95Ms))",
|
||||
"max=\(formatMs(summary.maxMs))",
|
||||
"total=\(formatMs(summary.totalMs))",
|
||||
"slowest=[\(slowest)]"
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
private func formatMs(_ value: Double) -> String {
|
||||
String(format: "%.2fms", value)
|
||||
}
|
||||
|
||||
private func label(for index: Int) -> String {
|
||||
String(format: "%03d", index)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue