From f95a32ea522a7ae6c783aaa7991d64398672421e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:44:16 -0700 Subject: [PATCH] Add workspace stress profiling and reduce switch churn (#1218) * Add workspace stress profiling and reduce switch churn * Address workspace stress review feedback --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 71 ++++- Sources/TabManager.swift | 92 ++++++- cmuxTests/WorkspaceStressProfileTests.swift | 282 ++++++++++++++++++++ 4 files changed, 426 insertions(+), 23 deletions(-) create mode 100644 cmuxTests/WorkspaceStressProfileTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a37d6096..86445cf5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -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 */, ); diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 19e2fa2e..be92eb3b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1331,6 +1331,7 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false + @State private var workspaceHandoffReadyCheckTask: Task? @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) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bd4dad61..4a888b14 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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 } diff --git a/cmuxTests/WorkspaceStressProfileTests.swift b/cmuxTests/WorkspaceStressProfileTests.swift new file mode 100644 index 00000000..bebb48d9 --- /dev/null +++ b/cmuxTests/WorkspaceStressProfileTests.swift @@ -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.. 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( + _ 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) + } +}