diff --git a/Packages/Shared/CmuxGhosttyKit/Package.swift b/Packages/Shared/CmuxGhosttyKit/Package.swift index ca8c03edcc19..48b61c2bee69 100644 --- a/Packages/Shared/CmuxGhosttyKit/Package.swift +++ b/Packages/Shared/CmuxGhosttyKit/Package.swift @@ -42,8 +42,8 @@ let package = Package( ), .binaryTarget( name: "GhosttyNextKit", - url: "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-3320bd06ec13ca5479ef842372be1736d9cdfecf-apple-v6/GhosttyNextKit.xcframework.zip", - checksum: "788fe6a6e42fc0062007cf7292992dd3bb770cbce7a33f5040d5180e40150bc1" + url: "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf-apple-v6/GhosttyNextKit.xcframework.zip", + checksum: "8ef3906625d23ee463cf2e83a598bed2dd72de0d310a8dab5e067cfc573737c1" ), ] ) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxApplication.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxApplication.swift index 5458cad1f166..6634a9cc4a86 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxApplication.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/CmuxApplication.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextWakeups import os /// Chromium's application protocols (base/message_loop/message_pump_apple.h, @@ -54,6 +55,9 @@ final class CmuxApplication: NSApplication, CEFAppProtocol { func setHandlingSendEvent(_ value: Bool) { handlingSendEvent = value } override func sendEvent(_ event: NSEvent) { + let probesKey = event.type == .keyDown && TypingLatencyProbe.isEnabled + if probesKey { TypingLatencyProbe.shared.keyDown(eventTimestamp: event.timestamp) } + defer { if probesKey { TypingLatencyProbe.shared.mark(.dispatchEnd) } } let previousSynthetic = currentEventIsSynthetic currentEventIsSynthetic = !SyntheticInput.isUserInput(event) defer { currentEventIsSynthetic = previousSynthetic } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Terminal/TerminalAttachment.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Terminal/TerminalAttachment.swift index 284db2e70cf0..0cd492190e3d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Terminal/TerminalAttachment.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Terminal/TerminalAttachment.swift @@ -1,5 +1,6 @@ public import Foundation import Synchronization +import CmuxNextWakeups import os /// One attached terminal view on its own connection (v12 has no stream @@ -134,6 +135,7 @@ public actor TerminalAttachment: TerminalByteChannel { if case .closed = event { queue.finish(event) } else { + if case .output = event { TypingLatencyProbe.shared.mark(.outputDecoded) } queue.push(event) } }, @@ -199,6 +201,7 @@ public actor TerminalAttachment: TerminalByteChannel { }) { id in try WireCoding.encodeRequest(SendInputRequest(surface: surface, bytes: data), id: id) } + TypingLatencyProbe.shared.mark(.socketSubmit) } catch { logger.debug("input dropped after close: \(String(describing: error), privacy: .public)") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalOutputLane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalOutputLane.swift index e0ecdbf75796..0125bbab571c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalOutputLane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalOutputLane.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxNextWakeups import GhosttyNextKit import Synchronization import os @@ -59,6 +60,7 @@ nonisolated final class TerminalOutputLane: @unchecked Sendable { guard let base = buffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } ghostty_surface_process_output(surface, base, UInt(buffer.count)) } + TypingLatencyProbe.shared.mark(.outputParsed) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSession.swift index 4f83c20fa521..01b2347bbe0a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSession.swift @@ -219,6 +219,7 @@ public final class TerminalSession { surfaceHasContent = true TerminalTimings.contentApplied() case .output(let data): + TypingLatencyProbe.shared.mark(.outputMain) ExpectedActivity.shared.note(.terminalOutput) guard let lane = surfaceView.lane else { return } await lane.waitForCapacity() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceBridge.swift index f279ebfafa7b..9d2ed6a0ac4e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceBridge.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxNextWakeups import GhosttyNextKit /// Per-surface userdata handed to Ghostty as both `userdata` and @@ -77,5 +78,6 @@ nonisolated struct TerminalInputSink: Sendable { /// to the session's ordered writer. nonisolated func ghosttyIOWrite(_ userdata: UnsafeMutableRawPointer?, _ bytes: UnsafePointer?, _ length: UInt) { guard let bridge = SurfaceBridge.from(userdata), let bytes, length > 0 else { return } + TypingLatencyProbe.shared.mark(.ioWrite) bridge.input.send(Data(bytes: bytes, count: Int(length))) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift index 9ed51c1f5f0d..3e821ede26ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift @@ -1,6 +1,7 @@ public import AppKit import CmuxNextDesign import CmuxNextTerminalGeometry +import CmuxNextWakeups import GhosttyNextKit import os import QuartzCore @@ -130,6 +131,7 @@ public final class TerminalSurfaceView: NSView { // Light/dark themes (default Apple System Colors) follow the app. GhosttyRuntime.shared.registerColorScheme(of: self) lane = TerminalOutputLane(surface: surface, label: "com.cmuxterm.next.terminal.output") + if TypingLatencyProbe.isEnabled, let layer { TypingFrameProbe.install(on: layer) } registerForDraggedTypes([.fileURL, .URL, .string]) updateContentScale() updateSurfaceSize(forceReport: true) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TypingFrameProbe.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TypingFrameProbe.swift new file mode 100644 index 000000000000..44d866622e75 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TypingFrameProbe.swift @@ -0,0 +1,34 @@ +import CmuxNextWakeups +import ObjectiveC +import QuartzCore + +/// Marks ``TypingLatencyProbe/Mark/contents`` when Ghostty hands a finished +/// frame to its `IOSurfaceLayer` (`setContents:` on the main thread; Core +/// Animation commits it at the end of that run-loop turn). Installed only +/// while the probe is enabled: it adds a `setContents:` override to the +/// layer's runtime class that calls `CALayer`'s and then marks. +enum TypingFrameProbe { + private typealias SetContents = @convention(c) (AnyObject, Selector, AnyObject?) -> Void + + @MainActor private static var installed: Set = [] + + @MainActor static func install(on layer: CALayer) { + let layerClass: AnyClass = type(of: layer) + guard layerClass != CALayer.self, installed.insert(ObjectIdentifier(layerClass)).inserted else { return } + let selector = #selector(setter: CALayer.contents) + guard let inherited = class_getInstanceMethod(CALayer.self, selector) else { return } + let original = unsafeBitCast(method_getImplementation(inherited), to: SetContents.self) + class_addMethod(layerClass, selector, imp_implementationWithBlock(override(calling: original, selector)), + method_getTypeEncoding(inherited)) + } + + /// Built outside the main actor so the block carries no isolation check: + /// a layer whose contents are set off the main thread must not trap. + private nonisolated static func override(calling original: SetContents, _ selector: Selector) + -> @convention(block) (AnyObject, AnyObject?) -> Void { + { layer, contents in + original(layer, selector, contents) + TypingLatencyProbe.shared.mark(.contents) + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/TypingLatencyProbe.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/TypingLatencyProbe.swift new file mode 100644 index 000000000000..8a3fa39ddc8d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/TypingLatencyProbe.swift @@ -0,0 +1,134 @@ +import Foundation +import Synchronization + +/// Opt-in keystroke-to-frame timeline for one terminal key at a time +/// (`CMUX_NEXT_TYPING_PROBE=`). Off by default: every hop then +/// costs one load of ``isEnabled``. +/// +/// A sample opens at a key-down and records the first time each later hop +/// runs: AppKit dispatch, Ghostty's write callback, the attach socket +/// submit, the echo's decode on the attach reader, its main-thread hop, +/// its parse on the output lane, and the first `layer.contents` set after +/// that parse (the frame Core Animation commits). The row is appended when +/// that frame lands. Drive it with keys paced wider than one round trip; +/// a key that arrives while a sample is open closes the old one unfinished. +/// Times are milliseconds after the event's own timestamp (seconds since +/// boot, the same clock as `CLOCK_UPTIME_RAW`). +public final class TypingLatencyProbe: Sendable { + public static let shared = TypingLatencyProbe(path: ProcessInfo.processInfo.environment["CMUX_NEXT_TYPING_PROBE"]) + + public static var isEnabled: Bool { shared.output != nil } + + public enum Mark: Int, CaseIterable, Sendable { + case dispatchStart, dispatchEnd, ioWrite, socketSubmit, outputDecoded, outputMain, outputParsed, contents + + var columnName: String { + switch self { + case .dispatchStart: "dispatch_start_ms" + case .dispatchEnd: "dispatch_end_ms" + case .ioWrite: "io_write_ms" + case .socketSubmit: "socket_submit_ms" + case .outputDecoded: "output_decoded_ms" + case .outputMain: "output_main_ms" + case .outputParsed: "output_parsed_ms" + case .contents: "contents_ms" + } + } + + /// The hop that must have run first, so output from an earlier key + /// is not taken for this key's echo. + var after: Mark? { + switch self { + case .dispatchStart, .dispatchEnd, .ioWrite: nil + case .socketSubmit: .ioWrite + case .outputDecoded: .socketSubmit + case .outputMain: .outputDecoded + case .outputParsed: .outputMain + case .contents: .outputParsed + } + } + } + + private struct Sample { + var index: Int + var event: UInt64 + var marks = [UInt64?](repeating: nil, count: Mark.allCases.count) + var ioWriteOnMain = false + } + + private struct State { + var next = 0 + var open: Sample? + } + + private let output: FileHandle? + private let state = Mutex(State()) + private let writes = DispatchQueue(label: "com.cmuxterm.next.typing-probe", qos: .utility) + + init(path: String?) { + guard let path, !path.isEmpty, + FileManager.default.createFile(atPath: path, contents: nil), + let handle = FileHandle(forWritingAtPath: path) + else { + output = nil + return + } + output = handle + let header = (["index", "complete", "io_write_on_main"] + Mark.allCases.map(\.columnName)).joined(separator: ",") + "\n" + handle.write(Data(header.utf8)) + } + + /// Opens a sample for a key-down whose `NSEvent.timestamp` is `eventTimestamp`. + public func keyDown(eventTimestamp: Double) { + guard output != nil else { return } + let now = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + let event = eventTimestamp > 0 ? UInt64(eventTimestamp * 1_000_000_000) : now + let unfinished = state.withLock { state -> Sample? in + let previous = state.open + var sample = Sample(index: state.next, event: event) + sample.marks[Mark.dispatchStart.rawValue] = now + state.next += 1 + state.open = sample + return previous + } + if let unfinished { emit(unfinished, complete: false) } + } + + /// Records `mark` for the open sample the first time it runs. A mark + /// before the hop it follows (``Mark/after``) is ignored. Output hops + /// are not tied to a surface: type into one terminal while sampling. + public func mark(_ mark: Mark) { + guard output != nil else { return } + let now = clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + let onMain = mark == .ioWrite && Thread.isMainThread + let finished = state.withLock { state -> Sample? in + guard var sample = state.open, sample.marks[mark.rawValue] == nil else { return nil } + if let after = mark.after, sample.marks[after.rawValue] == nil { return nil } + sample.marks[mark.rawValue] = now + if mark == .ioWrite { sample.ioWriteOnMain = onMain } + if mark == .contents { + state.open = nil + return sample + } + state.open = sample + return nil + } + if let finished { emit(finished, complete: true) } + } + + /// Waits for every row emitted so far to reach the file (tests). + func flush() { + writes.sync {} + } + + private func emit(_ sample: Sample, complete: Bool) { + guard let output else { return } + writes.async { + let millis = sample.marks.map { mark in + mark.map { String(format: "%.3f", Double(Int64(bitPattern: $0 &- sample.event)) / 1_000_000) } ?? "" + } + let row = (["\(sample.index)", complete ? "1" : "0", sample.ioWriteOnMain ? "1" : "0"] + millis).joined(separator: ",") + "\n" + output.write(Data(row.utf8)) + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextWakeupsTests/TypingLatencyProbeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextWakeupsTests/TypingLatencyProbeTests.swift new file mode 100644 index 000000000000..24002d10140d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextWakeupsTests/TypingLatencyProbeTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import CmuxNextWakeups + +@Suite struct TypingLatencyProbeTests { + private func rows(_ path: String) throws -> [[String: String]] { + let lines = try String(contentsOfFile: path, encoding: .utf8).split(separator: "\n").map(String.init) + let header = lines[0].split(separator: ",", omittingEmptySubsequences: false).map(String.init) + return lines.dropFirst().map { line in + let fields = line.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + return Dictionary(uniqueKeysWithValues: zip(header, fields)) + } + } + + private func temporaryPath() -> String { + FileManager.default.temporaryDirectory.appendingPathComponent("typing-probe-\(UUID().uuidString).csv").path + } + + @Test func disabledWithoutPathWritesNothing() { + let probe = TypingLatencyProbe(path: nil) + probe.keyDown(eventTimestamp: 1) + probe.mark(.contents) + probe.flush() + } + + @Test func aKeyThroughEveryHopWritesOneCompleteRow() throws { + let path = temporaryPath() + defer { try? FileManager.default.removeItem(atPath: path) } + let probe = TypingLatencyProbe(path: path) + probe.keyDown(eventTimestamp: Double(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / 1e9) + for mark in TypingLatencyProbe.Mark.allCases where mark != .dispatchStart { probe.mark(mark) } + probe.flush() + let rows = try rows(path) + #expect(rows.count == 1) + #expect(rows[0]["complete"] == "1") + for mark in TypingLatencyProbe.Mark.allCases { + let value = try #require(Double(rows[0][mark.columnName] ?? "")) + #expect(value >= 0) + } + } + + /// Output decoded before this key's input reached the socket is an + /// earlier key's echo and must not be taken for this key's. + @Test func outputBeforeTheInputIsIgnored() throws { + let path = temporaryPath() + defer { try? FileManager.default.removeItem(atPath: path) } + let probe = TypingLatencyProbe(path: path) + probe.keyDown(eventTimestamp: 0) + probe.mark(.outputDecoded) + probe.mark(.outputMain) + probe.mark(.outputParsed) + probe.mark(.contents) + // The next key closes the open sample unfinished. + probe.keyDown(eventTimestamp: 0) + probe.flush() + let rows = try rows(path) + #expect(rows.count == 1) + #expect(rows[0]["complete"] == "0") + #expect(rows[0]["output_decoded_ms"] == "") + #expect(rows[0]["contents_ms"] == "") + } +} diff --git a/ghostty-next b/ghostty-next index 3320bd06ec13..2199dc93ee3b 160000 --- a/ghostty-next +++ b/ghostty-next @@ -1 +1 @@ -Subproject commit 3320bd06ec13ca5479ef842372be1736d9cdfecf +Subproject commit 2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf diff --git a/ios/cmux/Settings.bundle/Acknowledgements.plist b/ios/cmux/Settings.bundle/Acknowledgements.plist index 16cb46e601d2..6597107d91bb 100644 --- a/ios/cmux/Settings.bundle/Acknowledgements.plist +++ b/ios/cmux/Settings.bundle/Acknowledgements.plist @@ -5135,11 +5135,11 @@ z2d 0.12.1 is licensed under the Mozilla Public License 2.0 (MPL-2.0). The Source Code Form of the exact version in this build is available at: -- https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz (the archive that Ghostty 3320bd06ec13ca5479ef842372be1736d9cdfecf fetches, Zig package z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ-) +- https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz (the archive that Ghostty 2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf fetches, Zig package z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ-) - https://github.com/vancluever/z2d/tree/7dbae85c81784dba9988320bf9543ed9a81350c8 (upstream tag v0.12.1) - the cmux release source archive cmux-next-source-<build>.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/nightly-next/ (<build> is the build number in the app's version), and the cmux source of this build at its public tag https://github.com/manaflow-ai/cmux/tree/cmux-next-src-<commit, 11 characters>. The archive contains Ghostty at the revision this build uses and every Ghostty Zig package archive, including this one. -z2d is licensed under the Mozilla Public License 2.0 (MPL-2.0); its text is above. This app contains z2d in Executable Form, unmodified. The Source Code Form of the exact version in this app is in the cmux iOS release source archive cmux-ios-source-<build>.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/ios-source/ (<build> is the build number in the app's version), and the cmux source of this build is at its public tag https://github.com/manaflow-ai/cmux/tree/cmux-next-src-<commit, 11 characters>. The archive contains Ghostty 3320bd06ec13ca5479ef842372be1736d9cdfecf (https://github.com/manaflow-ai/ghostty-next/tree/3320bd06ec13ca5479ef842372be1736d9cdfecf) and every Ghostty Zig package archive, z2d included. +z2d is licensed under the Mozilla Public License 2.0 (MPL-2.0); its text is above. This app contains z2d in Executable Form, unmodified. The Source Code Form of the exact version in this app is in the cmux iOS release source archive cmux-ios-source-<build>.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/ios-source/ (<build> is the build number in the app's version), and the cmux source of this build is at its public tag https://github.com/manaflow-ai/cmux/tree/cmux-next-src-<commit, 11 characters>. The archive contains Ghostty 2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf (https://github.com/manaflow-ai/ghostty-next/tree/2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf) and every Ghostty Zig package archive, z2d included. Title z2d (in Ghostty) @@ -5531,7 +5531,7 @@ obstacle to adoption, that text has been removed. FooterText - GhosttyNextKit https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-3320bd06ec13ca5479ef842372be1736d9cdfecf-apple-v6/GhosttyNextKit.xcframework.zip sha256 788fe6a6e42fc0062007cf7292992dd3bb770cbce7a33f5040d5180e40150bc1; Ghostty 3320bd06ec13ca5479ef842372be1736d9cdfecf; license tree 102 files. Omitted as absent from the linked app (iterm2_themes, zig_js): cmux-ci job 3cb75148fdc2bb261c043703, artifact sha256 c1e4854d61ea00ba5a7693e802c9610dc2e671cbc8adfec0611c286f6b18c4bc. Generated by scripts/cmux-next/notices/ios_notices.py. + GhosttyNextKit https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf-apple-v6/GhosttyNextKit.xcframework.zip sha256 8ef3906625d23ee463cf2e83a598bed2dd72de0d310a8dab5e067cfc573737c1; Ghostty 2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf; license tree 102 files. Omitted as absent from the linked app (iterm2_themes, zig_js): cmux-ci job 2c4987f6b4995f7758d98ad6, artifact sha256 244ff3a65e8a617e1bb6d3ecaa219859ea61cb395e52e4cffb48a6ef19250ec4. Generated by scripts/cmux-next/notices/ios_notices.py. Title Build record diff --git a/scripts/cmux-next/bench-typing.py b/scripts/cmux-next/bench-typing.py new file mode 100755 index 000000000000..dbe44e56961d --- /dev/null +++ b/scripts/cmux-next/bench-typing.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Keystroke-to-frame latency for one cmux-next terminal, hop by hop. + +Launch a tagged cmux-next with CMUX_NEXT_TYPING_PROBE= (see +TypingLatencyProbe.swift), focus a terminal at a shell prompt, then run: + + scripts/cmux-next/bench-typing.py --pid PID --window WINDOW_ID --csv PATH [--samples 60] + +Each sample is one real key-down posted to the app by CuaDriver +(`press_key`, background delivery), paced wider than a round trip so one +key is in flight at a time. The app's probe writes one CSV row per key: +milliseconds after the event's timestamp at which AppKit dispatch began +and ended, Ghostty called io_write, the attach socket took the input, the +echo was decoded, reached the main actor, was parsed, and the first frame +after that parse was handed to Core Animation (`contents_ms`). The photon +follows at the compositor's next vsync, at most one refresh later. + +Prints median, p95 and range per hop and per segment, and writes the same +as JSON next to the CSV. +""" + +import argparse +import csv +import json +import statistics +import subprocess +import sys +import time +from pathlib import Path + +HOPS = [ + "dispatch_start_ms", "dispatch_end_ms", "io_write_ms", "socket_submit_ms", + "output_decoded_ms", "output_main_ms", "output_parsed_ms", "contents_ms", +] +SEGMENTS = [ + ("event to dispatch", None, "dispatch_start_ms"), + ("dispatch to io_write", "dispatch_start_ms", "io_write_ms"), + ("io_write to socket", "io_write_ms", "socket_submit_ms"), + ("socket to echo decoded (daemon, host, shell)", "socket_submit_ms", "output_decoded_ms"), + ("decoded to main actor", "output_decoded_ms", "output_main_ms"), + ("main actor to parsed", "output_main_ms", "output_parsed_ms"), + ("parsed to frame contents", "output_parsed_ms", "contents_ms"), +] + + +def summarize(values): + values = sorted(values) + if not values: + return None + p95 = values[min(len(values) - 1, round(0.95 * (len(values) - 1)))] + return {"n": len(values), "median": round(statistics.median(values), 2), "p95": round(p95, 2), + "min": round(values[0], 2), "max": round(values[-1], 2)} + + +def press(driver, pid, window, key): + payload = json.dumps({"pid": pid, "window_id": window, "key": key}) + result = subprocess.run([driver, "call", "press_key", payload], check=True, capture_output=True, text=True, timeout=10) + # Without an exact window CuaDriver answers with candidates and posts nothing. + if '"candidates"' in result.stdout: + sys.exit(f"press_key did not post: {result.stdout[:300]}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pid", type=int, required=True) + parser.add_argument("--window", type=int, required=True, help="CuaDriver window_id of the cmux window") + parser.add_argument("--csv", type=Path, required=True) + parser.add_argument("--samples", type=int, default=60) + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--interval", type=float, default=0.3, help="seconds between keys") + parser.add_argument("--keys", default="abcdefghijklmnopqrstuvwxyz") + parser.add_argument("--driver", default=str(Path.home() / ".local/bin/cua-driver")) + parser.add_argument("--analyze-only", action="store_true", help="summarize an existing CSV") + args = parser.parse_args() + + if not args.analyze_only: + rows_before = len(args.csv.read_text().splitlines()) - 1 if args.csv.exists() else -1 + if rows_before < 0: + sys.exit(f"{args.csv} has no header: was the app launched with CMUX_NEXT_TYPING_PROBE?") + for index in range(args.warmup + args.samples): + press(args.driver, args.pid, args.window, args.keys[index % len(args.keys)]) + time.sleep(args.interval) + if (index + 1) % 40 == 0: + press(args.driver, args.pid, args.window, "return") + time.sleep(args.interval) + time.sleep(1) + + with args.csv.open() as handle: + rows = list(csv.DictReader(handle)) + if not args.analyze_only: + # Only this run's rows, past its warm-up keys. + rows = rows[rows_before + args.warmup:] + complete = [row for row in rows if row["complete"] == "1"] + measured = complete + result = { + "rows": len(rows), "complete": len(complete), "measured": len(measured), + "io_write_on_main": sum(row["io_write_on_main"] == "1" for row in measured), + "hops": {hop: summarize([float(row[hop]) for row in measured if row[hop]]) for hop in HOPS}, + "segments": {}, + } + for name, start, end in SEGMENTS: + values = [float(row[end]) - (float(row[start]) if start else 0.0) + for row in measured if row[end] and (start is None or row[start])] + result["segments"][name] = summarize(values) + + print(f"rows={result['rows']} complete={result['complete']} measured={result['measured']} " + f"io_write_on_main={result['io_write_on_main']}") + print(f"{'from key event (ms)':<46} {'median':>8} {'p95':>8} {'min':>8} {'max':>8}") + for hop, stats in result["hops"].items(): + if stats: + print(f"{hop:<46} {stats['median']:>8} {stats['p95']:>8} {stats['min']:>8} {stats['max']:>8}") + print(f"\n{'segment (ms)':<46} {'median':>8} {'p95':>8} {'min':>8} {'max':>8}") + for name, stats in result["segments"].items(): + if stats: + print(f"{name:<46} {stats['median']:>8} {stats['p95']:>8} {stats['min']:>8} {stats['max']:>8}") + args.csv.with_suffix(".summary.json").write_text(json.dumps(result, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/cmux-next/notices/ghosttykit-macos-link-set.json b/scripts/cmux-next/notices/ghosttykit-macos-link-set.json index d4e521647a46..b73c7310b2e3 100644 --- a/scripts/cmux-next/notices/ghosttykit-macos-link-set.json +++ b/scripts/cmux-next/notices/ghosttykit-macos-link-set.json @@ -19,9 +19,9 @@ ], "libintl": false, "pin": { - "ghostty_revision": "3320bd06ec13ca5479ef842372be1736d9cdfecf", - "sha256": "788fe6a6e42fc0062007cf7292992dd3bb770cbce7a33f5040d5180e40150bc1", - "url": "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-3320bd06ec13ca5479ef842372be1736d9cdfecf-apple-v6/GhosttyNextKit.xcframework.zip" + "ghostty_revision": "2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf", + "sha256": "8ef3906625d23ee463cf2e83a598bed2dd72de0d310a8dab5e067cfc573737c1", + "url": "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf-apple-v6/GhosttyNextKit.xcframework.zip" }, "schema": 1, "slice": "macos-arm64_x86_64", diff --git a/scripts/cmux-next/notices/ios/link-set.json b/scripts/cmux-next/notices/ios/link-set.json index 5fcce4f2c28d..144af5dd62f5 100644 --- a/scripts/cmux-next/notices/ios/link-set.json +++ b/scripts/cmux-next/notices/ios/link-set.json @@ -7,15 +7,15 @@ }, "evidence": { "app": "cmux-ios.xcarchive/Products/Applications/cmux.app", - "artifact_sha256": "c1e4854d61ea00ba5a7693e802c9610dc2e671cbc8adfec0611c286f6b18c4bc", + "artifact_sha256": "244ff3a65e8a617e1bb6d3ecaa219859ea61cb395e52e4cffb48a6ef19250ec4", "binaries": { "PlugIns/NotificationService.appex/NotificationService": "9b3a5de2c5efd9ea208c89ba9e06fa66b5f5a5e9c49c5e08dcc6b08f44496225", - "cmux": "9c6c95bdb6961b95d9f207714337ea97987108b5b1f2c6c68f2c6cf9649f59ca" + "cmux": "b8d57eb0aaa2eafa43334b7253a92a26abd967024139e8143c960732926006da" }, - "cmux_ci_job": "3cb75148fdc2bb261c043703", - "source_commit": "3a47976a2ad51b4d1c0fab3296f349e97798a8cb" + "cmux_ci_job": "2c4987f6b4995f7758d98ad6", + "source_commit": "2d003cfc91de1fbfcf5d48ce1ecf9fdb671efa38" }, - "pin_sha256": "788fe6a6e42fc0062007cf7292992dd3bb770cbce7a33f5040d5180e40150bc1", + "pin_sha256": "8ef3906625d23ee463cf2e83a598bed2dd72de0d310a8dab5e067cfc573737c1", "present_owners": { "vendored:pkg/dcimgui": 2, "vendored:pkg/highway": 11, @@ -265,9 +265,9 @@ "zutil.o" ], "pin": { - "ghostty_revision": "3320bd06ec13ca5479ef842372be1736d9cdfecf", - "sha256": "788fe6a6e42fc0062007cf7292992dd3bb770cbce7a33f5040d5180e40150bc1", - "url": "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-3320bd06ec13ca5479ef842372be1736d9cdfecf-apple-v6/GhosttyNextKit.xcframework.zip" + "ghostty_revision": "2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf", + "sha256": "8ef3906625d23ee463cf2e83a598bed2dd72de0d310a8dab5e067cfc573737c1", + "url": "https://github.com/manaflow-ai/ghostty-next/releases/download/xcframework-2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf-apple-v6/GhosttyNextKit.xcframework.zip" }, "schema": 1, "slice": "ios-arm64",