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
4 changes: 2 additions & 2 deletions Packages/Shared/CmuxGhosttyKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
]
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextWakeups
import os

/// Chromium's application protocols (base/message_loop/message_pump_apple.h,
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
},
Expand Down Expand Up @@ -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)")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CmuxNextWakeups
import GhosttyNextKit
import Synchronization
import os
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CmuxNextWakeups
import GhosttyNextKit

/// Per-surface userdata handed to Ghostty as both `userdata` and
Expand Down Expand Up @@ -77,5 +78,6 @@ nonisolated struct TerminalInputSink: Sendable {
/// to the session's ordered writer.
nonisolated func ghosttyIOWrite(_ userdata: UnsafeMutableRawPointer?, _ bytes: UnsafePointer<CChar>?, _ 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)))
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
public import AppKit
import CmuxNextDesign
import CmuxNextTerminalGeometry
import CmuxNextWakeups
import GhosttyNextKit
import os
import QuartzCore
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ObjectIdentifier> = []

@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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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=<csv path>`). 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))
}
}
}
Original file line number Diff line number Diff line change
@@ -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"] == "")
}
}
2 changes: 1 addition & 1 deletion ghostty-next
6 changes: 3 additions & 3 deletions ios/cmux/Settings.bundle/Acknowledgements.plist
Original file line number Diff line number Diff line change
Expand Up @@ -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-&lt;build&gt;.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/nightly-next/ (&lt;build&gt; 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-&lt;commit, 11 characters&gt;. 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-&lt;build&gt;.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/ios-source/ (&lt;build&gt; 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-&lt;commit, 11 characters&gt;. 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-&lt;build&gt;.tar.gz, published with this release at https://github.com/manaflow-ai/cmux/releases/download/ios-source/ (&lt;build&gt; 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-&lt;commit, 11 characters&gt;. The archive contains Ghostty 2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf (https://github.com/manaflow-ai/ghostty-next/tree/2199dc93ee3bf6ed9423dc5a814b079bba7d0ccf) and every Ghostty Zig package archive, z2d included.
</string>
<key>Title</key>
<string>z2d (in Ghostty)</string>
Expand Down Expand Up @@ -5531,7 +5531,7 @@ obstacle to adoption, that text has been removed.
</dict>
<dict>
<key>FooterText</key>
<string>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.
<string>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.
</string>
<key>Title</key>
<string>Build record</string>
Expand Down
Loading
Loading