From 8030559f8e7c65ab26457d9aa67c32f334002c7e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:32:30 -0700 Subject: [PATCH 01/45] cmux-next-ios: list scroll rig for every vertical list (DEBUG only) ListStressRig seeds workspaces, tab grid, agent chat, diff, Home or the Feed, opens it and scrolls its UIScrollView top to bottom and back with one FrameIntervalLog window per direction. verify/listperf/rig.sh builds and runs it on a fleet Mac's simulator; matrix.sh is the phase-1 measurement set. Co-Authored-By: Claude Opus 5.5 --- .../ListStressSettings.swift | 95 ++++++++ .../MockAgentSession.swift | 43 +++- .../Sources/CmuxMobileChief/MacHome.swift | 27 +++ .../CmuxMobileShell/ListStressRig.swift | 224 ++++++++++++++++++ .../Sources/CmuxMobileShell/RootView.swift | 1 + cmux-next-ios/verify/feedperf/summarize.py | 2 +- cmux-next-ios/verify/listperf/matrix.sh | 27 +++ cmux-next-ios/verify/listperf/rig.sh | 81 +++++++ 8 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/ListStressSettings.swift create mode 100644 cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/ListStressRig.swift create mode 100755 cmux-next-ios/verify/listperf/matrix.sh create mode 100755 cmux-next-ios/verify/listperf/rig.sh diff --git a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/ListStressSettings.swift b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/ListStressSettings.swift new file mode 100644 index 000000000000..4301924918ef --- /dev/null +++ b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/ListStressSettings.swift @@ -0,0 +1,95 @@ +// DEBUG and Demo builds only: the list scroll stress rig (verify/listperf/). +#if DEBUG +import Foundation + +/// Launch settings for the list scroll rig, from the environment +/// (`SIMCTL_CHILD_` on a simulator). The rig itself lives in CmuxMobileShell +/// (`ListStressRig`); the mock agent reads the two chat settings here. +/// - `CMUX_MOBILE_LIST_SCREEN`: the list to measure: `workspaces`, `tabs`, `chat`, `diff`, `home`, or `feed` +/// (seeded by `CMUX_MOBILE_FEED_STRESS`, as a reference for the rebuilt Feed). +/// - `CMUX_MOBILE_LIST_COUNT=N`: rows to seed (workspaces, tabs) or replayed turns (chat, diff). +/// - `CMUX_MOBILE_LIST_LIVE_HZ=HZ`: live changes per second while scrolling (workspace activity). +/// - `CMUX_MOBILE_LIST_STREAM=1`: every prompt streams one long answer with no waits. +/// - `CMUX_MOBILE_LIST_PASSES=N`: top to bottom and back N times at `CMUX_MOBILE_LIST_SPEED` pt/s +/// (default 2400), one frame-interval window per direction (needs `CMUX_MOBILE_FRAMELOG=1`). +/// - `CMUX_MOBILE_LIST_EXIT_WHEN_DONE=1`: exit after the last pass (the rig waits for the exit). +public struct ListStressSettings: Equatable, Sendable { + public static let shared = ListStressSettings() + + public var screen: String? + public var count: Int + public var liveHz: Double? + public var streams: Bool + public var passes: Int + public var speed: Double + public var settle: Double + public var exitsWhenDone: Bool + + public init(environment: [String: String] = ProcessInfo.processInfo.environment) { + screen = environment["CMUX_MOBILE_LIST_SCREEN"] + count = environment["CMUX_MOBILE_LIST_COUNT"].flatMap(Int.init) ?? 0 + liveHz = environment["CMUX_MOBILE_LIST_LIVE_HZ"].flatMap(Double.init) + streams = environment["CMUX_MOBILE_LIST_STREAM"] == "1" + passes = environment["CMUX_MOBILE_LIST_PASSES"].flatMap(Int.init) ?? 2 + speed = environment["CMUX_MOBILE_LIST_SPEED"].flatMap(Double.init) ?? 2400 + settle = environment["CMUX_MOBILE_LIST_SETTLE"].flatMap(Double.init) ?? 4 + exitsWhenDone = environment["CMUX_MOBILE_LIST_EXIT_WHEN_DONE"] == "1" + } + + /// Turns the mock agent replays as history: the chat and diff screens' load. + public var historyTurns: Int { + screen == "chat" || screen == "diff" ? count : 0 + } +} + +/// Deterministic agent-shaped text for the rig's replayed turns. +enum MockStressContent { + static let largeDiffLines = 2400 + + static func prompt(_ index: Int) -> String { + ["Make the dialer retry with backoff (#\(index))", "Why does the relay drop frames on cellular?", + "Add tests for the transport switch", "Summarize what changed since yesterday"][index % 4] + } + + static func thought(_ index: Int) -> String { + "The dialer keeps one attempt in flight. Retrying with backoff means a timer per Mac, so I should check how LinkDialer schedules today (pass \(index))." + } + + static func code(lines: Int, seed: Int, edited: Bool = false) -> String { + (0.. String { + (0..<24).map { "Test Case 'LinkDialerTests.testRetry\($0)' passed (0.0\($0 % 9)s)" }.joined(separator: "\n") + + "\nExecuted 24 tests, with 0 failures in 0.41 (0.44) seconds (run \(index))" + } + + static func answer(_ index: Int) -> String { + """ + **Done (#\(index)).** The dialer now retries with exponential backoff, capped at 30 s: + + ```swift + func retryDelay(attempt: Int) -> Duration { + .seconds(min(30, 1 << min(attempt, 5))) + } + ``` + + | Case | Before | After | + | --- | --- | --- | + | Wi-Fi drop | gave up | retries 1, 2, 4 s | + | Cellular | one try | retries until linked | + + - `LinkDialer` keeps one attempt in flight per Mac + - 24 tests pass, including the new `testRetryCap` + """ + } + + /// One long answer, streamed in 6-character chunks every 16 ms (about 375 characters a second). + static let streamedAnswer: String = (0..<40).map { answer($0) }.joined(separator: "\n\n") +} +#endif diff --git a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/MockAgentSession.swift b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/MockAgentSession.swift index e96edb52cba8..362c0640624c 100644 --- a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/MockAgentSession.swift +++ b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileAgentChat/MockAgentSession.swift @@ -191,6 +191,7 @@ public final class MockAgentSession: AgentSessionSource { emit(.turnResult(turnId: turnID, status: .completed, errorText: nil, at: start.addingTimeInterval(41))) let second = Date().addingTimeInterval(-1800) replaySubagentTurn(at: second) + replayStressTurns(ListStressSettings.shared.historyTurns) checkpointLog = [ AgentCheckpoint(id: "cp-h1", title: "What does the link framing look like today?", turnID: turnID, createdAt: start, branch: summary.branch, head: "4d81e0c", files: 0), @@ -202,6 +203,44 @@ public final class MockAgentSession: AgentSessionSource { ] } + // MARK: List stress rig + + /// `count` replayed turns shaped like real agent work (a thought, a read, an edit, a command + /// with output, a markdown answer with code and a table), for the list scroll rig + /// (`verify/listperf/`). The newest one edits a small file and a long one, the diff screen's case. + private func replayStressTurns(_ count: Int) { + guard count > 0 else { return } + let base = Date().addingTimeInterval(-1500) + for index in 0.. MacHomeRow? in + let isChief = index % 6 == 0 + var wire: [String: Any] = [:] + wire["id"] = "stress-\(index)" + wire["kind"] = kinds[index % kinds.count] + wire["name"] = "Conversation \(index)" + wire["unread"] = index % 3 == 0 ? index % 5 + 1 : 0 + wire["activity_at"] = stamp.string(from: Date().addingTimeInterval(-Double(index) * 900)) + wire["preview"] = "**Done (#\(index)).** The dialer now retries with `backoff`, capped at 30 s. [Details](https://cmux.com) and 24 tests pass." + if isChief { + wire["chief"] = "chief-\(index)" + wire["chief_state"] = index % 12 == 0 ? "working" : "idle" + } + wire["pinned"] = index < 4 + wire["pin_position"] = index + return MacHomeRow(wire: wire) + } + self.macID = macID + } + #endif + /// The Mac Chief's own conversation on this Mac (the oldest with the owner and `agent_mux`, /// as the Mac's Home picks it), from the owner's list: its row is the Mac Chief. public private(set) var macChiefID: String? diff --git a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/ListStressRig.swift b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/ListStressRig.swift new file mode 100644 index 000000000000..9c635109c188 --- /dev/null +++ b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/ListStressRig.swift @@ -0,0 +1,224 @@ +// DEBUG and Demo builds only: the list scroll stress rig (verify/listperf/). +#if os(iOS) && DEBUG +import CmuxMobileAgentChat +import CmuxMobileChief +import CmuxMobileDesign +import CmuxMobileModel +import CmuxMobileWorkspaces +import OSLog +import QuartzCore +import UIKit + +/// Seeds one list with `ListStressSettings` (mock mode), opens it, then scrolls its +/// UIScrollView like a steady finger, top to bottom and back, with one +/// `FrameIntervalLog` window per direction (`-down-N`, `-up-N`). +/// The Feed has its own rig (`FeedStress.swift`); this one covers the other lists. +@MainActor +final class ListStressRig: NSObject { + private let settings: ListStressSettings + private let shell: ShellModel + private weak var scrollView: UIScrollView? + private var link: CADisplayLink? + private var direction: CGFloat = 1 + private var pass = 0 + private var last: CFTimeInterval? + private var live: Task? + private let log = Logger(subsystem: "dev.cmux.mobile", category: "frames") + + init(settings: ListStressSettings, shell: ShellModel) { + self.settings = settings + self.shell = shell + } + + static func runIfConfigured(shell: ShellModel) async { + let settings = ListStressSettings.shared + guard let screen = settings.screen else { return } + let rig = ListStressRig(settings: settings, shell: shell) + await rig.open(screen) + try? await Task.sleep(for: .seconds(settings.settle)) + rig.startLive() + rig.begin() + // Kept alive by the display link until the last pass. + } + + // MARK: Seeding and navigation + + private func open(_ screen: String) async { + let store = shell.store + switch screen { + case "workspaces": + store.workspaces = Self.workspaces(count: settings.count, from: store.workspaces) + shell.tab = .workspaces + case "tabs": + guard var ws = store.workspace(id: "ws-cmux") else { return } + ws.tabs = Self.tabs(count: settings.count) + ws.activeTabID = "t-g0" + store.workspaces = store.workspaces.map { $0.id == ws.id ? ws : $0 } + store.noteOpened(tabID: "t-g0", in: ws.id) + shell.tab = .workspaces + try? await Task.sleep(for: .seconds(1)) + shell.workspacesPath = [ws] + try? await Task.sleep(for: .seconds(2)) + if settings.streams { DemoDirector.shared.post(.chatSend("stream")) } + try? await Task.sleep(for: .seconds(1)) + WorkspaceDemoDirector.shared.post(.showGrid(true)) + case "chat", "diff": + guard let ws = store.workspace(id: "ws-cmux") else { return } + store.noteOpened(tabID: "t-codex-stress", in: ws.id) + var opened = ws + // Codex: no waiting question on open, so the transcript is the replayed history. + opened.tabs.insert(WorkspaceTab(id: "t-codex-stress", kind: .agent, title: "Codex", subtitle: "Stress transcript", + harness: "codex"), at: 0) + store.workspaces = store.workspaces.map { $0.id == ws.id ? opened : $0 } + shell.tab = .workspaces + try? await Task.sleep(for: .seconds(1)) + shell.workspacesPath = [opened] + try? await Task.sleep(for: .seconds(2.5)) + if screen == "diff" { + DemoDirector.shared.post(.chatOpenFirstFile) + } else if settings.streams { + DemoDirector.shared.post(.chatSend("stream")) + } + case "feed": + // The Feed's own seeding (`CMUX_MOBILE_FEED_STRESS=N`); this rig only scrolls, as a reference. + shell.tab = .feed + case "home": + MacHomeFeed.shared.seedStressRows(count: settings.count, macID: "mac-studio") + shell.tab = .home + default: + log.error("FRAMELOG list-stress unknown screen \(screen, privacy: .public)") + } + } + + /// `count` workspaces cycled from the mock ones: a quarter working (the shimmer), + /// some needing the person, activity spread over two days. + static func workspaces(count: Int, from templates: [Workspace]) -> [Workspace] { + guard count > 0, !templates.isEmpty else { return templates } + let now = Date() + return (0.. [WorkspaceTab] { + (0.. 0 else { return } + let store = shell.store + live = Task { @MainActor in + var tick = 0 + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1 / hz)) + tick += 1 + var next = store.workspaces + guard !next.isEmpty else { continue } + let index = (tick * 7) % next.count + next[index].lastActivityAt = Date() + next[index].isWorking.toggle() + store.workspaces = next + } + } + } + + // MARK: Scrolling + + /// The visible vertical scroll view with the most content below the fold. + private func findScrollView() -> UIScrollView? { + let window = UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + guard let window else { return nil } + var best: (view: UIScrollView, overflow: CGFloat)? + func visible(_ view: UIView) -> Bool { + var current: UIView? = view + while let node = current { + if node.isHidden || node.alpha < 0.01 { return false } + current = node.superview + } + return true + } + func walk(_ view: UIView) { + if let scroll = view as? UIScrollView, scroll.bounds.height > 200, visible(scroll) { + let frame = scroll.convert(scroll.bounds, to: window) + let overflow = scroll.contentSize.height - scroll.bounds.height + if frame.intersects(window.bounds), overflow > 100, overflow > (best?.overflow ?? 0) { best = (scroll, overflow) } + } + view.subviews.forEach(walk) + } + walk(window) + return best?.view + } + + private func begin() { + guard let scroll = findScrollView() else { + log.error("FRAMELOG list-stress no scroll view for \(self.settings.screen ?? "-", privacy: .public)") + finish() + return + } + scrollView = scroll + log.notice("FRAMELOG list-stress \(self.settings.screen ?? "-", privacy: .public) scroll=\(String(describing: type(of: scroll)), privacy: .public) content=\(Int(scroll.contentSize.height), privacy: .public)") + startPass() + } + + private func startPass() { + FrameIntervalLog.shared.begin("\(settings.screen ?? "list")-\(direction > 0 ? "down" : "up")-\(pass)") + last = nil + let link = CADisplayLink(target: self, selector: #selector(step(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) + link.add(to: .main, forMode: .common) + self.link = link + } + + @objc private func step(_ link: CADisplayLink) { + guard let scrollView else { link.invalidate(); finish(); return } + let dt = last.map { link.timestamp - $0 } ?? 0 + last = link.timestamp + let top = -scrollView.adjustedContentInset.top + let bottom = max(top, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom) + var y = scrollView.contentOffset.y + direction * CGFloat(settings.speed * dt) + let reachedEnd = direction > 0 ? y >= bottom : y <= top + y = min(max(y, top), bottom) + scrollView.contentOffset.y = y + guard reachedEnd else { return } + link.invalidate() + self.link = nil + FrameIntervalLog.shared.end() + if direction < 0 { pass += 1 } + direction = -direction + if pass >= settings.passes { finish() } else { startPass() } + } + + private func finish() { + live?.cancel() + log.notice("FRAMELOG list-stress-done \(self.settings.screen ?? "-", privacy: .public) passes=\(self.pass, privacy: .public)") + if settings.exitsWhenDone { + FrameLogFile.flush() + exit(0) + } + } +} +#endif diff --git a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/RootView.swift b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/RootView.swift index 85679e01f5cc..e0a3c47f4dba 100644 --- a/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/RootView.swift +++ b/cmux-next-ios/CmuxMobileKit/Sources/CmuxMobileShell/RootView.swift @@ -493,6 +493,7 @@ struct ShellView: View { if options.sidebarOpen { shell.sidebar.open() } #if DEBUG if let demo = options.demo { await DemoDriver(shell: shell).run(demo) } + await ListStressRig.runIfConfigured(shell: shell) await DebugLaunch.run(selectTab: { shell.tab = $0 }) #endif } diff --git a/cmux-next-ios/verify/feedperf/summarize.py b/cmux-next-ios/verify/feedperf/summarize.py index 76d1f27c90a8..b9a9e4d02df7 100644 --- a/cmux-next-ios/verify/feedperf/summarize.py +++ b/cmux-next-ios/verify/feedperf/summarize.py @@ -42,7 +42,7 @@ def main(): ms = r.get("intervalsMs") or [] st = stats(ms, r["expectedMs"], r.get("mainThreadCPUMs"), r.get("mainThreadCPUPerFrameMs")) if ms else {} print(f"{r['label']:28} " + " ".join(f"{k}={v}" for k, v in st.items())) - if r["label"].startswith("feed-autoscroll"): + if r["label"].startswith("feed-autoscroll") or "-down-" in r["label"] or "-up-" in r["label"]: pooled += ms expected = r["expectedMs"] frame_cpu += r.get("mainThreadCPUPerFrameMs") or [] diff --git a/cmux-next-ios/verify/listperf/matrix.sh b/cmux-next-ios/verify/listperf/matrix.sh new file mode 100755 index 000000000000..375c40e21c6b --- /dev/null +++ b/cmux-next-ios/verify/listperf/matrix.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# The phase-1 scroll matrix: every vertical list at a realistic size, idle and with live +# updates, plus the rebuilt Feed as the reference. Each line is one `rig.sh run`; set +# REPS (default 2) for repeats, ONLY= to run a subset. +set -uo pipefail +here=$(cd "$(dirname "$0")" && pwd) +reps=${REPS:-2} +runs=( + "feed-ref feed FEED_STRESS=320" + "ws-200 workspaces LIST_COUNT=200" + "ws-200-live4 workspaces LIST_COUNT=200 LIST_LIVE_HZ=4" + "home-300 home LIST_COUNT=300" + "chat-40 chat LIST_COUNT=40" + "chat-40-stream chat LIST_COUNT=40 LIST_STREAM=1" + "tabs-30 tabs LIST_COUNT=30" + "tabs-30-stream tabs LIST_COUNT=30 LIST_STREAM=1" + "diff-2400 diff LIST_COUNT=3" +) +for rep in $(seq 1 "$reps"); do + for line in "${runs[@]}"; do + read -r label screen args <<<"$line" + [[ -n "${ONLY:-}" && $label != ${ONLY}* ]] && continue + echo "== $label r$rep" + # shellcheck disable=SC2086 + "$here/rig.sh" run "$label-r$rep" "$screen" $args | grep -E 'POOLED|-down-|-up-' || echo "$label r$rep: no windows" + done +done diff --git a/cmux-next-ios/verify/listperf/rig.sh b/cmux-next-ios/verify/listperf/rig.sh new file mode 100755 index 000000000000..8dd25fad17a6 --- /dev/null +++ b/cmux-next-ios/verify/listperf/rig.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# List scroll-performance rig: the Demo app on a fleet Mac's simulator, one list seeded +# and scrolled by ListStressRig (CmuxMobileShell/ListStressRig.swift) with the in-app +# frame-interval log (G-04). Never builds on this laptop and never opens Simulator.app. +# +# rig.sh sync xcodegen here, rsync sources to $HOST:$DIR (the Rust xcframework is built there once: +# `bash $DIR/cmux-next-ios/rust/scripts/build-xcframework.sh`) +# rig.sh build Demo simulator build on $HOST (arm64 only) +# rig.sh run LABEL SCREEN [K=V..] +# install, launch with CMUX_MOBILE_LIST_SCREEN=SCREEN and CMUX_MOBILE_=V, +# wait for the app to exit after the passes, copy framelog.jsonl to +# verify/evidence/listperf/LABEL.jsonl. VIDEO=1 also records LABEL.mp4; +# TRACE=