Skip to content
Closed
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 @@ -26,7 +26,7 @@ final class AppControl {
private let frames: FrameBatcher
private let frameProbe = DebugFrameProbe()
private let mainCPUProbe = DebugMainCPUProbe()
private let terminalDisplayProbe = DebugTerminalDisplayProbe()
private let terminalDisplayProbe = TerminalDisplayProbe()
/// `debug.motion`'s Core Animation commit timer (`ca.commit`), owned here, not by a static.
private let motionCommitProbe = MotionCommitProbe()
#if DEBUG || CMUX_BENCH_HOOKS
Expand Down Expand Up @@ -132,6 +132,7 @@ final class AppControl {
service.router.register([budget.method(registry: registry)])
service.router.register(DebugHibernationControl.methods(services))
service.router.register(DebugWindowsControl.methods(services) + [services.daemon.debugControlMethod { [weak services] in services?.windows.controllers.map { (id: $0.state.id, shows: $0.recoveryShows) } ?? [] }, services.daemon.debugRequestLatencyMethod()] + services.daemon.creationHold.methods + services.daemon.closeReadHold.methods)
terminalDisplayProbe.terminals = { [weak services] in services?.cache.terminals.map { ($0.key, $0.value.session) } ?? [] }
service.router.register([
.mainActor("debug.frames") { [probe = frameProbe] call in .value(probe.handle(call.params)) },
// Main-thread blocking call sites since launch (threading-model.md); {"reset": true} zeroes the counts.
Expand All @@ -144,8 +145,12 @@ final class AppControl {
},
// Main-thread CPU per frame against a 120 Hz frame (anim-perf-terminal.py).
.mainActor("debug.main_cpu") { [probe = mainCPUProbe] call in .value(probe.handle(call.params)) },
// Main-thread time in Ghostty's display passes (bench-terminal-resize.py).
.mainActor("debug.terminal_display") { [probe = terminalDisplayProbe] call in .value(probe.handle(call.params)) },
// Main-thread time in Ghostty's display passes, and whether each shown terminal changes size with its pane in
// every frame (bench-terminal-resize.py).
.mainActor("debug.terminal_display") { [probe = terminalDisplayProbe] call in
let report = probe.handle(action: call.params["action"]?.stringValue)
return .value((try? JSONDecoder().decode(CmuxNextSettings.JSONValue.self, from: JSONEncoder().encode(report))) ?? .null)
},
// Measured animation spans (plans/cmux-next/motion.md).
.mainActor("debug.motion") { [weak services, probe = motionCommitProbe] call in
.value(DebugMotion.handle(call.params, services, commitProbe: probe))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import CmuxNextControl
import CmuxNextSettings
import Foundation
import ObjectiveC
import QuartzCore
Expand All @@ -12,26 +10,63 @@ import QuartzCore
/// `start` installs the timing override once (it stays for the process; unarmed it only forwards) and
/// clears the record; `stop` returns it: calls, their total and max ms, and passes (calls of one Core
/// Animation commit, consecutive within 1 ms) with their p50/p95/max ms.
///
/// From `start` to `stop` it also checks that each terminal changes size with its pane (the bench fails a drag or a
/// key resize that holds one, ``TerminalPaneFollowProbe``): `stop` adds `follow_frames`, `terminals_followed` and
/// `follow_presenter` (the frame presenters' counts in the run).
@MainActor
final class DebugTerminalDisplayProbe {
public final class TerminalDisplayProbe {
/// What `debug.terminal_display` answers (snake_case keys). The pane check's keys are only in `stop`'s reply.
public nonisolated struct Report: Encodable, Sendable {
public var armed: Bool
public var calls: Int
public var totalMs: Double
public var maxCallMs: Double
public var passes: Int
public var passP50Ms: Double
public var passP95Ms: Double
public var passMaxMs: Double
public var followFrames: Int?
public var terminalsFollowed: [String: TerminalPaneFollowTally]?
public var followPresenter: TerminalPaneFollowPresenterCounts?

nonisolated enum CodingKeys: String, CodingKey {
case armed, calls, passes
case totalMs = "total_ms", maxCallMs = "max_call_ms", passP50Ms = "pass_p50_ms", passP95Ms = "pass_p95_ms"
case passMaxMs = "pass_max_ms", followFrames = "follow_frames", terminalsFollowed = "terminals_followed"
case followPresenter = "follow_presenter"
}
}

private var armed = false
private var installed = false
/// Per call: start (CACurrentMediaTime) and duration, seconds.
private var calls: [(start: CFTimeInterval, duration: CFTimeInterval)] = []
static let capacity = 200_000
private let follow = TerminalPaneFollowProbe()
/// The app's live terminals by tab key (the content cache's), for the pane check; set by the app.
public var terminals: @MainActor () -> [(key: String, session: TerminalSession)] = { [] }

public init() {}

func handle(_ params: [String: JSONValue]) -> JSONValue {
switch params["action"]?.stringValue ?? "read" {
/// `action`: "start", "stop", or anything else to read the record.
public func handle(action: String?) -> Report {
switch action ?? "read" {
case "start":
calls.removeAll(keepingCapacity: true)
armed = install()
return .object(["armed": .bool(armed)])
follow.start(terminals())
return report()
case "stop":
let stats = stats()
var report = report()
armed = false
return stats
follow.stop()
report.followFrames = follow.frames
report.terminalsFollowed = follow.tallies
report.followPresenter = follow.presenter
return report
default:
return stats()
return report()
}
}

Expand All @@ -54,7 +89,7 @@ final class DebugTerminalDisplayProbe {

/// Built outside the main actor so the block carries no isolation check of its own.
private nonisolated static func timed(_ original: @escaping @convention(c) (AnyObject, Selector) -> Void,
probe: DebugTerminalDisplayProbe) -> @convention(block) (AnyObject) -> Void {
probe: TerminalDisplayProbe) -> @convention(block) (AnyObject) -> Void {
{ layer in
let start = CACurrentMediaTime()
original(layer, #selector(CALayer.display))
Expand All @@ -65,7 +100,7 @@ final class DebugTerminalDisplayProbe {
}
}

private func stats() -> JSONValue {
private func report() -> Report {
var passes: [Double] = []
var passEnd: CFTimeInterval = -1
for call in calls {
Expand All @@ -81,16 +116,8 @@ final class DebugTerminalDisplayProbe {
return sorted[min(sorted.count - 1, Int((p * Double(sorted.count - 1)).rounded()))]
}
let durations: [Double] = calls.map { $0.duration * 1000 }
let total: Double = durations.reduce(0, +)
var result: [String: JSONValue] = [:]
result["armed"] = .bool(armed)
result["calls"] = .number(Double(calls.count))
result["total_ms"] = .number(total)
result["max_call_ms"] = .number(durations.max() ?? 0)
result["passes"] = .number(Double(passes.count))
result["pass_p50_ms"] = .number(percentile(0.5))
result["pass_p95_ms"] = .number(percentile(0.95))
result["pass_max_ms"] = .number(sorted.last ?? 0)
return .object(result)
return Report(armed: armed, calls: calls.count, totalMs: durations.reduce(0, +), maxCallMs: durations.max() ?? 0,
passes: passes.count, passP50Ms: percentile(0.5), passP95Ms: percentile(0.95),
passMaxMs: sorted.last ?? 0)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import AppKit
public import CoreGraphics
import CmuxNextCompat
import CmuxNextWakeups
import Foundation

/// One frame of a shown terminal against its pane (`debug.terminal_display`, the terminal resize bench's rule:
/// a drag or a key resize fails when a terminal does not change size with its pane). Sizes in points.
public nonisolated struct TerminalPaneFollowSizes: Sendable, Equatable {
/// The size the surface takes in its host now (``TerminalHostView/surfaceSize(forHostSize:)``): its pane's size.
public var area: CGSize
/// The size the host asked of the surface: the frame it wants while its window's commit owns the frame
/// (`motion.frameOwner.terminals`), else the surface view's frame. Not `area` while the surface is held.
public var asked: CGSize
/// What shows: the surface layer's bounds.
public var shown: CGSize
/// A size was asked of Ghostty and its frame is not shown yet (the frame owner keeps the old one meanwhile).
public var inFlight: Bool

public init(area: CGSize, asked: CGSize, shown: CGSize, inFlight: Bool = false) {
self.area = area
self.asked = asked
self.shown = shown
self.inFlight = inFlight
}
}

/// One terminal's frames against its pane over a run of `debug.terminal_display`: frames sampled, frames in which the
/// host asked the surface for another size than its pane's (held), frames in which the layer showed another size than
/// its pane's (lagging), frames with a size asked of Ghostty and not shown yet (in flight), and the first few held and
/// lagging ones. A size differs when either axis is more than ``tolerance`` off.
public nonisolated struct TerminalPaneFollowTally: Sendable, Equatable, Encodable {
/// One frame that differed: its index in the run (from 0) and its sizes, encoded as [width, height] in points.
public nonisolated struct Mismatch: Sendable, Equatable, Encodable {
public var frame: Int
public var sizes: TerminalPaneFollowSizes

nonisolated enum CodingKeys: String, CodingKey { case frame, area, asked, shown }

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
func pair(_ size: CGSize) -> [Double] { [size.width, size.height].map { (Double($0) * 10).rounded() / 10 } }
try container.encode(frame, forKey: .frame)
try container.encode(pair(sizes.area), forKey: .area)
try container.encode(pair(sizes.asked), forKey: .asked)
try container.encode(pair(sizes.shown), forKey: .shown)
}
}

public static let tolerance: CGFloat = 0.5
/// Mismatches kept of each kind.
public static let kept = 3

public private(set) var frames = 0
public private(set) var held = 0
public private(set) var lagging = 0
public private(set) var inFlight = 0
public private(set) var firstHeld: [Mismatch] = []
public private(set) var firstLagging: [Mismatch] = []

nonisolated enum CodingKeys: String, CodingKey {
case frames, held, lagging, inFlight = "in_flight", firstHeld = "first_held", firstLagging = "first_lagging"
}

public init() {}

/// Counts frame `frame` of the run.
public mutating func record(_ sizes: TerminalPaneFollowSizes, frame: Int) {
frames += 1
if sizes.inFlight { inFlight += 1 }
if Self.differ(sizes.asked, sizes.area) {
held += 1
if firstHeld.count < Self.kept { firstHeld.append(Mismatch(frame: frame, sizes: sizes)) }
}
if Self.differ(sizes.shown, sizes.area) {
lagging += 1
if firstLagging.count < Self.kept { firstLagging.append(Mismatch(frame: frame, sizes: sizes)) }
}
}

static func differ(_ a: CGSize, _ b: CGSize) -> Bool {
abs(a.width - b.width) > tolerance || abs(a.height - b.height) > tolerance
}
}

extension TerminalSession {
/// This terminal's sizes against its pane now (``TerminalPaneFollowSizes``); nil when its surface is not shown in
/// its host (no window, hidden or parked). Cheap: no Ghostty call.
var paneFollowSizes: TerminalPaneFollowSizes? {
let surface = surfaceView
guard surface.isShown, surface.superview === view else { return nil }
let ownership = surface.frameOwnership
let asked = ownership.embedderPresents ? (ownership.wanted?.size ?? surface.frame.size) : surface.frame.size
return TerminalPaneFollowSizes(area: view.surfaceSize(forHostSize: view.bounds.size), asked: asked,
shown: surface.layer?.bounds.size ?? surface.bounds.size,
inFlight: ownership.inFlight != nil)
}
}

/// The frame presenters' counts over a run of the pane check, summed over the followed windows
/// (``TerminalFramePresenter/Stats``): presented requests, passes that kept an old frame (its new one was not drawn
/// yet), passes that waited for another of a group, and requests that took their bounds without their frame.
public nonisolated struct TerminalPaneFollowPresenterCounts: Sendable, Equatable, Encodable {
public var passes = 0
public var presented = 0
public var keeps = 0
public var groupWaits = 0
public var fallbacks = 0

nonisolated enum CodingKeys: String, CodingKey {
case passes, presented, keeps, groupWaits = "group_waits", fallbacks
}

mutating func add(from start: TerminalFramePresenter.Stats, to end: TerminalFramePresenter.Stats) {
passes += end.passes - start.passes
presented += end.presented - start.presented
keeps += end.keeps - start.keeps
groupWaits += end.groupWaits - start.groupWaits
fallbacks += end.fallbacks - start.fallbacks
}
}

/// ``TerminalDisplayProbe``'s pane check: whether each shown terminal changes size with its pane in every frame of a
/// drag or a key resize (scripts/cmux-next/bench-terminal-resize.py).
///
/// From ``start(_:)`` to ``stop()``, each window that shows a followed terminal has a client on its own frame clock
/// (``FrameScheduler/forWindow(_:)``, the clock its layout springs and its frame presenter tick on), which marks that
/// window's frames. An observer of the main run loop, last after Core Animation's commit (`beforeWaiting`, order
/// `CFIndex.max`), samples the terminals of each window whose frame ticked in that turn: a sample is the geometry that
/// turn's commit carried for that window's frame. It samples only the terminals shown at ``start(_:)`` (a drag or a key
/// resize shows no new terminal): O(those terminals) per frame, and nothing at all while it does not run. At most
/// ``maximumRun``: a client that never sends `stop` must not keep a display link running.
@MainActor
final class TerminalPaneFollowProbe {
static let maximumRun: Duration = .seconds(600)

private struct Followed {
let key: String
weak var session: TerminalSession?
var tally = TerminalPaneFollowTally()
}

/// One followed window: its frame client, whether a frame ticked since the last sample, its frames sampled, its
/// terminals (indices into ``followed``) and its presenter's counts at ``start(_:)``.
private struct WindowClock {
weak var window: NSWindow?
let client: FrameClient
var pending = false
var frames = 0
var members: [Int] = []
let presenterAtStart: TerminalFramePresenter.Stats
}

private var followed: [Followed] = []
private var clocks: [ObjectIdentifier: WindowClock] = [:]
/// Frames sampled since ``start(_:)``, summed over the followed windows.
private(set) var frames = 0
/// The presenters' counts from ``start(_:)`` to ``stop()``.
private(set) var presenter = TerminalPaneFollowPresenterCounts()
private var observer: CFRunLoopObserver?
private let expiry = DemandTimer(owner: "debug.terminal_display.follow.expiry")

/// Clears the record and starts sampling the terminals of `terminals` shown now, each on its window's frames.
func start(_ terminals: [(key: String, session: TerminalSession)]) {
stop()
(frames, presenter, clocks) = (0, TerminalPaneFollowPresenterCounts(), [:])
followed = terminals.filter { $0.session.paneFollowSizes != nil }.map { Followed(key: $0.key, session: $0.session) }
for (index, entry) in followed.enumerated() {
guard let window = entry.session?.view.window else { continue }
let id = ObjectIdentifier(window)
if clocks[id] == nil {
let client = FrameClient(owner: "debug.terminal_display.follow", isAnimation: false,
on: FrameScheduler.forWindow(window)) { [weak self] _ in
self?.clocks[id]?.pending = true
return true
}
clocks[id] = WindowClock(window: window, client: client,
presenterAtStart: TerminalFramePresenter.forWindow(window).stats)
}
clocks[id]?.members.append(index)
}
guard !clocks.isEmpty else { return }
for clock in clocks.values { clock.client.activate() }
// Last after the commit (`CFIndex.max`); it only reads geometry: its context says so to the commit-boundary audit.
let observer = MainRunLoopObserver.afterCommitObserver(activities: .beforeWaiting, order: CFIndex.max) { [weak self] _ in
MainActor.assumeIsolated { self?.sampleFramedWindows() } // main-proof: an observer of the main run loop
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
self.observer = observer
expiry.schedule(after: Self.maximumRun) { @MainActor [weak self] in self?.stop() }
}

/// Stops sampling and adds up the presenters' counts; the record stays for ``tallies``.
func stop() {
expiry.cancel()
if let observer { CFRunLoopObserverInvalidate(observer) }
observer = nil
for id in clocks.keys {
guard let clock = clocks[id], clock.client.isActive else { continue }
clock.client.deactivate()
if let window = clock.window {
presenter.add(from: clock.presenterAtStart, to: TerminalFramePresenter.forWindow(window).stats)
}
}
}

/// Per terminal, by tab key.
var tallies: [String: TerminalPaneFollowTally] {
Dictionary(followed.map { ($0.key, $0.tally) }, uniquingKeysWith: { first, _ in first })
}

private func sampleFramedWindows() {
for id in clocks.keys {
guard let clock = clocks[id], clock.pending else { continue }
clocks[id]?.pending = false
for index in clock.members {
guard let sizes = followed[index].session?.paneFollowSizes else { continue }
followed[index].tally.record(sizes, frame: clock.frames)
}
clocks[id]?.frames += 1
frames += 1
}
}
}
Loading
Loading