Repository navigation
cmux-next agent pane: render at full rate until scrolls miss frames #16487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
afa359f
cmux-next agent pane: switch full-rate rendering on a live page
teamleaderleo 9fcfa85
cmux-next agent pane: test the frame pacing that picks the render rate
teamleaderleo 129253b
cmux-next debug.agent_pane: keep measuring a pane behind other windows
teamleaderleo 25af4f2
cmux-next agent pane: record pacing decisions outside #expect
teamleaderleo 742521f
cmux-next agent pane: render at full rate until scrolls miss frames
teamleaderleo 45fe16b
Merge remote-tracking branch 'origin/feat-cmux-next' into nx-pane-ada…
teamleaderleo a1a51ce
cmux-next agent pane: put close()'s doc comment back on close()
teamleaderleo 4d20a97
cmux-next: drop the lint-flagged spread in the pacing test clock
teamleaderleo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
70 changes: 70 additions & 0 deletions
70
Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFramePacing.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import Foundation | ||
|
|
||
| /// How fast an agent pane renders. | ||
| public enum AgentPaneRenderRate: Sendable { | ||
| /// WebKit's default: the display-rate divisor at or above 60 fps (80 Hz | ||
| /// on a 160 Hz display). | ||
| case capped | ||
| /// The display's full rate. | ||
| case full | ||
| /// The full rate while scrolls keep up, capped while the machine is | ||
| /// loaded (``AgentPaneFramePacing``). | ||
| case adaptive | ||
| } | ||
|
|
||
| /// Chooses the agent pane's rendering rate from how its scrolls paced. The | ||
| /// pane renders at the display's full rate until a scroll misses too many | ||
| /// frames, then at WebKit's capped rate (the display-rate divisor at or | ||
| /// above 60 fps) while the machine is loaded. After a backoff, a capped | ||
| /// scroll that paces cleanly brings full rate back; the backoff doubles | ||
| /// when full rate fails again soon after. | ||
| nonisolated struct AgentPaneFramePacing: Equatable, Sendable { | ||
| /// Scrolls shorter than this many frames decide nothing. | ||
| static let minimumFrames = 30 | ||
| /// A frame is late when its interval exceeds the expected one by half. | ||
| static let lateFactor = 1.5 | ||
| /// Late share at full rate that drops to the capped rate. | ||
| static let overloaded = 0.2 | ||
| /// Late share at the capped rate below which full rate may come back. | ||
| static let recovered = 0.05 | ||
| /// The first wait at the capped rate before full rate is tried again, | ||
| /// and the longest. | ||
| static let firstBackoff: TimeInterval = 10 | ||
| static let maximumBackoff: TimeInterval = 160 | ||
|
|
||
| private(set) var fullRate = true | ||
| private var backoff = firstBackoff | ||
| /// When the pane last changed rate. | ||
| private var changedAt: Date? | ||
|
|
||
| /// Records one scroll's frame intervals (ms) on a display that refreshes | ||
| /// every `displayInterval` ms; true when the pane should render at full rate. | ||
| mutating func record(intervals: [Double], displayInterval: Double, at now: Date) -> Bool { | ||
| let capped = Self.cappedInterval(displayInterval) | ||
| guard intervals.count >= Self.minimumFrames, capped > displayInterval else { return fullRate } | ||
| let elapsed = changedAt.map { now.timeIntervalSince($0) } ?? .infinity | ||
| if fullRate { | ||
| if Self.lateShare(intervals, expected: displayInterval) > Self.overloaded { | ||
| // Full rate that fails soon after it came back waits longer next time. | ||
| backoff = elapsed < backoff ? min(backoff * 2, Self.maximumBackoff) : Self.firstBackoff | ||
| fullRate = false | ||
| changedAt = now | ||
| } | ||
| } else if elapsed >= backoff, Self.lateShare(intervals, expected: capped) <= Self.recovered { | ||
| fullRate = true | ||
| changedAt = now | ||
| } | ||
| return fullRate | ||
| } | ||
|
|
||
| /// WebKit's capped frame interval: the display-rate divisor at or above | ||
| /// 60 fps (80 Hz on a 160 Hz display, 60 Hz on 120 Hz). | ||
| static func cappedInterval(_ displayInterval: Double) -> Double { | ||
| guard displayInterval > 0 else { return 0 } | ||
| return displayInterval * max(1, ((1000.0 / 60 + 0.01) / displayInterval).rounded(.down)) | ||
| } | ||
|
|
||
| private static func lateShare(_ intervals: [Double], expected: Double) -> Double { | ||
| Double(intervals.count { $0 > expected * lateFactor }) / Double(intervals.count) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 45 additions & 45 deletions
90
Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneFramePacingTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxNextAgentPane | ||
|
|
||
| /// A 160 Hz display: 6.25 ms frames at full rate, 12.5 ms under WebKit's cap. | ||
| @Suite struct AgentPaneFramePacingTests { | ||
| private let start = Date(timeIntervalSinceReferenceDate: 1_000) | ||
| private let display = 6.25 | ||
| private let backoff = AgentPaneFramePacing.firstBackoff | ||
|
|
||
| /// A scroll's frame intervals: `late` of every 10 take `slow` ms, the rest `fast`. | ||
| private func scroll(_ fast: Double, late: Int = 0, slow: Double = 0, frames: Int = 120) -> [Double] { | ||
| (0..<frames).map { $0 % 10 < late ? slow : fast } | ||
| } | ||
|
|
||
| @Test func aSmoothScrollStaysAtFullRate() { | ||
| var pacing = AgentPaneFramePacing() | ||
| let decision1 = pacing.record(intervals: scroll(display, late: 1, slow: 8), displayInterval: display, at: start) | ||
| #expect(decision1) | ||
| } | ||
|
|
||
| @Test func aScrollThatMissesFramesDropsToTheCappedRate() { | ||
| var pacing = AgentPaneFramePacing() | ||
| let decision2 = pacing.record(intervals: scroll(display, late: 4, slow: 12.5), displayInterval: display, at: start) | ||
| #expect(!decision2) | ||
| } | ||
|
|
||
| @Test func aShortScrollDecidesNothing() { | ||
| var pacing = AgentPaneFramePacing() | ||
| let decision3 = pacing.record(intervals: scroll(12.5, frames: AgentPaneFramePacing.minimumFrames - 1), displayInterval: display, at: start) | ||
| #expect(decision3) | ||
| } | ||
|
|
||
| @Test func aCleanCappedScrollRestoresFullRateAfterTheBackoff() { | ||
| var pacing = AgentPaneFramePacing() | ||
| _ = pacing.record(intervals: scroll(12.5), displayInterval: display, at: start) | ||
| let decision4 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: start.addingTimeInterval(backoff / 2)) | ||
| #expect(!decision4) | ||
| let decision5 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: start.addingTimeInterval(backoff + 1)) | ||
| #expect(decision5) | ||
| } | ||
|
|
||
| @Test func aCappedScrollThatStillMissesFramesKeepsTheCap() { | ||
| var pacing = AgentPaneFramePacing() | ||
| _ = pacing.record(intervals: scroll(12.5), displayInterval: display, at: start) | ||
| let decision6 = pacing.record(intervals: scroll(12.5, late: 3, slow: 25), displayInterval: display, at: start.addingTimeInterval(backoff + 1)) | ||
| #expect(!decision6) | ||
| } | ||
|
|
||
| @Test func aQuickRelapseDoublesTheBackoff() { | ||
| var pacing = AgentPaneFramePacing() | ||
| _ = pacing.record(intervals: scroll(12.5), displayInterval: display, at: start) | ||
| let restored = start.addingTimeInterval(backoff + 1) | ||
| let decision7 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: restored) | ||
| #expect(decision7) | ||
| let relapse = restored.addingTimeInterval(2) | ||
| let decision8 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: relapse) | ||
| #expect(!decision8) | ||
| let decision9 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: relapse.addingTimeInterval(backoff + 1)) | ||
| #expect(!decision9) | ||
| let decision10 = pacing.record(intervals: scroll(12.5), displayInterval: display, at: relapse.addingTimeInterval(2 * backoff + 1)) | ||
| #expect(decision10) | ||
| } | ||
|
|
||
| /// At 60 Hz the cap and the full rate are the same rate. | ||
| @Test func aDisplayNearSixtyHertzIsLeftAtItsRate() { | ||
| var pacing = AgentPaneFramePacing() | ||
| let decision11 = pacing.record(intervals: scroll(33.3), displayInterval: 1000 / 60, at: start) | ||
| #expect(decision11) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.