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
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,32 @@ import SwiftUI
@MainActor
@Observable
private final class WorkspaceListLayoutPreviewModel {
/// The continuous update feed's payload shape
/// (`CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES`).
enum LiveUpdateMode {
/// No feed.
case off
/// `1`: visible churn — unread toggles plus activity restamps.
case visible
/// `timestamps`: sub-minute activity restamps only, the shape the Mac
/// emits while agents stream (`last_activity_at` is the latest
/// notification's `createdAt`). Rows render identically, so a correct
/// list does zero work per tick.
case timestampsOnly
}

var workspaces: [MobileWorkspacePreview]
private let liveUpdatesEnabled: Bool
private let liveUpdateMode: LiveUpdateMode

/// Creates a preview model with an optional continuous update feed.
init(workspaces: [MobileWorkspacePreview], liveUpdatesEnabled: Bool) {
init(workspaces: [MobileWorkspacePreview], liveUpdateMode: LiveUpdateMode) {
self.workspaces = workspaces
self.liveUpdatesEnabled = liveUpdatesEnabled
self.liveUpdateMode = liveUpdateMode
}

/// Mutates rotating row payloads until the view-owned task is cancelled.
func runLiveUpdates() async {
guard liveUpdatesEnabled else { return }
guard liveUpdateMode != .off else { return }
var updateLane = 0
while !Task.isCancelled {
do {
Expand All @@ -30,8 +44,31 @@ private final class WorkspaceListLayoutPreviewModel {
return
}
for index in workspaces.indices where index % 10 == updateLane {
workspaces[index].hasUnread.toggle()
workspaces[index].previewAt = Date()
if liveUpdateMode == .visible {
workspaces[index].hasUnread.toggle()
workspaces[index].previewAt = Date()
workspaces[index].lastActivityAt = Date()
} else {
// Restamp relative to the row's own clock: the seeded
// timestamps are hours old, so jumping them to `Date()`
// would change the rendered minute on every row's first
// tick and do real row work. The bump also wraps back to
// the start of the row's current minute rather than
// crossing into the next one, so EVERY tick is a
// render-equivalent delta (this mode's zero-work
// contract), not just the first fifty-nine.
let current = workspaces[index].lastActivityAt
?? workspaces[index].previewAt
?? Date()
let minute = (current.timeIntervalSinceReferenceDate / 60)
.rounded(.down)
var restamped = current.addingTimeInterval(1)
if (restamped.timeIntervalSinceReferenceDate / 60).rounded(.down) != minute {
restamped = Date(timeIntervalSinceReferenceDate: minute * 60)
}
workspaces[index].previewAt = restamped
workspaces[index].lastActivityAt = restamped
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
updateLane = (updateLane + 1) % 10
}
Expand Down Expand Up @@ -94,12 +131,16 @@ public struct WorkspaceListLayoutPreviewView: View {
return workspace
}
: initialWorkspaces
let liveUpdateMode: WorkspaceListLayoutPreviewModel.LiveUpdateMode
switch environment["CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES"] {
case "1": liveUpdateMode = .visible
case "timestamps": liveUpdateMode = .timestampsOnly
default: liveUpdateMode = .off
}
_model = State(
initialValue: WorkspaceListLayoutPreviewModel(
workspaces: fixtureWorkspaces,
liveUpdatesEnabled: environment[
"CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES"
] == "1"
liveUpdateMode: liveUpdateMode
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
/// what the per-row estimate should have been.
let realizedHeightCounts: [String: Int]
let corrections: [Correction]
/// Display-link frame pacing sampled during the sweep; nil until the
/// sweep produced at least one frame interval.
let framePacing: FramePacing?
}

/// Frame-pacing summary of the sweep, in the shape of MetricKit's hitch
/// metrics: a hitch frame is one whose interval overran the display's
/// expected interval by 50% or more, and hitch time is the overrun beyond
/// the expected interval summed across hitch frames.
private struct FramePacing: Encodable {
let frameCount: Int
let expectedFrameMs: Double
let averageFrameMs: Double
let maxFrameMs: Double
let hitchFrameCount: Int
let hitchTotalMs: Double
/// Hitch milliseconds per second of sweep (Apple's hitch-ratio unit;
/// <5 is "good", >10 is user-visible stutter).
let hitchMsPerSecond: Double
}

private static let logger = Logger(subsystem: "dev.cmux.ios", category: "scroll-metrics")
Expand All @@ -81,6 +100,16 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
/// `contentSize.height` sampled once per sweep frame — the value the
/// scroll indicator actually renders from at draw time.
private var sampledDrawHeights: [Double] = []
/// Interval between consecutive sweep display-link callbacks; a late
/// callback means the previous frame overran its budget (a hitch).
private var sweepFrameDurations: [Double] = []
/// The expected interval for each entry of `sweepFrameDurations`,
/// captured as `targetTimestamp - timestamp` at the callback that STARTED
/// that frame, so each duration is judged against its own frame's budget
/// even when ProMotion/simulator refresh rates change mid-sweep.
private var sweepExpectedDurations: [Double] = []
private var lastSweepFrameTimestamp: CFTimeInterval?
private var lastSweepExpectedDuration: Double?

override func didMoveToWindow() {
super.didMoveToWindow()
Expand All @@ -97,7 +126,7 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
displayLink = link
}

@objc private func tick() {
@objc private func tick(_ link: CADisplayLink) {
switch phase {
case .searching(let framesLeft):
if let scrollView = Self.findListScrollView(in: window) {
Expand All @@ -116,6 +145,12 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
}
baselineAfterSettle()
case .sweeping:
if let last = lastSweepFrameTimestamp, let expected = lastSweepExpectedDuration {
sweepFrameDurations.append(link.timestamp - last)
sweepExpectedDurations.append(expected)
}
lastSweepFrameTimestamp = link.timestamp
lastSweepExpectedDuration = link.targetTimestamp - link.timestamp
sampledDrawHeights.append(Double(listScrollView?.contentSize.height ?? 0))
stepSweep()
case .finished:
Expand Down Expand Up @@ -166,6 +201,11 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
Self.logger.notice(
"scroll-metrics: sweep done corrections=\(self.corrections.count) totalAbsCorrection=\(self.totalAbsCorrection, format: .fixed(precision: 1)) initial=\(self.initialContentHeight, format: .fixed(precision: 1)) final=\(Double(self.listScrollView?.contentSize.height ?? 0), format: .fixed(precision: 1))"
)
if let pacing = framePacing {
Self.logger.notice(
"scroll-metrics: pacing frames=\(pacing.frameCount) hitchFrames=\(pacing.hitchFrameCount) hitchMs=\(pacing.hitchTotalMs, format: .fixed(precision: 1)) hitchMsPerS=\(pacing.hitchMsPerSecond, format: .fixed(precision: 2)) maxFrameMs=\(pacing.maxFrameMs, format: .fixed(precision: 1))"
)
}
}
}

Expand Down Expand Up @@ -205,6 +245,37 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
corrections.reduce(0) { $0 + abs($1.newHeight - $1.oldHeight) }
}

private var framePacing: FramePacing? {
guard !sweepFrameDurations.isEmpty,
sweepExpectedDurations.count == sweepFrameDurations.count else { return nil }
let medianExpected = sweepExpectedDurations.sorted()[sweepExpectedDurations.count / 2]
guard medianExpected > 0 else { return nil }
var hitchFrameCount = 0
var hitchTotal: Double = 0
var maxDuration: Double = 0
var total: Double = 0
// Judge each frame against its own captured budget so a legitimate
// refresh-rate change mid-sweep is not misclassified as a hitch; the
// median is reported only as the representative expected interval.
for (duration, expected) in zip(sweepFrameDurations, sweepExpectedDurations) {
total += duration
maxDuration = max(maxDuration, duration)
if expected > 0, duration >= expected * 1.5 {
hitchFrameCount += 1
hitchTotal += duration - expected
}
}
return FramePacing(
frameCount: sweepFrameDurations.count,
expectedFrameMs: medianExpected * 1000,
averageFrameMs: total / Double(sweepFrameDurations.count) * 1000,
maxFrameMs: maxDuration * 1000,
hitchFrameCount: hitchFrameCount,
hitchTotalMs: hitchTotal * 1000,
hitchMsPerSecond: total > 0 ? (hitchTotal * 1000) / total : 0
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private func writeReport() {
guard
let documents = FileManager.default.urls(
Expand All @@ -223,7 +294,8 @@ final class WorkspaceListScrollMetricsProbeView: UIView {
// written from a KVO callback inside a layout pass, where an extra
// whole-content layout query is both wasted work and reentrant.
realizedHeightCounts: finished ? realizedHeightCounts() : [:],
corrections: corrections
corrections: corrections,
framePacing: framePacing
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#if os(iOS) && DEBUG
import Foundation

/// DEBUG-only observation of which route a coordinator's most recent
/// configuration update took, isolated from the production type per the
/// source policy on test-only seams (mirrors how
/// `WorkspaceListScrollMetricsProbe` isolates DEBUG instrumentation). The
/// coordinator's `#if DEBUG` call sites record here; package tests read the
/// route back through ``WorkspaceListTableCoordinator/lastPayloadApplyRoute``.
extension WorkspaceListTableCoordinator {
/// How one configuration update reached the table.
enum PayloadApplyRoute: Equatable {
/// No row renders differently; the table was not touched.
case noChange
/// Payload-only changes with stable heights; the visible changed
/// cells were re-configured in place, listed here by item id.
case reconfiguredInPlace([String])
/// Structure or a row height changed; a snapshot was applied.
case snapshotApply
}

/// The most recent update's route, or nil before the first update.
var lastPayloadApplyRoute: PayloadApplyRoute? {
WorkspaceListApplyRouteProbe.lastRoute(for: self)
}

func recordPayloadApplyRoute(_ route: PayloadApplyRoute) {
WorkspaceListApplyRouteProbe.record(route, for: self)
}
}

/// Registry with weak coordinator keys: an entry dies with its coordinator,
/// so nothing accumulates over a DEBUG session and a new coordinator reusing
/// a freed address can never read a predecessor's route.
@MainActor
private enum WorkspaceListApplyRouteProbe {
private final class RouteBox {
let route: WorkspaceListTableCoordinator.PayloadApplyRoute
init(_ route: WorkspaceListTableCoordinator.PayloadApplyRoute) {
self.route = route
}
}

private static let lastRoutesByCoordinator =
NSMapTable<WorkspaceListTableCoordinator, RouteBox>.weakToStrongObjects()

static func record(
_ route: WorkspaceListTableCoordinator.PayloadApplyRoute,
for coordinator: WorkspaceListTableCoordinator
) {
lastRoutesByCoordinator.setObject(RouteBox(route), forKey: coordinator)
}

static func lastRoute(
for coordinator: WorkspaceListTableCoordinator
) -> WorkspaceListTableCoordinator.PayloadApplyRoute? {
lastRoutesByCoordinator.object(forKey: coordinator)?.route
}
}
#endif
Loading