From 3768f2e5ef13503ea7d862a55347085b20f60323 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:24:49 -0700 Subject: [PATCH 01/13] feat: harden Ghostty render worker supervision --- .../GhosttyRenderWorkerClient.swift | 529 ++++++++++++++++++ .../GhosttyRenderWorkerClientTests.swift | 294 ++++++++++ 2 files changed, 823 insertions(+) create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift new file mode 100644 index 000000000000..7df34be583e8 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift @@ -0,0 +1,529 @@ +internal import Darwin +public import CmuxTerminalRenderTransport +public import Foundation + +/// Supervises the single faceless process that owns all Ghostty render mirrors. +/// +/// The host never waits for a renderer response. Commands enter +/// ``commandSink`` from one ordered lane, writes happen off the main actor, and +/// worker generations fence late events and IOSurfaces after a crash. +public actor GhosttyRenderWorkerClient { + /// Reexec argument routed before the app initializes AppKit or SwiftUI. + public nonisolated static let workerModeArgument = "--cmux-ghostty-render-worker" + + /// Ordered, nonblocking ingress used by AppKit and Ghostty I/O callbacks. + public nonisolated let commandSink = GhosttyRenderCommandSink() + + private let executableURL: URL + private let arguments: [String] + private let extraEnvironment: [String: String] + private let initializationTimeout: Duration + private let automaticInitializationRetryLimit: Int + private let frameReceiver: TerminalRenderFrameReceiver + private let frameIngress: AsyncStream + private let frameContinuation: AsyncStream.Continuation + private let controlIngress: AsyncStream + private let controlContinuation: AsyncStream.Continuation + + private var commandConsumer: Task? + private var frameConsumer: Task? + private var controlConsumer: Task? + private var initializationWatchdog: Task? + private var frameThread: Thread? + private var child: GhosttyRenderChild? + private var workerGeneration: UInt64 = 0 + private var initializedGeneration: UInt64? + private var consecutiveInitializationTimeouts = 0 + private var hasLaunchedWorker = false + private var isShuttingDown = false + private var configuration: TerminalRenderConfigurationSnapshot? + private var desiredSurfaces: [UUID: TerminalRenderSurfaceDescriptor] = [:] + private var resynchronizingSurfaces: Set = [] + private var pendingMutations: [UUID: [TerminalRenderWorkerCommand]] = [:] + + private var eventSubscribers: [Int: AsyncStream.Continuation] = [:] + private var frameSubscribers: [Int: AsyncStream.Continuation] = [:] + private var nextSubscriberID = 0 + + /// Creates a client that launches `executableURL` on demand. + public init( + executableURL: URL, + arguments: [String] = [], + environment extraEnvironment: [String: String] = [:], + frameReceiver: TerminalRenderFrameReceiver? = nil, + initializationTimeout: Duration = .seconds(5), + automaticInitializationRetryLimit: Int = 1 + ) throws { + self.executableURL = executableURL + self.arguments = arguments + self.extraEnvironment = extraEnvironment + self.initializationTimeout = initializationTimeout + self.automaticInitializationRetryLimit = max(0, automaticInitializationRetryLimit) + self.frameReceiver = try frameReceiver ?? TerminalRenderFrameReceiver() + let framePair = AsyncStream.makeStream( + of: TerminalRenderFrame.self, + bufferingPolicy: .bufferingNewest(8) + ) + self.frameIngress = framePair.stream + self.frameContinuation = framePair.continuation + let controlPair = AsyncStream.makeStream( + of: GhosttyRenderChildControlIngress.self, + bufferingPolicy: .unbounded + ) + self.controlIngress = controlPair.stream + self.controlContinuation = controlPair.continuation + } + + /// Creates a client that reexecutes the current signed app binary. + public static func reexecingCurrentBinary() throws -> GhosttyRenderWorkerClient { + let binary = Bundle.main.executableURL + ?? URL(fileURLWithPath: CommandLine.arguments[0]) + return try GhosttyRenderWorkerClient( + executableURL: binary, + arguments: [workerModeArgument] + ) + } + + /// Starts the ordered consumers. Safe to call repeatedly. + public func start() { + guard commandConsumer == nil else { return } + let commandStream = commandSink.stream + commandConsumer = Task { [weak self] in + for await command in commandStream { + guard let self else { return } + await self.accept(command) + } + } + let frames = frameIngress + frameConsumer = Task { [weak self] in + for await frame in frames { + guard let self else { return } + await self.accept(frame) + } + } + let controlMessages = controlIngress + controlConsumer = Task { [weak self] in + for await message in controlMessages { + guard let self else { return } + await self.accept(message) + } + } + let frameContinuation = frameContinuation + frameThread = frameReceiver.start { frame in + frameContinuation.yield(frame) + } + } + + /// Installs or replaces the effective Ghostty configuration snapshot. + public func updateConfiguration(_ snapshot: TerminalRenderConfigurationSnapshot) { + start() + commandSink.enqueue(.replaceConfiguration(snapshot)) + } + + /// Subscribes to process lifecycle and recovery requests. + public func subscribeEvents() -> AsyncStream { + let id = allocateSubscriberID() + return AsyncStream { continuation in + eventSubscribers[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeEventSubscriber(id) } + } + } + } + + /// Subscribes to generation-fenced rendered frames. + public func subscribeFrames() -> AsyncStream { + let id = allocateSubscriberID() + return AsyncStream(bufferingPolicy: .bufferingNewest(3)) { continuation in + frameSubscribers[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.removeFrameSubscriber(id) } + } + } + } + + /// Terminates the worker and all local streams. + public func shutdown() { + isShuttingDown = true + initializationWatchdog?.cancel() + initializationWatchdog = nil + if let child { + try? send(.shutdown, through: child.channel) + child.process.terminate() + } + child = nil + commandSink.finish() + commandConsumer?.cancel() + commandConsumer = nil + frameConsumer?.cancel() + frameConsumer = nil + controlConsumer?.cancel() + controlConsumer = nil + frameContinuation.finish() + controlContinuation.finish() + frameReceiver.stop() + for continuation in eventSubscribers.values { continuation.finish() } + for continuation in frameSubscribers.values { continuation.finish() } + eventSubscribers.removeAll() + frameSubscribers.removeAll() + } + + private func accept(_ command: TerminalRenderWorkerCommand) { + switch command { + case .initialize: + broadcast(.failure("host attempted to enqueue worker initialization")) + + case let .replaceConfiguration(snapshot): + let hadConfiguration = configuration != nil + configuration = snapshot + do { + let launched = try ensureRunning() + if hadConfiguration, !launched, let channel = child?.channel { + try send(command, through: channel) + } + } catch { + loseWorker(reason: "configuration send failed: \(error)") + } + + case let .createSurface(descriptor): + desiredSurfaces[descriptor.id] = descriptor + resynchronizingSurfaces.remove(descriptor.id) + pendingMutations[descriptor.id] = nil + guard configuration != nil else { return } + do { + let launched = try ensureRunning() + if !launched, let channel = child?.channel { + try send(command, through: channel) + } + } catch { + loseWorker(reason: "surface create send failed: \(error)") + } + + case let .mutateSurface(id, generation, _): + guard desiredSurfaces[id]?.generation == generation else { return } + guard configuration != nil else { + pendingMutations[id, default: []].append(command) + return + } + do { + _ = try ensureRunning() + if resynchronizingSurfaces.contains(id) { + pendingMutations[id, default: []].append(command) + } else if let channel = child?.channel { + try send(command, through: channel) + } + } catch { + pendingMutations[id, default: []].append(command) + loseWorker(reason: "surface mutation send failed: \(error)") + } + + case let .resynchronizeSurface(descriptor, nextSequence, _): + guard desiredSurfaces[descriptor.id]?.generation == descriptor.generation else { return } + do { + _ = try ensureRunning() + guard let channel = child?.channel else { return } + try send(command, through: channel) + resynchronizingSurfaces.remove(descriptor.id) + let queued = pendingMutations.removeValue(forKey: descriptor.id) ?? [] + for pending in queued.compactMap({ trim($0, before: nextSequence) }) { + try send(pending, through: channel) + } + } catch { + loseWorker(reason: "surface resynchronization failed: \(error)") + } + + case let .destroySurface(id, generation): + guard desiredSurfaces[id]?.generation == generation else { return } + desiredSurfaces[id] = nil + resynchronizingSurfaces.remove(id) + pendingMutations[id] = nil + if let channel = child?.channel { + do { try send(command, through: channel) } + catch { loseWorker(reason: "surface destroy send failed: \(error)") } + } + + case .shutdown: + shutdown() + } + } + + @discardableResult + private func ensureRunning() throws -> Bool { + guard !isShuttingDown else { return false } + if let child, child.process.isRunning { return false } + guard let configuration else { return false } + + workerGeneration &+= 1 + let generation = workerGeneration + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + if !extraEnvironment.isEmpty { + process.environment = ProcessInfo.processInfo.environment + .merging(extraEnvironment) { _, new in new } + } + let stdin = Pipe() + let stdout = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + let channel = TerminalRenderMessageChannel( + readDescriptor: stdout.fileHandleForReading.fileDescriptor, + writeDescriptor: stdin.fileHandleForWriting.fileDescriptor, + nonblockingWrites: true + ) + try process.run() + child = GhosttyRenderChild( + generation: generation, + process: process, + channel: channel, + stdin: stdin, + stdout: stdout + ) + initializedGeneration = nil + + let readChannel = channel + let controlContinuation = controlContinuation + let reader = Thread { + withExtendedLifetime(stdout) { + while let payload = readChannel.receive() { + guard let event = try? TerminalRenderControlCodec.decodeEvent(payload) else { + continue + } + controlContinuation.yield(.event(event, generation: generation)) + } + controlContinuation.yield(.ended(generation: generation)) + } + } + reader.name = "cmux-ghostty-render-control-reader" + reader.stackSize = 1 << 20 + reader.start() + + try send( + .initialize( + protocolVersion: TerminalRenderProtocol.currentVersion, + workerGeneration: generation, + frameEndpoint: frameReceiver.endpoint, + configuration: configuration + ), + through: channel + ) + armInitializationWatchdog(generation: generation) + + if hasLaunchedWorker { + resynchronizingSurfaces = Set(desiredSurfaces.keys) + for descriptor in desiredSurfaces.values { + broadcast(.resynchronizationRequired( + surfaceID: descriptor.id, + surfaceGeneration: descriptor.generation + )) + } + } else { + for descriptor in desiredSurfaces.values { + try send(.createSurface(descriptor), through: channel) + } + for commands in pendingMutations.values { + for pending in commands { try send(pending, through: channel) } + } + pendingMutations.removeAll() + } + hasLaunchedWorker = true + return true + } + + private func accept(_ message: GhosttyRenderChildControlIngress) { + switch message { + case let .event(event, generation): + accept(event, generation: generation) + case let .ended(generation): + workerEnded(generation: generation) + case let .initializationTimedOut(generation): + initializationTimedOut(generation: generation) + } + } + + private func accept(_ event: TerminalRenderWorkerEvent, generation: UInt64) { + guard child?.generation == generation else { return } + switch event { + case let .initialized(version, announcedGeneration, processIdentifier): + guard version == TerminalRenderProtocol.currentVersion, + announcedGeneration == generation else { + loseWorker(reason: "worker protocol generation mismatch") + return + } + guard initializedGeneration != generation else { return } + initializedGeneration = generation + consecutiveInitializationTimeouts = 0 + initializationWatchdog?.cancel() + initializationWatchdog = nil + broadcast(.initialized( + workerGeneration: generation, + processIdentifier: processIdentifier + )) + case let .surfaceCreated(id, surfaceGeneration): + guard initializedGeneration == generation else { + loseWorker(reason: "worker created a surface before initialization") + return + } + guard desiredSurfaces[id]?.generation == surfaceGeneration else { return } + broadcast(.surfaceCreated( + surfaceID: id, + surfaceGeneration: surfaceGeneration + )) + case .surfaceDestroyed: + break + case let .outputApplied(id, surfaceGeneration, nextSequence): + guard initializedGeneration == generation else { + loseWorker(reason: "worker applied output before initialization") + return + } + guard desiredSurfaces[id]?.generation == surfaceGeneration else { return } + broadcast(.outputApplied( + surfaceID: id, + surfaceGeneration: surfaceGeneration, + nextSequence: nextSequence + )) + case let .failure(message): + broadcast(.failure(message)) + } + } + + private func accept(_ frame: TerminalRenderFrame) { + guard frame.metadata.workerGeneration == workerGeneration, + desiredSurfaces[frame.metadata.surfaceID]?.generation + == frame.metadata.surfaceGeneration, + !resynchronizingSurfaces.contains(frame.metadata.surfaceID) else { + return + } + for continuation in frameSubscribers.values { + continuation.yield(frame) + } + } + + private func workerEnded(generation: UInt64) { + guard let endedChild = child, endedChild.generation == generation else { return } + initializationWatchdog?.cancel() + initializationWatchdog = nil + initializedGeneration = nil + if endedChild.process.isRunning { + endedChild.process.terminate() + } + child = nil + resynchronizingSurfaces.formUnion(desiredSurfaces.keys) + broadcast(.workerExited(workerGeneration: generation)) + } + + private func loseWorker(reason: String) { + initializationWatchdog?.cancel() + initializationWatchdog = nil + initializedGeneration = nil + guard let lostChild = child else { + broadcast(.failure(reason)) + return + } + let generation = lostChild.generation + if lostChild.process.isRunning { + lostChild.process.terminate() + } + child = nil + resynchronizingSurfaces.formUnion(desiredSurfaces.keys) + broadcast(.failure(reason)) + broadcast(.workerExited(workerGeneration: generation)) + } + + private func armInitializationWatchdog(generation: UInt64) { + initializationWatchdog?.cancel() + let timeout = initializationTimeout + let controlContinuation = controlContinuation + initializationWatchdog = Task { + do { + try await Task.sleep(for: timeout) + } catch { + return + } + guard !Task.isCancelled else { return } + controlContinuation.yield(.initializationTimedOut(generation: generation)) + } + } + + private func initializationTimedOut(generation: UInt64) { + guard !isShuttingDown, + child?.generation == generation, + initializedGeneration != generation else { + return + } + + consecutiveInitializationTimeouts += 1 + loseWorker(reason: "worker initialization timed out") + + guard consecutiveInitializationTimeouts <= automaticInitializationRetryLimit else { + return + } + do { + _ = try ensureRunning() + } catch { + broadcast(.failure("worker restart failed: \(error)")) + } + } + + private func send( + _ command: TerminalRenderWorkerCommand, + through channel: TerminalRenderMessageChannel + ) throws { + try channel.send(TerminalRenderControlCodec.encode(command)) + } + + private func trim( + _ command: TerminalRenderWorkerCommand, + before nextSequence: UInt64 + ) -> TerminalRenderWorkerCommand? { + guard case let .mutateSurface(id, generation, mutation) = command, + case let .processOutput(sequence, bytes) = mutation else { + return command + } + let end = sequence &+ UInt64(bytes.count) + guard end > nextSequence else { return nil } + guard sequence < nextSequence else { return command } + let dropCount = Int(nextSequence - sequence) + return .mutateSurface( + id: id, + generation: generation, + mutation: .processOutput( + sequence: nextSequence, + bytes: Data(bytes.dropFirst(dropCount)) + ) + ) + } + + private func allocateSubscriberID() -> Int { + defer { nextSubscriberID += 1 } + return nextSubscriberID + } + + private func removeEventSubscriber(_ id: Int) { + eventSubscribers[id] = nil + } + + private func removeFrameSubscriber(_ id: Int) { + frameSubscribers[id] = nil + } + + private func broadcast(_ event: GhosttyRenderWorkerClientEvent) { + for continuation in eventSubscribers.values { + continuation.yield(event) + } + } +} + +private struct GhosttyRenderChild { + let generation: UInt64 + let process: Process + let channel: TerminalRenderMessageChannel + let stdin: Pipe + let stdout: Pipe +} + +private enum GhosttyRenderChildControlIngress: Sendable { + case event(TerminalRenderWorkerEvent, generation: UInt64) + case ended(generation: UInt64) + case initializationTimedOut(generation: UInt64) +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift new file mode 100644 index 000000000000..43451440ad66 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift @@ -0,0 +1,294 @@ +import CmuxTerminalRenderTransport +import Foundation +import Testing +@testable import CmuxGhosttyRenderClient + +@Suite(.serialized) struct GhosttyRenderWorkerClientTests { + @Test func sendsOrderedSurfaceOutputThroughRealProcess() async throws { + let client = try GhosttyRenderWorkerClient(executableURL: fixtureURL()) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 4) + client.commandSink.enqueue(.createSurface(descriptor)) + client.commandSink.enqueue(.mutateSurface( + id: id, + generation: 4, + mutation: .processOutput(sequence: 0, bytes: Data("hello".utf8)) + )) + + let events = await collector.waitUntil { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 4, + nextSequence: 5 + )) + } + await client.shutdown() + + #expect(events.contains { event in + if case .initialized = event { return true } + return false + }) + #expect(events.contains(.surfaceCreated( + surfaceID: id, + surfaceGeneration: 4 + ))) + } + + @Test func preservesChildStdoutEventOrder() async throws { + let client = try GhosttyRenderWorkerClient(executableURL: fixtureURL()) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 5) + client.commandSink.enqueue(.createSurface(descriptor)) + for sequence in 0..<128 { + client.commandSink.enqueue(.mutateSurface( + id: id, + generation: 5, + mutation: .processOutput( + sequence: UInt64(sequence), + bytes: Data([UInt8(sequence)]) + ) + )) + } + + let events = await collector.waitUntil { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 5, + nextSequence: 128 + )) + } + await client.shutdown() + + let outputSequences = events.compactMap { event -> UInt64? in + guard case let .outputApplied(surfaceID, generation, nextSequence) = event, + surfaceID == id, + generation == 5 else { + return nil + } + return nextSequence + } + #expect(outputSequences == (1...128).map(UInt64.init)) + + let initializedIndex = events.firstIndex { event in + if case .initialized = event { return true } + return false + } + let surfaceIndex = events.firstIndex(of: .surfaceCreated( + surfaceID: id, + surfaceGeneration: 5 + )) + let firstOutputIndex = events.firstIndex(of: .outputApplied( + surfaceID: id, + surfaceGeneration: 5, + nextSequence: 1 + )) + #expect(initializedIndex != nil) + #expect(surfaceIndex != nil) + #expect(firstOutputIndex != nil) + if let initializedIndex, let surfaceIndex, let firstOutputIndex { + #expect(initializedIndex < surfaceIndex) + #expect(surfaceIndex < firstOutputIndex) + } + } + + @Test func restartsHungWorkerOnceWithoutDuplicateExitEvents() async throws { + let client = try GhosttyRenderWorkerClient( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["30"], + initializationTimeout: .milliseconds(100), + automaticInitializationRetryLimit: 1 + ) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + + _ = await collector.waitUntil(timeout: .seconds(3)) { events in + events.filter { event in + if case .workerExited = event { return true } + return false + }.count == 2 + } + try? await Task.sleep(for: .milliseconds(200)) + let events = await collector.snapshot() + await client.shutdown() + + let lifecycle = events.compactMap { event -> String? in + switch event { + case let .failure(message) where message == "worker initialization timed out": + return "timeout" + case let .workerExited(generation): + return "exit-\(generation)" + default: + return nil + } + } + #expect(lifecycle == ["timeout", "exit-1", "timeout", "exit-2"]) + #expect(!events.contains { event in + if case .initialized = event { return true } + return false + }) + } + + @Test func crashRequestsSnapshotBeforeQueuedOutputResumes() async throws { + let crashMarker = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ghostty-render-crash-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: crashMarker) } + + let client = try GhosttyRenderWorkerClient( + executableURL: fixtureURL(), + environment: [ + "CMUX_GHOSTTY_RENDER_FIXTURE_CRASH_ONCE_FILE": crashMarker.path, + ] + ) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + _ = await collector.waitUntil { events in + events.contains { event in + if case .workerExited = event { return true } + return false + } + } + try? await Task.sleep(for: .milliseconds(100)) + let initialCrashEvents = await collector.snapshot() + let initialLifecycle = initialCrashEvents.compactMap { event -> String? in + switch event { + case .initialized: + return "initialized" + case let .workerExited(generation): + return "exit-\(generation)" + default: + return nil + } + } + #expect(initialLifecycle == ["initialized", "exit-1"]) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 8) + client.commandSink.enqueue(.createSurface(descriptor)) + client.commandSink.enqueue(.mutateSurface( + id: id, + generation: 8, + mutation: .processOutput(sequence: 11, bytes: Data("tail".utf8)) + )) + + let recoveryEvents = await collector.waitUntil { events in + events.contains(.resynchronizationRequired( + surfaceID: id, + surfaceGeneration: 8 + )) + } + #expect(recoveryEvents.contains(.resynchronizationRequired( + surfaceID: id, + surfaceGeneration: 8 + ))) + + client.commandSink.enqueue(.resynchronizeSurface( + descriptor: descriptor, + nextOutputSequence: 11, + screenTailVT: Data("snapshot".utf8) + )) + let completed = await collector.waitUntil { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 8, + nextSequence: 15 + )) + } + await client.shutdown() + + #expect(completed.contains(.surfaceCreated( + surfaceID: id, + surfaceGeneration: 8 + ))) + } + + private func configuration() -> TerminalRenderConfigurationSnapshot { + TerminalRenderConfigurationSnapshot( + revision: 1, + contents: "font-size = 14\n" + ) + } + + private func surfaceDescriptor( + id: UUID, + generation: UInt64 + ) -> TerminalRenderSurfaceDescriptor { + TerminalRenderSurfaceDescriptor( + id: id, + generation: generation, + width: 800, + height: 600, + scaleX: 2, + scaleY: 2, + fontSize: 14, + context: 1 + ) + } +} + +private actor EventCollector { + private var events: [GhosttyRenderWorkerClientEvent] = [] + private var pump: Task? + + init(stream: AsyncStream) { + Task { await start(stream) } + } + + private func start(_ stream: AsyncStream) { + pump = Task { + for await event in stream { + events.append(event) + } + } + } + + func waitUntil( + timeout: Duration = .seconds(10), + _ predicate: @escaping @Sendable ([GhosttyRenderWorkerClientEvent]) -> Bool + ) async -> [GhosttyRenderWorkerClientEvent] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if predicate(events) { return events } + try? await Task.sleep(for: .milliseconds(20)) + } + return events + } + + func snapshot() -> [GhosttyRenderWorkerClientEvent] { + events + } +} + +private func fixtureURL() -> URL { + builtExecutableURL(named: "cmux-ghostty-render-fixture") +} + +private func builtExecutableURL(named name: String) -> URL { + let fileManager = FileManager.default + var candidates: [URL] = [] + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + candidates.append(bundle.bundleURL.deletingLastPathComponent().appendingPathComponent(name)) + } + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let buildDirectory = packageRoot.appendingPathComponent(".build") + candidates.append(buildDirectory.appendingPathComponent("debug").appendingPathComponent(name)) + if let triples = try? fileManager.contentsOfDirectory( + at: buildDirectory, + includingPropertiesForKeys: nil + ) { + for triple in triples { + candidates.append(triple.appendingPathComponent("debug").appendingPathComponent(name)) + } + } + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) + ?? candidates[0] +} From 52dee6213bd17c03042156285f4f94dc67deeb5a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:27:20 -0700 Subject: [PATCH 02/13] Add remote Ghostty frame presentation layer --- Packages/macOS/CmuxTerminal/Package.swift | 9 + .../Hosting/GhosttyRemoteIOSurfaceLayer.swift | 189 +++++++++++++++++ .../GhosttyRemoteIOSurfaceLayerTests.swift | 191 ++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift diff --git a/Packages/macOS/CmuxTerminal/Package.swift b/Packages/macOS/CmuxTerminal/Package.swift index 4ce16e85691c..7da11507d5db 100644 --- a/Packages/macOS/CmuxTerminal/Package.swift +++ b/Packages/macOS/CmuxTerminal/Package.swift @@ -15,6 +15,7 @@ let package = Package( ], dependencies: [ .package(path: "../CmuxTerminalCore"), + .package(path: "../CmuxTerminalRenderTransport"), .package(path: "../CMUXDebugLog"), .package(path: "../CMUXAgentLaunch"), .package(path: "../../Shared/CMUXMobileCore"), @@ -26,6 +27,10 @@ let package = Package( dependencies: [ .product(name: "CmuxTerminalCore", package: "CmuxTerminalCore"), .product(name: "CmuxGhosttyKit", package: "CmuxTerminalCore"), + .product( + name: "CmuxTerminalRenderTransport", + package: "CmuxTerminalRenderTransport" + ), .product(name: "CMUXDebugLog", package: "CMUXDebugLog"), .product(name: "CMUXAgentLaunch", package: "CMUXAgentLaunch"), .product(name: "CMUXMobileCore", package: "CMUXMobileCore"), @@ -53,6 +58,10 @@ let package = Package( "GhosttyRuntimeTestStubs", .product(name: "CmuxTerminalCore", package: "CmuxTerminalCore"), .product(name: "CmuxGhosttyKit", package: "CmuxTerminalCore"), + .product( + name: "CmuxTerminalRenderTransport", + package: "CmuxTerminalRenderTransport" + ), ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift new file mode 100644 index 000000000000..c60ca6015d18 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift @@ -0,0 +1,189 @@ +public import CmuxTerminalRenderTransport +public import CoreGraphics +internal import Foundation +internal import IOSurface +public import QuartzCore + +/// Integer pixel dimensions expected from a Ghostty render worker. +public struct GhosttyRenderPixelSize: Equatable, Sendable { + public let width: UInt32 + public let height: UInt32 + + /// Creates an expected remote-frame size. + public init(width: UInt32, height: UInt32) { + self.width = width + self.height = height + } +} + +/// Presents generation-fenced IOSurfaces received from a Ghostty render worker. +/// +/// All configuration and frame presentation happens on the main actor. The +/// layer explicitly retains its last accepted IOSurface while a worker is +/// unavailable or its mirror is being reconfigured, avoiding a blank terminal +/// between worker generations. +public final class GhosttyRemoteIOSurfaceLayer: CALayer { + /// State accessed only by the main-actor APIs below. CALayer's required + /// `init(layer:)` remains nonisolated because Core Animation may create a + /// presentation-layer copy off the main thread; that copy gets inert state + /// and inherits only CALayer's visual properties. + private final class PresentationState: @unchecked Sendable { + let surfaceID: UUID + var workerGeneration: UInt64? + var surfaceGeneration: UInt64 + var expectedPixelSize: GhosttyRenderPixelSize + var lastAcceptedFrameSequence: UInt64? + var retainedSurface: IOSurfaceRef? + + init( + surfaceID: UUID, + workerGeneration: UInt64?, + surfaceGeneration: UInt64, + expectedPixelSize: GhosttyRenderPixelSize + ) { + self.surfaceID = surfaceID + self.workerGeneration = workerGeneration + self.surfaceGeneration = surfaceGeneration + self.expectedPixelSize = expectedPixelSize + } + } + + private let presentationState: PresentationState + + /// Stable identity of the terminal surface this layer presents. + @MainActor public var surfaceID: UUID { presentationState.surfaceID } + + /// Worker generation currently allowed to present, or nil after exit. + @MainActor public var workerGeneration: UInt64? { presentationState.workerGeneration } + + /// Native mirror generation currently allowed to present. + @MainActor public var surfaceGeneration: UInt64 { presentationState.surfaceGeneration } + + /// Pixel dimensions frames must match before presentation. + @MainActor public var expectedPixelSize: GhosttyRenderPixelSize { + presentationState.expectedPixelSize + } + + /// Last accepted sequence in the current worker and surface generation. + @MainActor public var lastAcceptedFrameSequence: UInt64? { + presentationState.lastAcceptedFrameSequence + } + + // An explicit strong reference documents and enforces the last-frame + // retention contract independently of CALayer.contents implementation + // details. Tests inspect it through @testable import. + @MainActor internal var retainedSurface: IOSurfaceRef? { + presentationState.retainedSurface + } + + /// Creates a layer for one terminal mirror generation. + @MainActor public init( + surfaceID: UUID, + workerGeneration: UInt64, + surfaceGeneration: UInt64, + expectedPixelSize: GhosttyRenderPixelSize, + backingScaleFactor: CGFloat + ) { + self.presentationState = PresentationState( + surfaceID: surfaceID, + workerGeneration: workerGeneration, + surfaceGeneration: surfaceGeneration, + expectedPixelSize: expectedPixelSize + ) + super.init() + configurePresentation(backingScaleFactor: backingScaleFactor) + } + + override public init(layer: Any) { + self.presentationState = PresentationState( + surfaceID: UUID(), + workerGeneration: nil, + surfaceGeneration: 0, + expectedPixelSize: GhosttyRenderPixelSize(width: 0, height: 0) + ) + super.init(layer: layer) + } + + @available(*, unavailable, message: "Use init(surfaceID:workerGeneration:surfaceGeneration:expectedPixelSize:backingScaleFactor:)") + override public init() { + fatalError("unavailable") + } + + @available(*, unavailable, message: "GhosttyRemoteIOSurfaceLayer does not support archival") + required public init?(coder: NSCoder) { + fatalError("unavailable") + } + + /// Updates the worker generation and permits its sequence to start over. + /// The last frame remains visible until the new worker presents a match. + @MainActor public func updateWorkerGeneration(_ generation: UInt64) { + guard presentationState.workerGeneration != generation else { return } + presentationState.workerGeneration = generation + presentationState.lastAcceptedFrameSequence = nil + } + + /// Fences late frames after a worker exits while retaining its last frame. + /// A stale exit notification cannot invalidate a newer worker generation. + @MainActor public func invalidateWorkerGeneration(_ generation: UInt64) { + guard presentationState.workerGeneration == generation else { return } + presentationState.workerGeneration = nil + presentationState.lastAcceptedFrameSequence = nil + } + + /// Updates the mirrored native-surface generation. + /// The last frame remains visible while the new mirror starts rendering. + @MainActor public func updateSurfaceGeneration(_ generation: UInt64) { + guard presentationState.surfaceGeneration != generation else { return } + presentationState.surfaceGeneration = generation + presentationState.lastAcceptedFrameSequence = nil + } + + /// Updates the required pixel dimensions without clearing the last frame. + @MainActor public func updateExpectedPixelSize(_ size: GhosttyRenderPixelSize) { + presentationState.expectedPixelSize = size + } + + /// Updates point-to-pixel mapping when the view changes backing display. + @MainActor public func updateBackingScaleFactor(_ backingScaleFactor: CGFloat) { + contentsScale = Self.validatedBackingScaleFactor(backingScaleFactor) + } + + /// Presents a frame when every identity, generation, sequence, and size + /// fence matches. Rejected frames leave the current contents untouched. + @discardableResult + @MainActor public func present(_ frame: TerminalRenderFrame) -> Bool { + let metadata = frame.metadata + guard metadata.surfaceID == presentationState.surfaceID, + let workerGeneration = presentationState.workerGeneration, + metadata.workerGeneration == workerGeneration, + metadata.surfaceGeneration == presentationState.surfaceGeneration, + metadata.width == presentationState.expectedPixelSize.width, + metadata.height == presentationState.expectedPixelSize.height, + IOSurfaceGetWidth(frame.surface) == Int(presentationState.expectedPixelSize.width), + IOSurfaceGetHeight(frame.surface) == Int(presentationState.expectedPixelSize.height), + presentationState.lastAcceptedFrameSequence.map({ metadata.frameSequence > $0 }) ?? true else { + return false + } + + // Keep the surface alive before making it visible. Disable implicit + // contents animation so each worker frame replaces its predecessor + // atomically instead of cross-fading terminal pixels. + presentationState.retainedSurface = frame.surface + CATransaction.begin() + CATransaction.setDisableActions(true) + contents = frame.surface + CATransaction.commit() + presentationState.lastAcceptedFrameSequence = metadata.frameSequence + return true + } + + @MainActor private func configurePresentation(backingScaleFactor: CGFloat) { + contentsGravity = .topLeft + contentsScale = Self.validatedBackingScaleFactor(backingScaleFactor) + } + + private static func validatedBackingScaleFactor(_ scale: CGFloat) -> CGFloat { + guard scale.isFinite else { return 1 } + return max(1, scale) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift new file mode 100644 index 000000000000..60a176345141 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift @@ -0,0 +1,191 @@ +import CoreFoundation +import IOSurface +import QuartzCore +import Testing +@testable import CmuxTerminal +import CmuxTerminalRenderTransport + +@Suite(.serialized) +@MainActor +struct GhosttyRemoteIOSurfaceLayerTests { + private let surfaceID = UUID(uuidString: "184DCEB4-F88C-4A9D-89F4-03087E606B8B")! + private let pixelSize = GhosttyRenderPixelSize(width: 32, height: 24) + + @Test func acceptsMatchingFrameAndConfiguresPixelPresentation() throws { + let layer = makeLayer(backingScaleFactor: 2) + let frame = try makeFrame(sequence: 7) + + #expect(layer.present(frame)) + #expect(layer.lastAcceptedFrameSequence == 7) + #expect(layer.contentsGravity == .topLeft) + #expect(layer.contentsScale == 2) + #expect(layer.retainedSurface.map(IOSurfaceGetID) == IOSurfaceGetID(frame.surface)) + #expect(displayedSurfaceID(layer) == IOSurfaceGetID(frame.surface)) + } + + @Test func rejectsWrongSurfaceIdentityWithoutReplacingContents() throws { + let layer = makeLayer() + let accepted = try makeFrame(sequence: 1) + #expect(layer.present(accepted)) + let retainedID = IOSurfaceGetID(accepted.surface) + + let rejected = try makeFrame(surfaceID: UUID(), sequence: 2) + #expect(!layer.present(rejected)) + expectRetainsSurface(layer, id: retainedID, sequence: 1) + } + + @Test func fencesWorkerGenerationAndRejectsLateFramesAfterExit() throws { + let layer = makeLayer() + let accepted = try makeFrame(sequence: 5) + #expect(layer.present(accepted)) + let retainedID = IOSurfaceGetID(accepted.surface) + + let wrongWorker = try makeFrame(workerGeneration: 12, sequence: 6) + #expect(!layer.present(wrongWorker)) + layer.invalidateWorkerGeneration(11) + #expect(layer.workerGeneration == nil) + #expect(!layer.present(try makeFrame(sequence: 7))) + expectRetainsSurface(layer, id: retainedID, sequence: nil) + + layer.updateWorkerGeneration(12) + #expect(layer.present(try makeFrame(workerGeneration: 12, sequence: 0))) + } + + @Test func staleWorkerExitCannotInvalidateNewWorker() { + let layer = makeLayer() + layer.updateWorkerGeneration(12) + layer.invalidateWorkerGeneration(11) + #expect(layer.workerGeneration == 12) + } + + @Test func fencesSurfaceGenerationAndAllowsNewSequenceAfterReconfiguration() throws { + let layer = makeLayer() + let accepted = try makeFrame(sequence: 9) + #expect(layer.present(accepted)) + let retainedID = IOSurfaceGetID(accepted.surface) + + #expect(!layer.present(try makeFrame(surfaceGeneration: 4, sequence: 10))) + layer.updateSurfaceGeneration(4) + expectRetainsSurface(layer, id: retainedID, sequence: nil) + #expect(layer.present(try makeFrame(surfaceGeneration: 4, sequence: 0))) + } + + @Test func rejectsDuplicateAndDecreasingFrameSequences() throws { + let layer = makeLayer() + let accepted = try makeFrame(sequence: 8) + #expect(layer.present(accepted)) + let retainedID = IOSurfaceGetID(accepted.surface) + + #expect(!layer.present(try makeFrame(sequence: 8))) + #expect(!layer.present(try makeFrame(sequence: 7))) + expectRetainsSurface(layer, id: retainedID, sequence: 8) + #expect(layer.present(try makeFrame(sequence: 9))) + } + + @Test func rejectsMetadataDimensionsThatDoNotMatchExpectedSize() throws { + let layer = makeLayer() + let rejected = try makeFrame( + metadataSize: GhosttyRenderPixelSize(width: 31, height: 24), + surfaceSize: pixelSize + ) + + #expect(!layer.present(rejected)) + #expect(layer.contents == nil) + #expect(layer.retainedSurface == nil) + } + + @Test func rejectsIOSurfaceDimensionsThatDoNotMatchMetadata() throws { + let layer = makeLayer() + let rejected = try makeFrame( + metadataSize: pixelSize, + surfaceSize: GhosttyRenderPixelSize(width: 31, height: 24) + ) + + #expect(!layer.present(rejected)) + #expect(layer.contents == nil) + #expect(layer.retainedSurface == nil) + } + + @Test func sizeAndScaleUpdatesRetainLastFrameUntilMatchingReplacement() throws { + let layer = makeLayer() + let accepted = try makeFrame(sequence: 3) + #expect(layer.present(accepted)) + let retainedID = IOSurfaceGetID(accepted.surface) + + let newSize = GhosttyRenderPixelSize(width: 48, height: 40) + layer.updateExpectedPixelSize(newSize) + layer.updateBackingScaleFactor(3) + #expect(layer.contentsScale == 3) + expectRetainsSurface(layer, id: retainedID, sequence: 3) + #expect(!layer.present(try makeFrame(sequence: 4))) + #expect(layer.present(try makeFrame(sequence: 5, metadataSize: newSize, surfaceSize: newSize))) + } + + private func makeLayer(backingScaleFactor: CGFloat = 1) -> GhosttyRemoteIOSurfaceLayer { + GhosttyRemoteIOSurfaceLayer( + surfaceID: surfaceID, + workerGeneration: 11, + surfaceGeneration: 3, + expectedPixelSize: pixelSize, + backingScaleFactor: backingScaleFactor + ) + } + + private func makeFrame( + surfaceID: UUID? = nil, + workerGeneration: UInt64 = 11, + surfaceGeneration: UInt64 = 3, + sequence: UInt64 = 1, + metadataSize: GhosttyRenderPixelSize? = nil, + surfaceSize: GhosttyRenderPixelSize? = nil + ) throws -> TerminalRenderFrame { + let metadataSize = metadataSize ?? pixelSize + let surfaceSize = surfaceSize ?? metadataSize + let receiver = try TerminalRenderFrameReceiver() + let sender = try TerminalRenderFrameSender(endpoint: receiver.endpoint) + let surface = makeIOSurface(size: surfaceSize) + let metadata = TerminalRenderFrameMetadata( + surfaceID: surfaceID ?? self.surfaceID, + workerGeneration: workerGeneration, + surfaceGeneration: surfaceGeneration, + frameSequence: sequence, + width: metadataSize.width, + height: metadataSize.height + ) + + #expect(try sender.send(surface: surface, metadata: metadata)) + let received = try receiver.receiveOne(timeoutMilliseconds: 1_000) + return try #require(received) + } + + private func makeIOSurface(size: GhosttyRenderPixelSize) -> IOSurfaceRef { + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 4 + let properties: [CFString: Any] = [ + kIOSurfaceWidth: width, + kIOSurfaceHeight: height, + kIOSurfaceBytesPerElement: 4, + kIOSurfaceBytesPerRow: bytesPerRow, + kIOSurfaceAllocSize: bytesPerRow * height, + ] + return IOSurfaceCreate(properties as CFDictionary)! + } + + private func expectRetainsSurface( + _ layer: GhosttyRemoteIOSurfaceLayer, + id: IOSurfaceID, + sequence: UInt64? + ) { + #expect(layer.retainedSurface.map(IOSurfaceGetID) == id) + #expect(displayedSurfaceID(layer) == id) + #expect(layer.lastAcceptedFrameSequence == sequence) + } + + private func displayedSurfaceID(_ layer: CALayer) -> IOSurfaceID? { + guard let contents = layer.contents else { return nil } + let coreFoundationContents = contents as CFTypeRef + guard CFGetTypeID(coreFoundationContents) == IOSurfaceGetTypeID() else { return nil } + return IOSurfaceGetID(contents as! IOSurfaceRef) + } +} From 673d70aadfc4c8d4f22fa06241204e787850cbec Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:40:58 -0700 Subject: [PATCH 03/13] docs: describe Ghostty render worker boundary --- docs/ghostty-fork.md | 93 ++++++++++++++++++++++- docs/ghostty-render-worker.md | 138 ++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 docs/ghostty-render-worker.md diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 62d0a6d35feb..5582ec66a884 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,10 +12,95 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Current cmux pinned fork head: `bb30526cd`. It advances the previous cmux pin -`b4b6d69c8` through the already-merged theme, render-grid, and wrap-aware URL -updates, then preserves authoritative sprite-font shaping runs. The commit is -reachable from fork `main` through the merged +Current cmux pinned fork head: `0f400d0f5`. It adds the external Metal +presenter and atomic renderer-worker recovery state on top of `bb30526cd`. +The two fork commits are `9a391205c` and `0f400d0f5` on +`feat/cmux-external-metal-presenter`. + +### External Metal presentation and recoverable worker snapshots + +- Commits: + - `9a391205c` (feat: add external Metal presenter ABI) + - `0f400d0f5` (feat: serialize worker recovery state) +- Files: + - `include/ghostty.h` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `src/config/CApi.zig` + - `src/input/Binding.zig` + - `src/renderer/Metal.zig` + - `src/termio/Options.zig` + - `src/termio/Termio.zig` +- Public C ABI: + + ```c + typedef void (*ghostty_metal_external_present_cb)(void* userdata, + void* iosurface, + uint32_t width_px, + uint32_t height_px); + + typedef struct { + void* userdata; + ghostty_metal_external_present_cb present; + } ghostty_platform_metal_external_s; + + // Appended to ghostty_surface_config_s. + ghostty_pty_tee_cb pty_tee_cb; + void* pty_tee_userdata; + + GHOSTTY_API ghostty_string_s ghostty_config_serialize(ghostty_config_t); + + GHOSTTY_API bool ghostty_surface_read_screen_tail_vt_with_output_sequence( + ghostty_surface_t surface, + uintptr_t max_rows, + uintptr_t max_bytes, + ghostty_text_s* result, + uint64_t* next_sequence); + ``` + + - `GHOSTTY_PLATFORM_METAL_EXTERNAL` selects a Metal presenter that does not + read or modify an `NSView`, `UIView`, or `CALayer`. The corresponding + `ghostty_platform_metal_external_s` stores embedder userdata and a required + `ghostty_metal_external_present_cb`. + - The present callback receives a borrowed `IOSurfaceRef` after Metal has + completed the frame, plus its pixel dimensions. The callback runs on a + Metal command-buffer completion thread, must be thread-safe and + nonblocking, and must retain the IOSurface or create its transport handle + before returning if the embedder needs it afterward. + - `ghostty_surface_config_s.pty_tee_cb` and + `ghostty_surface_config_s.pty_tee_userdata` install the existing PTY tee + before the IO thread starts. This closes the startup-byte race inherent in + installing the callback later with `ghostty_surface_set_pty_tee_cb`. + The callback receives every PTY byte slice before the local VT parser and + runs on the IO read thread. + - `ghostty_config_serialize(ghostty_config_t)` returns every public effective + configuration value as valid Ghostty config-file syntax in a + `ghostty_string_s`. The caller releases it with `ghostty_string_free`. + - `ghostty_surface_read_screen_tail_vt_with_output_sequence(...)` captures a + byte-bounded VT reconstruction and the modulo-`uint64_t` position of the + next PTY-output byte under the same renderer-state lock. The sequence + advances only after each byte slice has been applied to terminal state. + The caller releases the returned `ghostty_text_s` with + `ghostty_surface_free_text`. +- Summary: + - A manual-IO Ghostty surface can render into IOSurfaces without constructing + an AppKit or UIKit presentation object. cmux uses this for visual mirrors in + its renderer worker. + - The authority surface can tee raw output from byte zero, then atomically + pair a bounded terminal snapshot with the exact replay position if the + worker restarts. + - Effective configuration serialization lets the worker build its mirrors + from the same finalized Ghostty configuration as the authority. +- Conflict note: the new platform enum value, platform-union member, callback + shape, and appended `ghostty_surface_config_s` fields are public ABI. Future + upstream merges must keep their numeric values, ordering, and C/Zig layouts + synchronized. Keep the output counter update after VT parsing and keep the + snapshot plus sequence read inside one renderer-state critical section. + +The previous cmux pinned fork head was `bb30526cd`. It advances the earlier +`b4b6d69c8` pin through the already-merged theme, render-grid, and wrap-aware +URL updates, then preserves authoritative sprite-font shaping runs. The commit +is reachable from fork `main` through the merged https://github.com/manaflow-ai/ghostty/pull/120. The corresponding universal ReleaseFast GhosttyKit archive is published at https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-bb30526cdab8f5fb08ae43e404e3aacc40d3ffc3-crashsubdir-cmux-crash-v1 diff --git a/docs/ghostty-render-worker.md b/docs/ghostty-render-worker.md new file mode 100644 index 000000000000..3c88485f1742 --- /dev/null +++ b/docs/ghostty-render-worker.md @@ -0,0 +1,138 @@ +# Ghostty render worker architecture + +The macOS app runs every active Ghostty Metal renderer in one supervised child +process. The app process still owns each terminal session and its authoritative +Ghostty terminal state. This boundary isolates Metal rendering and presentation +work. It does not move the complete terminal engine out of the app process. + +## Process ownership + +| Owner | Responsibilities | +| --- | --- | +| App process | AppKit and SwiftUI, shell child and PTY, authoritative Ghostty parser and terminal model, input and IME, accessibility and terminal queries, worker supervision, and final IOSurface assignment to a `CALayer` | +| Render worker | One Ghostty app, one manual-IO visual mirror per live terminal surface, active Ghostty renderer threads, Metal resources, and IOSurface production | + +The app reexecutes its signed executable with +`--cmux-ghostty-render-worker`. `CmuxMain` handles that argument before +initializing `NSApplication` or SwiftUI and enters `runGhosttyRenderWorker()`. +The worker implementation imports no AppKit and creates no AppKit objects. The +current worker is still the main app executable, so its image remains linked +against the app's frameworks. + +The app creates the authoritative surface with Ghostty's external Metal +platform and a no-op present callback. It immediately marks that surface +occluded and its renderer unrealized. The authority keeps its PTY, parser, +terminal model, synchronous input behavior, and query APIs, but has no AppKit +presentation object or active Metal swap chain. The worker creates the mirror +with `GHOSTTY_SURFACE_IO_MANUAL` and +`GHOSTTY_PLATFORM_METAL_EXTERNAL`, then keeps its renderer realized only while +the corresponding pane needs renderer resources. + +## Configuration and control + +The app serializes its finalized effective Ghostty configuration with +`ghostty_config_serialize`. A monotonic configuration revision lets the worker +ignore stale replacements. The worker loads the serialized text into its own +Ghostty config before creating mirror surfaces. + +Commands and acknowledgements use a versioned, binary-property-list protocol +over the child's standard input and output. Each payload has a four-byte length +prefix and a 16 MiB limit. The app's pipe writes are nonblocking, and AppKit or +PTY callbacks only enqueue work onto one ordered command lane. The main thread +never waits for the worker. + +Each surface command carries a stable surface UUID and a monotonic surface +generation. Commands cover ordered PTY output, size and scale, focus, +visibility, color scheme, renderer realization, refreshes, preedit, pointer +state, selection clearing, and visual Ghostty binding actions. The worker +serializes libghostty calls on its engine queue. Surface and worker generations +make late commands, events, and frames harmless after replacement or restart. + +## Terminal output mirroring + +The authority installs its PTY tee in `ghostty_surface_config_s` before +`ghostty_surface_new` starts the IO thread. The callback copies every raw output +slice before the authority parser consumes it, assigns its modulo-`UInt64` +stream position, and enqueues it for the matching worker mirror. + +The worker applies those bytes with `ghostty_surface_process_output`. It tracks +the next contiguous stream position, ignores already-applied overlap, and +reports a gap instead of silently applying out-of-order bytes. The mirror has +no shell child or PTY. Its manual-IO write callback discards terminal replies +because only the authority may write to the real session. + +The output stream is parsed twice, once by the authority and once by the visual +mirror. This preserves synchronous terminal behavior and session survival, at +the cost of duplicate parser and terminal-model work. + +## Frame transport and presentation + +Ghostty renders into its normal Metal IOSurface targets in the worker. After +the GPU completes a frame, the external presenter callback transfers an +IOSurface Mach port to the app. The frame path does not copy pixel buffers and +does not use the control pipe. + +The app creates a random bootstrap service name and a random 128-bit token for +the frame channel. Every Mach message carries the token, surface UUID, worker +generation, surface generation, frame sequence, and pixel dimensions. The +worker uses a zero-timeout Mach send. If the receive queue is full, it drops +that obsolete frame instead of blocking a Metal completion thread. + +`GhosttyRemoteIOSurfaceLayer` accepts only a frame whose identity, generations, +strictly increasing sequence, declared dimensions, actual IOSurface dimensions, +and current expected pane size all match. Assigning the accepted IOSurface to +the layer is the remaining main-actor presentation step. The layer retains the +last accepted IOSurface and disables implicit content animations. + +## Crash recovery + +A worker exit invalidates that worker generation, but the layer keeps its last +frame visible. The authority surface, PTY, shell process, input path, and full +terminal state remain alive in the app process. + +The next configuration or surface mutation starts a new worker generation. The +supervisor then asks every live authority for a recovery snapshot. Ghostty +atomically captures a bounded VT reconstruction of the newest terminal rows and +the exact next processed-output position. The new mirror applies the snapshot, +adopts that position, and then consumes queued output from the first +unrepresented byte. Overlapping queued output is trimmed, and other mutations +remain queued until resynchronization completes. + +Initialization has a timeout and one automatic retry. A full or failed +nonblocking control write is treated as worker loss so the app can recover from +the authority instead of stalling a caller. + +## Residual limitations + +- The app process still owns the Ghostty PTY, parser, terminal model, input, + accessibility, clipboard-related queries, and an unrealized local renderer + control path. Parser CPU and terminal-model memory have not left the app. +- Each terminal is represented twice. The worker adds another parser, terminal + model, font state, and active renderer, so total CPU and memory can increase + even when app-process and main-thread contention decrease. +- All mirrors share one worker process. One worker failure temporarily freezes + visual updates for every terminal, although their sessions continue and the + last frames remain visible. +- Recovery reconstructs at most 4,000 physical rows and 8 MiB of VT data for a + mirror. Older history remains authoritative in the app but is not included in + the restarted mirror's visual reconstruction. +- The worker is a reexecution mode of the signed app binary, not a separate + minimal helper executable. It avoids AppKit initialization and use, but the + binary is still linked with AppKit. +- The host main actor still handles input integration, pane geometry, and the + final constant-time IOSurface-to-layer swap. This design removes active + Ghostty Metal rendering from that process boundary, not all terminal-related + work from the main thread. + +## Code map + +- `Packages/macOS/CmuxTerminalRenderTransport` defines the versioned control + protocol, framed pipe channel, and authenticated Mach IOSurface transfer. +- `Packages/macOS/CmuxGhosttyRenderService` contains the supervisor client and + AppKit-free worker runtime. +- `Packages/macOS/CmuxTerminal` owns the authority-to-mirror routing, atomic + recovery snapshots, generation fencing, and remote presentation layer. +- `Sources/TerminalSurfaceRuntimeWiring.swift` installs the process-wide worker, + configuration updates, event handling, and frame delivery. +- `ghostty/` contains the external Metal presenter, initial PTY tee, effective + configuration serialization, and atomic VT-tail plus output-sequence APIs. From 22a16db9d91561ea4c6e837014f47773f4135a82 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 22:44:21 -0700 Subject: [PATCH 04/13] test: exercise real Ghostty render worker --- .../CmuxGhosttyRenderService/Package.swift | 120 ++++++++++++++++++ .../Sources/GhosttyKit/GhosttyKitShim.c | 1 + .../GhosttyKit/include/GhosttyKitShim.h | 3 + .../WorkerTestHost.swift | 8 ++ .../GhosttyRenderWorkerClientTests.swift | 115 +++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Package.swift create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-worker-test-host/WorkerTestHost.swift diff --git a/Packages/macOS/CmuxGhosttyRenderService/Package.swift b/Packages/macOS/CmuxGhosttyRenderService/Package.swift new file mode 100644 index 000000000000..e1805c2bb610 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Package.swift @@ -0,0 +1,120 @@ +// swift-tools-version: 6.0 + +import Foundation +import PackageDescription + +let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let ghosttyArchivePath = packageDirectory + .appendingPathComponent( + "../../../GhosttyKit.xcframework/macos-arm64_x86_64/ghostty-internal.a" + ) + .standardizedFileURL + .path + +let package = Package( + name: "CmuxGhosttyRenderService", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxGhosttyRenderClient", + targets: ["CmuxGhosttyRenderClient"] + ), + .library( + name: "CmuxGhosttyRenderWorker", + targets: ["CmuxGhosttyRenderWorker"] + ), + .executable( + name: "cmux-ghostty-render-fixture", + targets: ["cmux-ghostty-render-fixture"] + ), + .executable( + name: "cmux-ghostty-render-worker-test-host", + targets: ["cmux-ghostty-render-worker-test-host"] + ), + ], + dependencies: [ + .package(path: "../CmuxTerminalRenderTransport"), + ], + targets: [ + .target( + name: "CmuxGhosttyRenderClient", + dependencies: [ + .product( + name: "CmuxTerminalRenderTransport", + package: "CmuxTerminalRenderTransport" + ), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .target( + name: "GhosttyKit", + path: "Sources/GhosttyKit", + publicHeadersPath: "include" + ), + .target( + name: "CmuxGhosttyRenderWorker", + dependencies: [ + .product( + name: "CmuxTerminalRenderTransport", + package: "CmuxTerminalRenderTransport" + ), + "GhosttyKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ], + linkerSettings: [ + // The Ghostty XCFramework's macOS archive lacks SwiftPM's + // required `lib` prefix. Import its headers through the local + // shim target and link the same archive explicitly. + .unsafeFlags([ghosttyArchivePath]), + .linkedLibrary("c++"), + .linkedFramework("Metal"), + .linkedFramework("QuartzCore"), + .linkedFramework("IOSurface"), + .linkedFramework("UniformTypeIdentifiers"), + .linkedFramework("Carbon"), + ] + ), + .executableTarget( + name: "cmux-ghostty-render-fixture", + dependencies: [ + .product( + name: "CmuxTerminalRenderTransport", + package: "CmuxTerminalRenderTransport" + ), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .executableTarget( + name: "cmux-ghostty-render-worker-test-host", + dependencies: ["CmuxGhosttyRenderWorker"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "CmuxGhosttyRenderClientTests", + dependencies: [ + "CmuxGhosttyRenderClient", + "cmux-ghostty-render-fixture", + "cmux-ghostty-render-worker-test-host", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c new file mode 100644 index 000000000000..4f46267dc87e --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c @@ -0,0 +1 @@ +#include "GhosttyKitShim.h" diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h new file mode 100644 index 000000000000..306d4eff9289 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h @@ -0,0 +1,3 @@ +#pragma once + +#include "../../../../../../GhosttyKit.xcframework/macos-arm64_x86_64/Headers/ghostty.h" diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-worker-test-host/WorkerTestHost.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-worker-test-host/WorkerTestHost.swift new file mode 100644 index 000000000000..bf4e0146618f --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-worker-test-host/WorkerTestHost.swift @@ -0,0 +1,8 @@ +import CmuxGhosttyRenderWorker + +@main +struct CmuxGhosttyRenderWorkerTestHost { + static func main() { + runGhosttyRenderWorker() + } +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift index 43451440ad66..f86e2a306f04 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift @@ -1,9 +1,80 @@ import CmuxTerminalRenderTransport import Foundation +import IOSurface import Testing @testable import CmuxGhosttyRenderClient @Suite(.serialized) struct GhosttyRenderWorkerClientTests { + @Test func realWorkerRendersFrameInChildProcess() async throws { + let client = try GhosttyRenderWorkerClient(executableURL: realWorkerURL()) + let eventCollector = EventCollector(stream: await client.subscribeEvents()) + let frameCollector = FrameCollector(stream: await client.subscribeFrames()) + await client.updateConfiguration(configuration()) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 3) + client.commandSink.enqueue(.createSurface(descriptor)) + + let readyEvents = await eventCollector.waitUntil(timeout: .seconds(20)) { events in + events.contains(.surfaceCreated( + surfaceID: id, + surfaceGeneration: descriptor.generation + )) + } + let initializedProcessIdentifier = readyEvents.compactMap { event -> Int32? in + guard case let .initialized(_, processIdentifier) = event else { return nil } + return processIdentifier + }.last + #expect(initializedProcessIdentifier != nil) + #expect(initializedProcessIdentifier != ProcessInfo.processInfo.processIdentifier) + #expect(!readyEvents.contains { event in + if case .failure = event { return true } + return false + }) + + let initialFrameSequence = await frameCollector.maximumSequence( + surfaceID: id, + surfaceGeneration: descriptor.generation + ) ?? 0 + let output = Data("\u{1B}[31mrendered by worker\u{1B}[0m\r\n".utf8) + client.commandSink.enqueue(.mutateSurface( + id: id, + generation: descriptor.generation, + mutation: .processOutput(sequence: 0, bytes: output) + )) + client.commandSink.enqueue(.mutateSurface( + id: id, + generation: descriptor.generation, + mutation: .refresh + )) + + let outputEvents = await eventCollector.waitUntil(timeout: .seconds(20)) { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: descriptor.generation, + nextSequence: UInt64(output.count) + )) + } + let frame = await frameCollector.waitForFrame(timeout: .seconds(20)) { frame in + frame.metadata.surfaceID == id + && frame.metadata.surfaceGeneration == descriptor.generation + && frame.metadata.frameSequence > initialFrameSequence + } + await client.shutdown() + + #expect(outputEvents.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: descriptor.generation, + nextSequence: UInt64(output.count) + ))) + let renderedFrame = try #require(frame) + #expect(renderedFrame.metadata.workerGeneration == 1) + #expect(renderedFrame.metadata.width > 0) + #expect(renderedFrame.metadata.height > 0) + #expect(IOSurfaceGetWidth(renderedFrame.surface) == Int(renderedFrame.metadata.width)) + #expect(IOSurfaceGetHeight(renderedFrame.surface) == Int(renderedFrame.metadata.height)) + } + @Test func sendsOrderedSurfaceOutputThroughRealProcess() async throws { let client = try GhosttyRenderWorkerClient(executableURL: fixtureURL()) let collector = EventCollector(stream: await client.subscribeEvents()) @@ -265,10 +336,54 @@ private actor EventCollector { } } +private actor FrameCollector { + private var frames: [TerminalRenderFrame] = [] + private var pump: Task? + + init(stream: AsyncStream) { + Task { await start(stream) } + } + + private func start(_ stream: AsyncStream) { + pump = Task { + for await frame in stream { + frames.append(frame) + } + } + } + + func maximumSequence(surfaceID: UUID, surfaceGeneration: UInt64) -> UInt64? { + frames.lazy + .filter { + $0.metadata.surfaceID == surfaceID + && $0.metadata.surfaceGeneration == surfaceGeneration + } + .map(\.metadata.frameSequence) + .max() + } + + func waitForFrame( + timeout: Duration, + _ predicate: @escaping @Sendable (TerminalRenderFrame) -> Bool + ) async -> TerminalRenderFrame? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if let frame = frames.last(where: predicate) { return frame } + try? await Task.sleep(for: .milliseconds(20)) + } + return frames.last(where: predicate) + } +} + private func fixtureURL() -> URL { builtExecutableURL(named: "cmux-ghostty-render-fixture") } +private func realWorkerURL() -> URL { + builtExecutableURL(named: "cmux-ghostty-render-worker-test-host") +} + private func builtExecutableURL(named name: String) -> URL { let fileManager = FileManager.default var candidates: [URL] = [] From 209799d21660352d8ac54e6d4667e8e05e5e4bca Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:02:40 -0700 Subject: [PATCH 05/13] feat: move Ghostty rendering into dedicated worker --- .../CmuxGhosttyRenderService/Package.swift | 9 +- .../GhosttyRenderCommandSink.swift | 41 ++ .../GhosttyRenderWorkerClient.swift | 28 +- .../GhosttyRenderWorkerClientEvent.swift | 23 + .../RunGhosttyRenderWorker.swift | 496 ++++++++++++++++++ .../Sources/GhosttyKit/GhosttyKitShim.c | 1 - .../GhosttyKit/include/GhosttyKitShim.h | 3 - .../Sources/GhosttyKit/module.modulemap | 4 + .../cmux-ghostty-render-fixture/main.swift | 60 +++ .../TerminalSurfaceNativeViewing.swift | 36 ++ ...minalSurfaceRuntimeScreenTailRequest.swift | 30 ++ ...nalSurfaceRuntimeTeardownCoordinator.swift | 6 + .../Runtime/TerminalByteTeeBinding.swift | 31 +- .../Runtime/TerminalRenderWorkerRouting.swift | 22 + .../TerminalSurfaceRuntimeDependencies.swift | 5 + .../Surface/TerminalSurface+CopyMode.swift | 4 +- .../TerminalSurface+ForceRefresh.swift | 1 + .../Surface/TerminalSurface+Mobile.swift | 13 + .../TerminalSurface+MobileViewportFit.swift | 3 + .../Surface/TerminalSurface+Renderer.swift | 168 ++++-- .../TerminalSurface+RuntimeLifecycle.swift | 70 ++- ...rminalSurface+RuntimeSurfaceCreation.swift | 22 +- .../TerminalSurface+ScreenSnapshot.swift | 31 ++ .../Surface/TerminalSurface+Sizing.swift | 3 + .../Surface/TerminalSurface.swift | 9 + .../FakeTerminalByteTee.swift | 14 +- .../GhosttyRuntimeTestStubs.c | 1 + .../include/GhosttyRuntimeTestStubs.h | 1 + .../CmuxTerminalRenderTransport/Package.swift | 47 ++ .../TerminalRenderControlProtocol.swift | 240 +++++++++ .../TerminalRenderFrameTransport.swift | 274 ++++++++++ .../TerminalRenderMessageChannel.swift | 114 ++++ .../TerminalRenderMachIPC.c | 195 +++++++ .../include/TerminalRenderMachIPC.h | 76 +++ .../TerminalRenderControlProtocolTests.swift | 92 ++++ .../TerminalRenderFrameTransportTests.swift | 67 +++ .../TerminalRenderMessageChannelTests.swift | 42 ++ RenderWorker/GhosttyRenderWorkerMain.swift | 8 + Sources/GhosttyTerminalView.swift | 234 +++++++-- Sources/TerminalOutputTeeCallback.swift | 1 + Sources/TerminalOutputTeeContext.swift | 23 + Sources/TerminalSurfaceRuntimeWiring.swift | 129 ++++- Sources/cmuxApp.swift | 2 - cmux.xcodeproj/project.pbxproj | 133 +++++ cmux.xcworkspace/contents.xcworkspacedata | 6 + docs/ghostty-render-worker.md | 16 +- ghostty | 2 +- scripts/strip-release-bundle.sh | 1 + 48 files changed, 2680 insertions(+), 157 deletions(-) create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderCommandSink.swift create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClientEvent.swift create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift delete mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c delete mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/module.modulemap create mode 100644 Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRenderWorkerRouting.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Package.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderFrameTransport.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderMessageChannel.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/TerminalRenderMachIPC.c create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/include/TerminalRenderMachIPC.h create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderFrameTransportTests.swift create mode 100644 Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderMessageChannelTests.swift create mode 100644 RenderWorker/GhosttyRenderWorkerMain.swift diff --git a/Packages/macOS/CmuxGhosttyRenderService/Package.swift b/Packages/macOS/CmuxGhosttyRenderService/Package.swift index e1805c2bb610..f32d166c40b1 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Package.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Package.swift @@ -52,10 +52,9 @@ let package = Package( .enableUpcomingFeature("InternalImportsByDefault"), ] ), - .target( - name: "GhosttyKit", - path: "Sources/GhosttyKit", - publicHeadersPath: "include" + .systemLibrary( + name: "CmuxGhosttyRenderWorkerGhosttyKit", + path: "Sources/GhosttyKit" ), .target( name: "CmuxGhosttyRenderWorker", @@ -64,7 +63,7 @@ let package = Package( name: "CmuxTerminalRenderTransport", package: "CmuxTerminalRenderTransport" ), - "GhosttyKit", + "CmuxGhosttyRenderWorkerGhosttyKit", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderCommandSink.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderCommandSink.swift new file mode 100644 index 000000000000..38681fc55aa2 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderCommandSink.swift @@ -0,0 +1,41 @@ +public import CmuxTerminalRenderTransport +internal import Dispatch + +/// Thread-safe, totally ordered ingress for terminal renderer commands. +/// +/// Ghostty PTY output arrives on I/O threads while AppKit mutations arrive on +/// the main actor. Both enter this one serial queue before the client actor +/// consumes them, preventing unstructured Tasks from reordering output, +/// resize, focus, and pointer changes. +public final class GhosttyRenderCommandSink: @unchecked Sendable { + private let queue = DispatchQueue( + label: "dev.cmux.ghostty-render.command-outbox", + qos: .userInitiated + ) + private let continuation: AsyncStream.Continuation + let stream: AsyncStream + + init() { + let pair = AsyncStream.makeStream( + of: TerminalRenderWorkerCommand.self, + bufferingPolicy: .unbounded + ) + self.stream = pair.stream + self.continuation = pair.continuation + } + + /// Adds one command to the global worker ordering lane without blocking + /// the caller on process I/O. + public func enqueue(_ command: TerminalRenderWorkerCommand) { + queue.async { [continuation] in + continuation.yield(command) + } + } + + /// Ends the ordering lane during client shutdown. + func finish() { + queue.async { [continuation] in + continuation.finish() + } + } +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift index 7df34be583e8..b74cf84383c2 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift @@ -8,8 +8,8 @@ public import Foundation /// ``commandSink`` from one ordered lane, writes happen off the main actor, and /// worker generations fence late events and IOSurfaces after a crash. public actor GhosttyRenderWorkerClient { - /// Reexec argument routed before the app initializes AppKit or SwiftUI. - public nonisolated static let workerModeArgument = "--cmux-ghostty-render-worker" + /// App-relative location of the dedicated AppKit-free worker executable. + public nonisolated static let bundledWorkerRelativePath = "bin/cmux-ghostty-render-worker" /// Ordered, nonblocking ingress used by AppKit and Ghostty I/O callbacks. public nonisolated let commandSink = GhosttyRenderCommandSink() @@ -74,14 +74,16 @@ public actor GhosttyRenderWorkerClient { self.controlContinuation = controlPair.continuation } - /// Creates a client that reexecutes the current signed app binary. - public static func reexecingCurrentBinary() throws -> GhosttyRenderWorkerClient { - let binary = Bundle.main.executableURL - ?? URL(fileURLWithPath: CommandLine.arguments[0]) - return try GhosttyRenderWorkerClient( - executableURL: binary, - arguments: [workerModeArgument] - ) + /// Creates a client for the dedicated worker bundled with the app. + public static func bundledWorker(in bundle: Bundle = .main) throws -> GhosttyRenderWorkerClient { + guard let resources = bundle.resourceURL else { + throw GhosttyRenderWorkerLaunchError.missingResourceDirectory + } + let executable = resources.appendingPathComponent(bundledWorkerRelativePath) + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw GhosttyRenderWorkerLaunchError.missingBundledWorker(executable) + } + return try GhosttyRenderWorkerClient(executableURL: executable) } /// Starts the ordered consumers. Safe to call repeatedly. @@ -514,6 +516,12 @@ public actor GhosttyRenderWorkerClient { } } +/// Failures resolving the dedicated renderer executable from the app bundle. +public enum GhosttyRenderWorkerLaunchError: Error, Equatable, Sendable { + case missingResourceDirectory + case missingBundledWorker(URL) +} + private struct GhosttyRenderChild { let generation: UInt64 let process: Process diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClientEvent.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClientEvent.swift new file mode 100644 index 000000000000..c4018fea87ad --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClientEvent.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// Supervision events emitted by the host-side render worker client. +public enum GhosttyRenderWorkerClientEvent: Equatable, Sendable { + /// The child accepted the current protocol generation. + case initialized(workerGeneration: UInt64, processIdentifier: Int32) + + /// A mirror generation is ready. + case surfaceCreated(surfaceID: UUID, surfaceGeneration: UInt64) + + /// A mirror applied output through this exclusive byte position. + case outputApplied(surfaceID: UUID, surfaceGeneration: UInt64, nextSequence: UInt64) + + /// A child exited or its control channel failed. + case workerExited(workerGeneration: UInt64) + + /// The worker lost volatile terminal state and needs an authoritative host + /// screen snapshot before queued mutations can resume. + case resynchronizationRequired(surfaceID: UUID, surfaceGeneration: UInt64) + + /// A recoverable worker diagnostic. + case failure(String) +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift new file mode 100644 index 000000000000..50cd2c127723 --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift @@ -0,0 +1,496 @@ +internal import CmuxTerminalRenderTransport +internal import Darwin +internal import Foundation +internal import CmuxGhosttyRenderWorkerGhosttyKit +internal import IOSurface + +/// Runs the AppKit-free Ghostty render worker on the current executable. +/// +/// The parent launches this before initializing `NSApplication`. The calling +/// thread owns the blocking control read while every libghostty call is +/// serialized on `engineQueue`. Metal completion callbacks use only the +/// nonblocking Mach frame sender. +public func runGhosttyRenderWorker() -> Never { + let channel = TerminalRenderMessageChannel( + readDescriptor: STDIN_FILENO, + writeDescriptor: STDOUT_FILENO + ) + let engine = GhosttyRenderWorkerEngine(channel: channel) + let status = engine.run() + Darwin.exit(status) +} + +private final class GhosttyRenderWorkerEngine: @unchecked Sendable { + private let channel: TerminalRenderMessageChannel + private let engineQueue = DispatchQueue( + label: "dev.cmux.ghostty-render-worker.engine", + qos: .userInteractive + ) + private var app: ghostty_app_t? + private var configuration: ghostty_config_t? + private var configurationRevision: UInt64 = 0 + private var frameSender: TerminalRenderFrameSender? + private var workerGeneration: UInt64 = 0 + private var surfaces: [UUID: WorkerSurface] = [:] + private var shuttingDown = false + + init(channel: TerminalRenderMessageChannel) { + self.channel = channel + } + + func run() -> Int32 { + while let payload = channel.receive() { + guard let command = try? TerminalRenderControlCodec.decodeCommand(payload) else { + send(.failure("render worker rejected an invalid control message")) + continue + } + let shouldContinue = engineQueue.sync { handle(command) } + if !shouldContinue { break } + } + engineQueue.sync { teardown() } + return 0 + } + + private func handle(_ command: TerminalRenderWorkerCommand) -> Bool { + switch command { + case let .initialize(version, generation, endpoint, snapshot): + guard app == nil else { + send(.failure("render worker was initialized more than once")) + return true + } + guard version == TerminalRenderProtocol.currentVersion else { + send(.failure("render worker protocol version mismatch")) + return false + } + do { + try initialize( + workerGeneration: generation, + endpoint: endpoint, + configurationSnapshot: snapshot + ) + send(.initialized( + protocolVersion: version, + workerGeneration: generation, + processIdentifier: getpid() + )) + } catch { + send(.failure("render worker initialization failed: \(error)")) + return false + } + + case let .replaceConfiguration(snapshot): + guard app != nil else { + send(.failure("render worker received configuration before initialization")) + return true + } + guard snapshot.revision > configurationRevision else { return true } + do { + try replaceConfiguration(snapshot) + } catch { + send(.failure("render worker configuration failed: \(error)")) + } + + case let .createSurface(descriptor): + do { + try createSurface(descriptor, emitCreated: true) + } catch { + send(.failure("render surface \(descriptor.id) creation failed: \(error)")) + } + + case let .mutateSurface(id, generation, mutation): + guard let surface = surfaces[id], surface.descriptor.generation == generation else { + return true + } + apply(mutation, to: surface) + + case let .resynchronizeSurface(descriptor, nextOutputSequence, screenTailVT): + do { + try createSurface(descriptor, emitCreated: false) + guard let surface = surfaces[descriptor.id] else { + throw GhosttyRenderWorkerError.surfaceCreationFailed + } + if !screenTailVT.isEmpty { + process(screenTailVT, through: surface.handle) + } + surface.nextOutputSequence = nextOutputSequence + ghostty_surface_refresh(surface.handle) + send(.surfaceCreated(id: descriptor.id, generation: descriptor.generation)) + send(.outputApplied( + id: descriptor.id, + generation: descriptor.generation, + nextSequence: nextOutputSequence + )) + } catch { + send(.failure("render surface \(descriptor.id) resynchronization failed: \(error)")) + } + + case let .destroySurface(id, generation): + guard surfaces[id]?.descriptor.generation == generation else { return true } + destroySurface(id: id, emitDestroyed: true) + + case .shutdown: + shuttingDown = true + return false + } + return true + } + + private func initialize( + workerGeneration: UInt64, + endpoint: TerminalRenderFrameEndpoint, + configurationSnapshot: TerminalRenderConfigurationSnapshot + ) throws { + let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) + guard result == GHOSTTY_SUCCESS else { + throw GhosttyRenderWorkerError.ghosttyInitializationFailed(result) + } + + let config = try makeConfiguration(configurationSnapshot) + do { + frameSender = try TerminalRenderFrameSender(endpoint: endpoint) + } catch { + ghostty_config_free(config) + throw error + } + + var runtime = ghostty_runtime_config_s() + runtime.userdata = Unmanaged.passUnretained(self).toOpaque() + runtime.supports_selection_clipboard = false + runtime.wakeup_cb = { userdata in + guard let userdata else { return } + let engine = Unmanaged + .fromOpaque(userdata) + .takeUnretainedValue() + engine.scheduleTick() + } + runtime.action_cb = { _, _, _ in true } + runtime.read_clipboard_cb = cmuxRenderWorkerReadClipboardCallback + runtime.confirm_read_clipboard_cb = { _, _, _, _ in } + runtime.write_clipboard_cb = { _, _, _, _, _ in } + runtime.close_surface_cb = { _, _ in } + runtime.tmux_control_cb = { _, _, _, _, _ in } + + guard let createdApp = ghostty_app_new(&runtime, config) else { + ghostty_config_free(config) + throw GhosttyRenderWorkerError.appCreationFailed + } + app = createdApp + configuration = config + configurationRevision = configurationSnapshot.revision + self.workerGeneration = workerGeneration + } + + private func makeConfiguration( + _ snapshot: TerminalRenderConfigurationSnapshot + ) throws -> ghostty_config_t { + guard let config = ghostty_config_new() else { + throw GhosttyRenderWorkerError.configurationCreationFailed + } + snapshot.contents.withCString { contents in + "cmux-render-worker".withCString { source in + ghostty_config_load_string( + config, + contents, + UInt(snapshot.contents.utf8.count), + source + ) + } + } + ghostty_config_finalize(config) + return config + } + + private func replaceConfiguration( + _ snapshot: TerminalRenderConfigurationSnapshot + ) throws { + guard let app else { throw GhosttyRenderWorkerError.notInitialized } + let newConfiguration = try makeConfiguration(snapshot) + ghostty_app_update_config(app, newConfiguration) + let oldConfiguration = configuration + configuration = newConfiguration + configurationRevision = snapshot.revision + if let oldConfiguration { ghostty_config_free(oldConfiguration) } + } + + private func createSurface( + _ descriptor: TerminalRenderSurfaceDescriptor, + emitCreated: Bool + ) throws { + guard let app, let frameSender else { + throw GhosttyRenderWorkerError.notInitialized + } + destroySurface(id: descriptor.id, emitDestroyed: false) + + let presentation = WorkerSurfacePresentation( + sender: frameSender, + surfaceID: descriptor.id, + workerGeneration: workerGeneration, + surfaceGeneration: descriptor.generation + ) + let retainedPresentation = Unmanaged.passRetained(presentation) + var surfaceConfig = ghostty_surface_config_new() + surfaceConfig.platform_tag = GHOSTTY_PLATFORM_METAL_EXTERNAL + surfaceConfig.platform = ghostty_platform_u( + metal_external: ghostty_platform_metal_external_s( + userdata: retainedPresentation.toOpaque(), + present: cmuxRenderWorkerPresentCallback + ) + ) + surfaceConfig.userdata = retainedPresentation.toOpaque() + surfaceConfig.scale_factor = max(descriptor.scaleX, descriptor.scaleY) + surfaceConfig.font_size = descriptor.fontSize + surfaceConfig.context = surfaceContext(rawValue: descriptor.context) + surfaceConfig.io_mode = GHOSTTY_SURFACE_IO_MANUAL + surfaceConfig.io_write_cb = { _, _, _ in } + surfaceConfig.io_write_userdata = nil + + guard let handle = ghostty_surface_new(app, &surfaceConfig) else { + retainedPresentation.release() + throw GhosttyRenderWorkerError.surfaceCreationFailed + } + let workerSurface = WorkerSurface( + descriptor: descriptor, + handle: handle, + presentation: retainedPresentation + ) + surfaces[descriptor.id] = workerSurface + ghostty_surface_set_content_scale(handle, descriptor.scaleX, descriptor.scaleY) + ghostty_surface_set_size(handle, max(descriptor.width, 1), max(descriptor.height, 1)) + ghostty_surface_set_occlusion(handle, true) + _ = ghostty_surface_set_renderer_realized(handle, true) + ghostty_surface_refresh(handle) + if emitCreated { + send(.surfaceCreated(id: descriptor.id, generation: descriptor.generation)) + } + } + + private func apply( + _ mutation: TerminalRenderSurfaceMutation, + to surface: WorkerSurface + ) { + let handle = surface.handle + switch mutation { + case let .processOutput(sequence, bytes): + applyOutput(sequence: sequence, bytes: bytes, to: surface) + case let .resize(width, height): + ghostty_surface_set_size(handle, max(width, 1), max(height, 1)) + case let .contentScale(x, y): + ghostty_surface_set_content_scale(handle, x, y) + case let .focus(focused): + ghostty_surface_set_focus(handle, focused) + case let .occlusion(visible): + ghostty_surface_set_occlusion(handle, visible) + case let .colorScheme(rawValue): + ghostty_surface_set_color_scheme(handle, colorScheme(rawValue: rawValue)) + case let .rendererRealized(realized): + _ = ghostty_surface_set_renderer_realized(handle, realized) + case .refresh: + ghostty_surface_refresh(handle) + case let .preedit(text, _, _): + guard let text else { + ghostty_surface_preedit(handle, nil, 0) + return + } + text.withCString { ghostty_surface_preedit(handle, $0, UInt(text.utf8.count)) } + case let .mousePosition(x, y, modifiers): + ghostty_surface_mouse_pos( + handle, + x, + y, + ghostty_input_mods_e(rawValue: modifiers) + ) + case let .mouseButton(state, button, modifiers): + _ = ghostty_surface_mouse_button( + handle, + ghostty_input_mouse_state_e(rawValue: UInt32(bitPattern: state)), + ghostty_input_mouse_button_e(rawValue: UInt32(bitPattern: button)), + ghostty_input_mods_e(rawValue: modifiers) + ) + case let .mouseScroll(deltaX, deltaY, modifiers): + ghostty_surface_mouse_scroll(handle, deltaX, deltaY, Int32(bitPattern: modifiers)) + case .clearSelection: + _ = ghostty_surface_clear_selection(handle) + case let .bindingAction(action): + action.withCString { + _ = ghostty_surface_binding_action(handle, $0, UInt(action.utf8.count)) + } + } + } + + private func applyOutput( + sequence: UInt64, + bytes: Data, + to surface: WorkerSurface + ) { + let expected = surface.nextOutputSequence + let end = sequence &+ UInt64(bytes.count) + if end <= expected { + sendOutputApplied(for: surface) + return + } + guard sequence <= expected else { + send(.failure( + "render surface \(surface.descriptor.id) output gap: expected \(expected), received \(sequence)" + )) + return + } + let offset = Int(expected - sequence) + let suffix = offset == 0 ? bytes : Data(bytes.dropFirst(offset)) + process(suffix, through: surface.handle) + surface.nextOutputSequence = expected &+ UInt64(suffix.count) + sendOutputApplied(for: surface) + } + + private func process(_ data: Data, through surface: ghostty_surface_t) { + data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } + ghostty_surface_process_output(surface, base, UInt(bytes.count)) + } + } + + private func sendOutputApplied(for surface: WorkerSurface) { + send(.outputApplied( + id: surface.descriptor.id, + generation: surface.descriptor.generation, + nextSequence: surface.nextOutputSequence + )) + } + + private func destroySurface(id: UUID, emitDestroyed: Bool) { + guard let surface = surfaces.removeValue(forKey: id) else { return } + ghostty_surface_free(surface.handle) + surface.presentation.release() + if emitDestroyed { + send(.surfaceDestroyed(id: id, generation: surface.descriptor.generation)) + } + } + + private func scheduleTick() { + engineQueue.async { [weak self] in + guard let self, !self.shuttingDown, let app = self.app else { return } + ghostty_app_tick(app) + } + } + + private func send(_ event: TerminalRenderWorkerEvent) { + guard let encoded = try? TerminalRenderControlCodec.encode(event) else { return } + try? channel.send(encoded) + } + + private func teardown() { + shuttingDown = true + for id in Array(surfaces.keys) { + destroySurface(id: id, emitDestroyed: false) + } + if let app { + ghostty_app_free(app) + self.app = nil + } + if let configuration { + ghostty_config_free(configuration) + self.configuration = nil + } + frameSender = nil + } +} + +private final class WorkerSurface { + let descriptor: TerminalRenderSurfaceDescriptor + let handle: ghostty_surface_t + let presentation: Unmanaged + var nextOutputSequence: UInt64 = 0 + + init( + descriptor: TerminalRenderSurfaceDescriptor, + handle: ghostty_surface_t, + presentation: Unmanaged + ) { + self.descriptor = descriptor + self.handle = handle + self.presentation = presentation + } +} + +private final class WorkerSurfacePresentation: @unchecked Sendable { + private let sender: TerminalRenderFrameSender + private let surfaceID: UUID + private let workerGeneration: UInt64 + private let surfaceGeneration: UInt64 + private let lock = NSLock() + private var nextFrameSequence: UInt64 = 1 + + init( + sender: TerminalRenderFrameSender, + surfaceID: UUID, + workerGeneration: UInt64, + surfaceGeneration: UInt64 + ) { + self.sender = sender + self.surfaceID = surfaceID + self.workerGeneration = workerGeneration + self.surfaceGeneration = surfaceGeneration + } + + func present(_ surface: IOSurfaceRef, width: UInt32, height: UInt32) { + let sequence = lock.withLock { () -> UInt64 in + defer { nextFrameSequence &+= 1 } + return nextFrameSequence + } + _ = try? sender.send( + surface: surface, + metadata: TerminalRenderFrameMetadata( + surfaceID: surfaceID, + workerGeneration: workerGeneration, + surfaceGeneration: surfaceGeneration, + frameSequence: sequence, + width: width, + height: height + ) + ) + } +} + +private let cmuxRenderWorkerPresentCallback: ghostty_metal_external_present_cb = { + userdata, + iosurface, + width, + height in + guard let userdata, let iosurface else { return } + let presentation = Unmanaged + .fromOpaque(userdata) + .takeUnretainedValue() + let surface = Unmanaged + .fromOpaque(iosurface) + .takeUnretainedValue() + presentation.present(surface, width: width, height: height) +} + +private let cmuxRenderWorkerReadClipboardCallback: @convention(c) ( + UnsafeMutableRawPointer?, + ghostty_clipboard_e, + UnsafeMutableRawPointer? +) -> Bool = { _, _, _ in false } + +private func surfaceContext(rawValue: Int32) -> ghostty_surface_context_e { + switch rawValue { + case Int32(GHOSTTY_SURFACE_CONTEXT_TAB.rawValue): GHOSTTY_SURFACE_CONTEXT_TAB + case Int32(GHOSTTY_SURFACE_CONTEXT_SPLIT.rawValue): GHOSTTY_SURFACE_CONTEXT_SPLIT + default: GHOSTTY_SURFACE_CONTEXT_WINDOW + } +} + +private func colorScheme(rawValue: Int32) -> ghostty_color_scheme_e { + switch rawValue { + case Int32(GHOSTTY_COLOR_SCHEME_LIGHT.rawValue): GHOSTTY_COLOR_SCHEME_LIGHT + case Int32(GHOSTTY_COLOR_SCHEME_DARK.rawValue): GHOSTTY_COLOR_SCHEME_DARK + default: GHOSTTY_COLOR_SCHEME_LIGHT + } +} + +private enum GhosttyRenderWorkerError: Error { + case ghosttyInitializationFailed(Int32) + case configurationCreationFailed + case appCreationFailed + case surfaceCreationFailed + case notInitialized +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c deleted file mode 100644 index 4f46267dc87e..000000000000 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/GhosttyKitShim.c +++ /dev/null @@ -1 +0,0 @@ -#include "GhosttyKitShim.h" diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h deleted file mode 100644 index 306d4eff9289..000000000000 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/include/GhosttyKitShim.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -#include "../../../../../../GhosttyKit.xcframework/macos-arm64_x86_64/Headers/ghostty.h" diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/module.modulemap b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/module.modulemap new file mode 100644 index 000000000000..e54f87887ede --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/GhosttyKit/module.modulemap @@ -0,0 +1,4 @@ +module CmuxGhosttyRenderWorkerGhosttyKit { + header "../../../../../GhosttyKit.xcframework/macos-arm64_x86_64/Headers/ghostty.h" + export * +} diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift new file mode 100644 index 000000000000..c84e59a7639e --- /dev/null +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift @@ -0,0 +1,60 @@ +import CmuxTerminalRenderTransport +import Foundation + +let channel = TerminalRenderMessageChannel(readDescriptor: 0, writeDescriptor: 1) +let environment = ProcessInfo.processInfo.environment +let crashAfterInitialize = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH"] == "1" +let crashOnceFile = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH_ONCE_FILE"] + +func send(_ event: TerminalRenderWorkerEvent) { + guard let payload = try? TerminalRenderControlCodec.encode(event) else { return } + try? channel.send(payload) +} + +while let payload = channel.receive() { + guard let command = try? TerminalRenderControlCodec.decodeCommand(payload) else { + send(.failure("decode failed")) + continue + } + switch command { + case let .initialize(version, generation, _, _): + send(.initialized( + protocolVersion: version, + workerGeneration: generation, + processIdentifier: ProcessInfo.processInfo.processIdentifier + )) + if crashAfterInitialize { + exit(86) + } + if let crashOnceFile, + !FileManager.default.fileExists(atPath: crashOnceFile) { + _ = FileManager.default.createFile(atPath: crashOnceFile, contents: Data()) + exit(86) + } + case let .createSurface(descriptor): + send(.surfaceCreated(id: descriptor.id, generation: descriptor.generation)) + case let .resynchronizeSurface(descriptor, nextSequence, _): + send(.surfaceCreated(id: descriptor.id, generation: descriptor.generation)) + send(.outputApplied( + id: descriptor.id, + generation: descriptor.generation, + nextSequence: nextSequence + )) + case let .mutateSurface(id, generation, mutation): + if case let .processOutput(sequence, bytes) = mutation { + send(.outputApplied( + id: id, + generation: generation, + nextSequence: sequence + UInt64(bytes.count) + )) + } + case let .destroySurface(id, generation): + send(.surfaceDestroyed(id: id, generation: generation)) + case .replaceConfiguration: + continue + case .shutdown: + exit(0) + } +} + +exit(0) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift index 8818f4e49032..06ec76b8bc9d 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift @@ -1,5 +1,6 @@ public import AppKit public import CmuxTerminalCore +public import CmuxTerminalRenderTransport /// The inner terminal NSView a ``TerminalSurface`` renders into. /// @@ -34,4 +35,39 @@ public protocol TerminalSurfaceNativeViewing: NSView, TerminalSurfaceHosting { /// - Returns: Whether a refresh was performed. @discardableResult func forceRefreshSurface() -> Bool + + /// Installs or retargets the host-owned IOSurface presentation layer. + func configureRemoteRenderer( + surfaceID: UUID, + surfaceGeneration: UInt64, + width: UInt32, + height: UInt32 + ) + + /// Allows frames from a newly initialized worker generation. + func updateRemoteRendererWorkerGeneration(_ generation: UInt64) + + /// Fences late frames from an exited worker without clearing the last frame. + func invalidateRemoteRendererWorkerGeneration(_ generation: UInt64) + + /// Updates the exact pixel dimensions accepted by the presentation layer. + func updateRemoteRendererExpectedSize(width: UInt32, height: UInt32) + + /// Presents one already generation-fenced remote frame. + @discardableResult + func presentRemoteRendererFrame(_ frame: TerminalRenderFrame) -> Bool +} + +public extension TerminalSurfaceNativeViewing { + func configureRemoteRenderer( + surfaceID: UUID, + surfaceGeneration: UInt64, + width: UInt32, + height: UInt32 + ) {} + + func updateRemoteRendererWorkerGeneration(_ generation: UInt64) {} + func invalidateRemoteRendererWorkerGeneration(_ generation: UInt64) {} + func updateRemoteRendererExpectedSize(width: UInt32, height: UInt32) {} + @discardableResult func presentRemoteRendererFrame(_ frame: TerminalRenderFrame) -> Bool { false } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index b962b7fd8a56..418e06bf7eb0 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -31,4 +31,34 @@ struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { } return String(bytes: Data(bytes: bytes, count: byteCount), encoding: .utf8) } + + func readWithOutputSequence() -> TerminalSurfaceRenderResynchronizationSnapshot? { + var text = ghostty_text_s() + var nextOutputSequence: UInt64 = 0 + guard ghostty_surface_read_screen_tail_vt_with_output_sequence( + surface, + UInt(maxRows), + UInt(maxBytes), + &text, + &nextOutputSequence + ) else { + return nil + } + defer { ghostty_surface_free_text(surface, &text) } + let data: Data + if let bytes = text.text, text.text_len > 0 { + data = Data(bytes: bytes, count: Int(text.text_len)) + } else { + data = Data() + } + return TerminalSurfaceRenderResynchronizationSnapshot( + screenTailVT: data, + nextOutputSequence: nextOutputSequence + ) + } +} + +struct TerminalSurfaceRenderResynchronizationSnapshot: Sendable { + let screenTailVT: Data + let nextOutputSequence: UInt64 } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 024a955b692f..590c307166d3 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -37,6 +37,12 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { request.read() } + func readRenderResynchronizationSnapshot( + _ request: TerminalSurfaceRuntimeScreenTailRequest + ) -> TerminalSurfaceRenderResynchronizationSnapshot? { + request.readWithOutputSequence() + } + /// Queues a native-surface free from any isolation (the surface model's /// `deinit` is nonisolated and cannot await). /// diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift index 462120d92f41..727d97dafc87 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalByteTeeBinding.swift @@ -11,24 +11,43 @@ public protocol TerminalByteTeeLease: AnyObject, Sendable { func release() } +/// A callback and retained userdata prepared before `ghostty_surface_new`. +/// Installing this pair in the initial C config closes the startup-byte race +/// that exists when a tee is attached after Ghostty starts its IO thread. +public struct TerminalByteTeeInstallation { + public let callback: ghostty_pty_tee_cb + public let userdata: UnsafeMutableRawPointer + public let lease: any TerminalByteTeeLease + + public init( + callback: @escaping ghostty_pty_tee_cb, + userdata: UnsafeMutableRawPointer, + lease: any TerminalByteTeeLease + ) { + self.callback = callback + self.userdata = userdata + self.lease = lease + } +} + /// Installs and tears down the shared PTY output tee for runtime surfaces. /// /// The app routes tee'd bytes to opt-in terminal-output consumers while /// preserving one libghostty callback per surface. public protocol TerminalByteTeeBinding: AnyObject, Sendable { - /// Installs the PTY tee callback on a freshly created runtime surface. + /// Prepares the PTY tee callback before a runtime surface is created. /// /// - Parameters: - /// - surface: The live runtime surface. /// - workspaceID: The workspace that owns the surface. /// - surfaceID: The owning surface id used to key tee state. + /// - surfaceGeneration: The worker mirror generation receiving bytes. /// - Returns: The retained lease the caller releases on teardown. @MainActor - func installTee( - on surface: ghostty_surface_t, + func prepareTee( workspaceID: UUID, - surfaceID: UUID - ) -> any TerminalByteTeeLease + surfaceID: UUID, + surfaceGeneration: UInt64 + ) -> TerminalByteTeeInstallation /// Drops all tee/replay state keyed by a surface id. /// diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRenderWorkerRouting.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRenderWorkerRouting.swift new file mode 100644 index 000000000000..07102ce3ab5f --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRenderWorkerRouting.swift @@ -0,0 +1,22 @@ +public import CmuxTerminalRenderTransport + +/// Nonblocking command seam from one terminal surface to the process-wide +/// Ghostty render worker supervisor. +/// +/// All producers use the supervisor's single ordered ingress, including the +/// Ghostty PTY read thread. Implementations must copy borrowed data before +/// returning and must never wait for the worker process. +public protocol TerminalRenderWorkerRouting: AnyObject, Sendable { + /// Enqueues one versioned render-worker command. + func enqueueRenderCommand(_ command: TerminalRenderWorkerCommand) +} + +/// Default used by package tests and embedders that do not install a worker. +public final class DisabledTerminalRenderWorkerRouter: TerminalRenderWorkerRouting, @unchecked Sendable { + /// Process-wide inert router. + public static let shared = DisabledTerminalRenderWorkerRouter() + + private init() {} + + public func enqueueRenderCommand(_ command: TerminalRenderWorkerCommand) {} +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift index 4dd6d4bcf76b..10a898bbb5fc 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift @@ -24,6 +24,9 @@ public struct TerminalSurfaceRuntimeDependencies { /// The shared PTY output-tee installer. public let byteTee: any TerminalByteTeeBinding + /// The process-wide renderer-worker command router. + public let renderWorker: any TerminalRenderWorkerRouting + /// The renderer-reclamation pass scheduler. public let rendererRealization: any TerminalRendererRealizationScheduling @@ -61,6 +64,7 @@ public struct TerminalSurfaceRuntimeDependencies { viewProvider: any TerminalSurfaceViewProviding, spawnPolicy: any TerminalSurfaceSpawnPolicyProviding, byteTee: any TerminalByteTeeBinding, + renderWorker: any TerminalRenderWorkerRouting = DisabledTerminalRenderWorkerRouter.shared, rendererRealization: any TerminalRendererRealizationScheduling, hibernationRecorder: any AgentHibernationRecording, runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator, @@ -76,6 +80,7 @@ public struct TerminalSurfaceRuntimeDependencies { self.viewProvider = viewProvider self.spawnPolicy = spawnPolicy self.byteTee = byteTee + self.renderWorker = renderWorker self.rendererRealization = rendererRealization self.hibernationRecorder = hibernationRecorder self.runtimeTeardown = runtimeTeardown diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swift index 9635a5d322be..d7e4384d51a4 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+CopyMode.swift @@ -11,9 +11,11 @@ extension TerminalSurface { @discardableResult public func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } - return action.withCString { cString in + let handled = action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) } + enqueueRenderMutation(.bindingAction(action)) + return handled } /// Performs an internal binding action without treating it as user input. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ForceRefresh.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ForceRefresh.swift index 128ca4c1068c..881ef8602d1b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ForceRefresh.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ForceRefresh.swift @@ -63,5 +63,6 @@ extension TerminalSurface { return } ghostty_surface_refresh(surface) + enqueueRenderMutation(.refresh) } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift index fe9b98df8d9a..5c336ccb996f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift @@ -26,6 +26,8 @@ extension TerminalSurface { let posY = (Double(row) + 0.5) * cellHeightPt ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE) ghostty_surface_mouse_scroll(surface, 0, deltaLines, 0) + mirrorRenderMousePosition(x: posX, y: posY, modifiers: 0) + mirrorRenderMouseScroll(deltaX: 0, deltaY: deltaLines, modifiers: 0) } /// Forward a mobile tap to this real surface as a left mouse click at the @@ -50,6 +52,17 @@ extension TerminalSurface { ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, GHOSTTY_MODS_NONE) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, GHOSTTY_MODS_NONE) + mirrorRenderMousePosition(x: posX, y: posY, modifiers: 0) + mirrorRenderMouseButton( + state: Int32(GHOSTTY_MOUSE_PRESS.rawValue), + button: Int32(GHOSTTY_MOUSE_LEFT.rawValue), + modifiers: 0 + ) + mirrorRenderMouseButton( + state: Int32(GHOSTTY_MOUSE_RELEASE.rawValue), + button: Int32(GHOSTTY_MOUSE_LEFT.rawValue), + modifiers: 0 + ) } /// Exports the surface grid as a mobile render frame (optionally filtered diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift index 3a1626267a9a..45163f160b5e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift @@ -111,6 +111,7 @@ extension TerminalSurface { return (fit.columns, fit.rows) } ghostty_surface_set_size(surface, appliedWidth, appliedHeight) + updateRenderMirrorSize(width: appliedWidth, height: appliedHeight) lastPixelWidth = appliedWidth lastPixelHeight = appliedHeight ghostty_surface_refresh(surface) @@ -160,6 +161,7 @@ extension TerminalSurface { guard sizeChanged else { return (appliedColumns, appliedRows) } ghostty_surface_set_size(surface, appliedWidth, appliedHeight) + updateRenderMirrorSize(width: appliedWidth, height: appliedHeight) lastPixelWidth = appliedWidth lastPixelHeight = appliedHeight ghostty_surface_refresh(surface) @@ -205,6 +207,7 @@ extension TerminalSurface { return fontRestored } ghostty_surface_set_size(surface, uncappedWidth, uncappedHeight) + updateRenderMirrorSize(width: uncappedWidth, height: uncappedHeight) lastPixelWidth = uncappedWidth lastPixelHeight = uncappedHeight ghostty_surface_refresh(surface) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index d479f70b505e..069ca701f3b5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -1,6 +1,7 @@ public import AppKit public import Foundation public import GhosttyKit +public import CmuxTerminalRenderTransport // MARK: - Focus, occlusion, and renderer reclamation @@ -16,6 +17,7 @@ extension TerminalSurface { /// Without this, `createSurface` would replay a stale state on recreation. public func recordExternalFocusState(_ focused: Bool) { desiredFocusState = focused + enqueueRenderMutation(.focus(focused)) } /// Applies a focus state to the runtime surface (deduplicated). @@ -29,6 +31,7 @@ extension TerminalSurface { // layout restoration). createSurface syncs the state once created. guard let surface = surface else { return } ghostty_surface_set_focus(surface, focused) + enqueueRenderMutation(.focus(focused)) // If we focus a surface while it is being rapidly reparented (closing splits, etc), // Ghostty's CVDisplayLink can end up started before the display id is valid, leaving @@ -45,15 +48,14 @@ extension TerminalSurface { /// Applies the occlusion state to the runtime surface. public func setOcclusion(_ visible: Bool) { - guard let surface = surface else { return } - ghostty_surface_set_occlusion(surface, visible) + enqueueRenderMutation(.occlusion(visible)) } /// Whether this surface currently holds realized GPU renderer resources. /// Read by `RendererRealizationController` to skip surfaces with nothing to /// release. Requires a live runtime surface — the `rendererRealized` flag /// defaults to `true` even before `createSurface`, so gate on `surface`. - public var isRendererRealized: Bool { surface != nil && rendererRealized } + public var isRendererRealized: Bool { renderMirrorDescriptor != nil && rendererRealized } /// Whether this surface's portal is currently visible in the UI. This is the /// authoritative on-screen signal (the same one that drives occlusion via @@ -98,22 +100,10 @@ extension TerminalSurface { public func releaseRenderer() -> Bool { #if os(macOS) guard rendererRealized, !rendererPortalVisible else { return false } - // The reclamation controller is default-on and scans every registered - // wrapper, so validate the native pointer (registry ownership + - // liveness) before the C call instead of trusting `surface != nil`. - // This self-heals a stale wrapper whose runtime surface was freed - // out-of-band rather than passing a dangling pointer to Ghostty. - guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.release") else { return false } - // Only advance our mirror state when the message was actually enqueued - // (a `.forever` push can still drop on a spurious wakeup while the - // mailbox is full). If it dropped, keep `rendererRealized = true` so the - // controller retries on its next pass rather than desyncing from - // Ghostty's still-realized swap chain. - if ghostty_surface_set_renderer_realized(surface, false) { - rendererRealized = false - return true - } - return false + guard renderMirrorDescriptor != nil else { return false } + enqueueRenderMutation(.rendererRealized(false)) + rendererRealized = false + return true #else return false #endif @@ -128,28 +118,124 @@ extension TerminalSurface { public func realizeRenderer() { #if os(macOS) guard !rendererRealized else { return } - // Validate the native pointer before the C call (see releaseRenderer). - // If the wrapper is stale this returns nil and tears it down; the next - // createSurface re-creates a fresh realized surface, so we never - // double-realize a defunct swap chain. - guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } - // Non-blocking enqueue (the C API pushes `.instant`): advance our mirror - // state only on success. On re-show the renderer mailbox is normally - // empty, so the realize enqueues immediately and the surface is never - // presented against a defunct swap chain. In the rare full-mailbox case - // the push drops, `rendererRealized` stays false, and the controller's - // pass re-realizes any visible-but-unrealized surface as the backstop. We - // never block the main actor waiting on the renderer thread. - if ghostty_surface_set_renderer_realized(surface, true) { - rendererRealized = true - } else { - // Enqueue dropped (full mailbox, i.e. the renderer thread is not - // draining). Kick an immediate reclamation pass so the controller - // re-realizes this now-visible surface on the next runloop turn - // instead of waiting for the periodic tick, minimizing how long a - // re-shown terminal could draw against a defunct swap chain. - rendererRealization.scheduleImmediatePass() - } + guard renderMirrorDescriptor != nil else { return } + enqueueRenderMutation(.rendererRealized(true)) + rendererRealized = true #endif } + + func enqueueRenderMutation(_ mutation: TerminalRenderSurfaceMutation) { + guard let descriptor = renderMirrorDescriptor else { return } + renderWorker.enqueueRenderCommand(.mutateSurface( + id: id, + generation: descriptor.generation, + mutation: mutation + )) + } + + /// Mirrors the host appearance into the worker-owned renderer. + public func setRenderColorScheme(_ rawValue: Int32) { + enqueueRenderMutation(.colorScheme(rawValue)) + } + + public func mirrorRenderPreedit( + _ text: String?, + selectionStart: Int, + selectionLength: Int + ) { + enqueueRenderMutation(.preedit( + text: text, + selectionStart: selectionStart, + selectionLength: selectionLength + )) + } + + public func mirrorRenderMousePosition(x: Double, y: Double, modifiers: UInt32) { + enqueueRenderMutation(.mousePosition(x: x, y: y, modifiers: modifiers)) + } + + public func mirrorRenderMouseButton(state: Int32, button: Int32, modifiers: UInt32) { + enqueueRenderMutation(.mouseButton( + state: state, + button: button, + modifiers: modifiers + )) + } + + public func mirrorRenderMouseScroll(deltaX: Double, deltaY: Double, modifiers: UInt32) { + enqueueRenderMutation(.mouseScroll( + deltaX: deltaX, + deltaY: deltaY, + modifiers: modifiers + )) + } + + public func clearRenderSelection() { + enqueueRenderMutation(.clearSelection) + } + + @MainActor + func updateRenderMirrorSize(width: UInt32, height: UInt32) { + guard let descriptor = renderMirrorDescriptor else { return } + renderMirrorDescriptor = TerminalRenderSurfaceDescriptor( + id: descriptor.id, + generation: descriptor.generation, + width: width, + height: height, + scaleX: descriptor.scaleX, + scaleY: descriptor.scaleY, + fontSize: descriptor.fontSize, + context: descriptor.context + ) + enqueueRenderMutation(.resize(width: width, height: height)) + surfaceView.updateRemoteRendererExpectedSize(width: width, height: height) + } + + @MainActor + func updateRenderMirrorScale(x: Double, y: Double) { + guard let descriptor = renderMirrorDescriptor else { return } + renderMirrorDescriptor = TerminalRenderSurfaceDescriptor( + id: descriptor.id, + generation: descriptor.generation, + width: descriptor.width, + height: descriptor.height, + scaleX: x, + scaleY: y, + fontSize: descriptor.fontSize, + context: descriptor.context + ) + enqueueRenderMutation(.contentScale(x: x, y: y)) + } + + func destroyRenderMirror() { + guard let descriptor = renderMirrorDescriptor else { return } + renderMirrorDescriptor = nil + rendererRealized = false + renderWorker.enqueueRenderCommand(.destroySurface( + id: descriptor.id, + generation: descriptor.generation + )) + } + + /// Updates the view's frame fence after the worker accepts initialization. + @MainActor + public func renderWorkerDidInitialize( + workerGeneration: UInt64, + processIdentifier: Int32 + ) { + guard renderMirrorDescriptor != nil else { return } + surfaceView.updateRemoteRendererWorkerGeneration(workerGeneration) + } + + /// Retains the last frame while fencing messages from an exited worker. + @MainActor + public func renderWorkerDidExit(workerGeneration: UInt64) { + surfaceView.invalidateRemoteRendererWorkerGeneration(workerGeneration) + } + + /// Hands an imported IOSurface to the main-actor presentation layer. + @MainActor + public func acceptRenderWorkerFrame(_ frame: TerminalRenderFrame) { + _ = surfaceView.presentRemoteRendererFrame(frame) + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 600c3bf4faf9..377e85d61ac4 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -2,6 +2,7 @@ public import AppKit public import Foundation public import GhosttyKit public import CmuxTerminalCore +internal import CmuxTerminalRenderTransport internal import CMUXAgentLaunch internal import Darwin #if DEBUG @@ -134,6 +135,7 @@ extension TerminalSurface { mobileByteTeeLease = nil registry.unregisterRuntimeSurface(surface, ownerId: id) self.surface = nil + destroyRenderMirror() activePortalHostLease = nil portalHostAuthority = nil recordTeardownRequest(reason: reason) @@ -230,6 +232,7 @@ extension TerminalSurface { backgroundSurfaceStartSource = .normal cancelClaudeCommandShimInstallLifecycle() closeHeadlessStartupWindowIfNeeded() + destroyRenderMirror() let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil @@ -300,6 +303,7 @@ extension TerminalSurface { backgroundSurfaceStartSource = .normal cancelClaudeCommandShimInstallLifecycle() closeHeadlessStartupWindowIfNeeded() + destroyRenderMirror() let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil let manualIOContext = manualIOContext @@ -512,16 +516,58 @@ extension TerminalSurface { let scaleFactors = scaleFactors(for: view) + renderMirrorGeneration &+= 1 + let mirrorGeneration = renderMirrorGeneration + let initialBackingSize = view.convertToBacking( + NSRect(origin: .zero, size: view.bounds.size) + ).size + let initialWidth = max(pixelDimension(from: initialBackingSize.width), 1) + let initialHeight = max(pixelDimension(from: initialBackingSize.height), 1) + let baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate() + let descriptor = TerminalRenderSurfaceDescriptor( + id: id, + generation: mirrorGeneration, + width: initialWidth, + height: initialHeight, + scaleX: scaleFactors.x, + scaleY: scaleFactors.y, + fontSize: CmuxSurfaceConfigTemplate.runtimeFontSize( + fromBasePoints: baseConfig.fontSize, + percent: globalFontMagnificationPercent() + ), + context: Int32(truncatingIfNeeded: surfaceContext.rawValue) + ) + renderMirrorDescriptor = descriptor + surfaceView.configureRemoteRenderer( + surfaceID: id, + surfaceGeneration: mirrorGeneration, + width: initialWidth, + height: initialHeight + ) + renderWorker.enqueueRenderCommand(.createSurface(descriptor)) + let teeInstallation = byteTee.prepareTee( + workspaceID: tabId, + surfaceID: id, + surfaceGeneration: mirrorGeneration + ) + let runtimeSurfaceCreation = createNativeRuntimeSurface( app: app, for: view, scaleFactors: scaleFactors, - claudeShim: claudeShim + claudeShim: claudeShim, + teeInstallation: teeInstallation ) surface = runtimeSurfaceCreation.createdSurface let runtimeInitialInput = runtimeSurfaceCreation.runtimeInitialInput if surface == nil { + teeInstallation.lease.release() + renderWorker.enqueueRenderCommand(.destroySurface( + id: id, + generation: mirrorGeneration + )) + renderMirrorDescriptor = nil surfaceCallbackContext?.release() surfaceCallbackContext = nil manualIOContext?.release() @@ -564,7 +610,7 @@ extension TerminalSurface { // grid parity by construction. The lease is released alongside // `surfaceCallbackContext` when the surface tears down. mobileByteTeeLease?.release() - mobileByteTeeLease = byteTee.installTee(on: createdSurface, workspaceID: tabId, surfaceID: id) + mobileByteTeeLease = teeInstallation.lease if runtimeInitialInput != nil { nextRuntimeInitialInput = nil } @@ -626,6 +672,21 @@ extension TerminalSurface { // surface converges with any focus changes that happened while the // surface was being initialized. ghostty_surface_set_focus(createdSurface, desiredFocusState) + // Keep the authority parser/input surface alive while permanently + // relinquishing its local GPU renderer. The worker mirror is the sole + // owner of presentation resources. + ghostty_surface_set_occlusion(createdSurface, false) + _ = ghostty_surface_set_renderer_realized(createdSurface, false) + renderWorker.enqueueRenderCommand(.mutateSurface( + id: id, + generation: mirrorGeneration, + mutation: .focus(desiredFocusState) + )) + renderWorker.enqueueRenderCommand(.mutateSurface( + id: id, + generation: mirrorGeneration, + mutation: .occlusion(rendererPortalVisible) + )) flushPendingSocketInputIfNeeded() @@ -634,6 +695,11 @@ extension TerminalSurface { // transition nudges the renderer. view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) + renderWorker.enqueueRenderCommand(.mutateSurface( + id: id, + generation: mirrorGeneration, + mutation: .refresh + )) NotificationCenter.default.post( name: .terminalSurfaceDidBecomeReady, diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index 56fd35352634..80748275cc89 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -16,7 +16,8 @@ extension TerminalSurface { app: ghostty_app_t, for view: any TerminalSurfaceNativeViewing, scaleFactors: (x: CGFloat, y: CGFloat, layer: CGFloat), - claudeShim: ClaudeCommandShim? + claudeShim: ClaudeCommandShim?, + teeInstallation: TerminalByteTeeInstallation ) -> (createdSurface: ghostty_surface_t?, runtimeInitialInput: String?) { var baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate() var surfaceConfig = ghostty_surface_config_new() @@ -26,16 +27,23 @@ extension TerminalSurface { percent: magnificationPercent ) surfaceConfig.wait_after_command = baseConfig.waitAfterCommand - surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS - surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( - nsview: Unmanaged.passUnretained(view as NSView).toOpaque() - )) + // The authority surface owns PTY/parser/input state but never presents + // pixels in the app process. The worker owns the realized renderer. + surfaceConfig.platform_tag = GHOSTTY_PLATFORM_METAL_EXTERNAL + surfaceConfig.platform = ghostty_platform_u( + metal_external: ghostty_platform_metal_external_s( + userdata: nil, + present: cmuxTerminalAuthorityNoopPresentCallback + ) + ) let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceHost: view, surfaceController: self)) surfaceConfig.userdata = callbackContext.toOpaque() surfaceCallbackContext?.release() surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext + surfaceConfig.pty_tee_cb = teeInstallation.callback + surfaceConfig.pty_tee_userdata = teeInstallation.userdata if manualIO { // MANUAL I/O: ghostty spawns no process; typed input is delivered // to our callback and output is injected through @@ -297,3 +305,7 @@ extension TerminalSurface { } } } + +private let cmuxTerminalAuthorityNoopPresentCallback: ghostty_metal_external_present_cb = { + _, _, _, _ in +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index c1c9f7aebb01..855da54718ff 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -1,3 +1,5 @@ +public import CmuxTerminalRenderTransport + extension TerminalSurface { /// Reads a byte-bounded VT reconstruction of the newest physical terminal rows. /// @@ -25,4 +27,33 @@ extension TerminalSurface { ) ) } + + /// Captures the authority parser and its exact processed-output position + /// for a restarted renderer worker. + @MainActor + public func renderWorkerResynchronizationCommand( + surfaceGeneration: UInt64, + maxRows: Int = 4_000, + maxBytes: Int = 8 * 1_048_576 + ) async -> TerminalRenderWorkerCommand? { + guard let descriptor = renderMirrorDescriptor, + descriptor.generation == surfaceGeneration, + let surface = liveSurfaceForGhosttyAccess(reason: "renderWorkerResynchronization") else { + return nil + } + guard let snapshot = await runtimeTeardown.readRenderResynchronizationSnapshot( + TerminalSurfaceRuntimeScreenTailRequest( + surface: surface, + maxRows: maxRows, + maxBytes: maxBytes + ) + ) else { + return nil + } + return .resynchronizeSurface( + descriptor: descriptor, + nextOutputSequence: snapshot.nextOutputSequence, + screenTailVT: snapshot.screenTailVT + ) + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift index ab0c03082e62..522c0a47e475 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift @@ -304,6 +304,7 @@ extension TerminalSurface { let deferScaleUntilResized = scaleChanged && sizeChanged && (xScale > lastXScale || yScale > lastYScale) if scaleChanged && !deferScaleUntilResized { ghostty_surface_set_content_scale(surface, xScale, yScale) + updateRenderMirrorScale(x: xScale, y: yScale) lastXScale = xScale lastYScale = yScale } @@ -351,6 +352,7 @@ extension TerminalSurface { writeProcessOutputData(Self.decawmDisableSequence, to: surface) } ghostty_surface_set_size(surface, wpx, hpx) + updateRenderMirrorSize(width: wpx, height: hpx) lastPixelWidth = wpx lastPixelHeight = hpx if manualIO { @@ -373,6 +375,7 @@ extension TerminalSurface { // applying the larger cell only shrinks it back to the final width. if deferScaleUntilResized { ghostty_surface_set_content_scale(surface, xScale, yScale) + updateRenderMirrorScale(x: xScale, y: yScale) lastXScale = xScale lastYScale = yScale } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index a77596a814c1..6ca139a9ce5e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -3,6 +3,7 @@ public import Combine public import Foundation public import GhosttyKit public import CmuxTerminalCore +public import CmuxTerminalRenderTransport #if DEBUG internal import CMUXDebugLog #endif @@ -80,6 +81,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { let engine: any TerminalEngineHosting let spawnPolicyProvider: any TerminalSurfaceSpawnPolicyProviding let byteTee: any TerminalByteTeeBinding + let renderWorker: any TerminalRenderWorkerRouting let rendererRealization: any TerminalRendererRealizationScheduling let hibernationRecorder: any AgentHibernationRecording let runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator @@ -279,6 +281,11 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// coordinator's request). Releasing earlier is a use-after-free on the /// io-reader thread. var mobileByteTeeLease: (any TerminalByteTeeLease)? + /// Monotonic identity of the worker-owned visual mirror for the current + /// native authority surface. This is independent of pointer reuse. + var renderMirrorGeneration: UInt64 = 0 + /// Latest descriptor replayed after worker restart. + var renderMirrorDescriptor: TerminalRenderSurfaceDescriptor? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` /// reapplies this value once the runtime surface exists, then keeps using it @@ -451,6 +458,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { self.engine = dependencies.engine self.spawnPolicyProvider = dependencies.spawnPolicy self.byteTee = dependencies.byteTee + self.renderWorker = dependencies.renderWorker self.rendererRealization = dependencies.rendererRealization self.hibernationRecorder = dependencies.hibernationRecorder self.runtimeTeardown = dependencies.runtimeTeardown @@ -530,6 +538,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { claudeCommandShimCompletionTask?.cancel() registry.unregister(self) markPortalLifecycleClosed(reason: "deinit") + destroyRenderMirror() // Mirror closeHeadlessStartupWindowIfNeeded: deinit is nonisolated, so // the NSWindow teardown hops to the main actor through the same kind of // @unchecked Sendable transport the runtime teardown request uses. The diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift index b0d444acd2c9..8f1969836c6b 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalByteTee.swift @@ -4,12 +4,16 @@ import GhosttyKit final class FakeTerminalByteTee: TerminalByteTeeBinding { @MainActor - func installTee( - on surface: ghostty_surface_t, + func prepareTee( workspaceID: UUID, - surfaceID: UUID - ) -> any TerminalByteTeeLease { - FakeTerminalByteTeeLease() + surfaceID: UUID, + surfaceGeneration: UInt64 + ) -> TerminalByteTeeInstallation { + TerminalByteTeeInstallation( + callback: { _, _, _ in }, + userdata: Unmanaged.passUnretained(self).toOpaque(), + lease: FakeTerminalByteTeeLease() + ) } @MainActor diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 34d89fe40e37..d4c4159acde2 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -111,6 +111,7 @@ bool ghostty_surface_process_exited(void *surface) { void ghostty_surface_process_output(void) {} void ghostty_surface_quicklook_font(void) {} void ghostty_surface_read_screen_tail_vt(void) {} +void ghostty_surface_read_screen_tail_vt_with_output_sequence(void) {} void ghostty_surface_read_text(void) {} void ghostty_surface_refresh(void) {} void ghostty_surface_render_grid_json(void) {} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 621cc73b716f..e8a726b4c814 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -48,6 +48,7 @@ bool ghostty_surface_process_exited(void *surface); void ghostty_surface_process_output(void); void ghostty_surface_quicklook_font(void); void ghostty_surface_read_screen_tail_vt(void); +void ghostty_surface_read_screen_tail_vt_with_output_sequence(void); void ghostty_surface_read_text(void); void ghostty_surface_refresh(void); void ghostty_surface_render_grid_json(void); diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Package.swift b/Packages/macOS/CmuxTerminalRenderTransport/Package.swift new file mode 100644 index 000000000000..b8a47cddd5a3 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxTerminalRenderTransport", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxTerminalRenderTransport", + targets: ["CmuxTerminalRenderTransport"] + ), + ], + targets: [ + .target( + name: "TerminalRenderMachIPC", + linkerSettings: [ + .linkedFramework("IOSurface"), + .linkedFramework("Security"), + ] + ), + .target( + name: "CmuxTerminalRenderTransport", + dependencies: ["TerminalRenderMachIPC"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ], + linkerSettings: [ + .linkedFramework("IOSurface"), + .linkedFramework("Security"), + ] + ), + .testTarget( + name: "CmuxTerminalRenderTransportTests", + dependencies: ["CmuxTerminalRenderTransport"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift new file mode 100644 index 000000000000..708a84c4d846 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift @@ -0,0 +1,240 @@ +public import Foundation + +/// The versioned control-plane contract between the AppKit host and the +/// faceless Ghostty render worker. +public enum TerminalRenderProtocol { + /// Bump when either side can no longer decode the other side's messages. + public static let currentVersion: UInt32 = 1 +} + +/// A bootstrap endpoint for the separate Mach channel that transfers frames. +public struct TerminalRenderFrameEndpoint: Codable, Equatable, Sendable { + /// Random bootstrap service registered by the host process. + public let serviceName: String + + /// A random 128-bit value checked on every frame message. + public let authenticationToken: Data + + /// Creates an endpoint after validating its fixed-size authentication token. + public init(serviceName: String, authenticationToken: Data) throws { + guard !serviceName.isEmpty, + authenticationToken.count == TerminalRenderFrameTransport.authenticationTokenLength else { + throw TerminalRenderProtocolError.invalidFrameEndpoint + } + self.serviceName = serviceName + self.authenticationToken = authenticationToken + } +} + +/// A complete, already-finalized Ghostty configuration serialized in Ghostty's +/// own config-file format by the host. +public struct TerminalRenderConfigurationSnapshot: Codable, Equatable, Sendable { + /// Monotonic host revision used to reject stale configuration updates. + public let revision: UInt64 + + /// Effective Ghostty configuration text. + public let contents: String + + /// Creates a configuration snapshot. + public init(revision: UInt64, contents: String) { + self.revision = revision + self.contents = contents + } +} + +/// Everything the worker needs to create a visual-only Ghostty mirror. +public struct TerminalRenderSurfaceDescriptor: Codable, Equatable, Sendable { + /// Stable cmux surface identity. + public let id: UUID + + /// Monotonic native-surface lifetime identity. + public let generation: UInt64 + + /// Initial pixel width. + public let width: UInt32 + + /// Initial pixel height. + public let height: UInt32 + + /// Initial horizontal backing scale. + public let scaleX: Double + + /// Initial vertical backing scale. + public let scaleY: Double + + /// Surface-local runtime font size. Zero inherits the app configuration. + public let fontSize: Float + + /// Ghostty's surface-context raw value. + public let context: Int32 + + /// Creates a render-mirror descriptor. + public init( + id: UUID, + generation: UInt64, + width: UInt32, + height: UInt32, + scaleX: Double, + scaleY: Double, + fontSize: Float, + context: Int32 + ) { + self.id = id + self.generation = generation + self.width = width + self.height = height + self.scaleX = scaleX + self.scaleY = scaleY + self.fontSize = fontSize + self.context = context + } +} + +/// A visual state transition replayed against one worker-owned mirror surface. +public enum TerminalRenderSurfaceMutation: Codable, Equatable, Sendable { + /// Ordered raw PTY bytes, captured before the host parser consumes them. + case processOutput(sequence: UInt64, bytes: Data) + + /// Pixel geometry changed. + case resize(width: UInt32, height: UInt32) + + /// Backing scale changed. + case contentScale(x: Double, y: Double) + + /// First-responder focus changed. + case focus(Bool) + + /// Portal visibility changed. + case occlusion(Bool) + + /// Host light/dark scheme changed, using Ghostty's raw enum value. + case colorScheme(Int32) + + /// Worker GPU resources should be realized or reclaimed. + case rendererRealized(Bool) + + /// Request a damage-driven redraw through Ghostty's normal wakeup path. + case refresh + + /// IME marked text changed. + case preedit(text: String?, selectionStart: Int, selectionLength: Int) + + /// Pointer position changed, using Ghostty modifier bits. + case mousePosition(x: Double, y: Double, modifiers: UInt32) + + /// Pointer button changed, using Ghostty's raw state/button values. + case mouseButton(state: Int32, button: Int32, modifiers: UInt32) + + /// Scroll-wheel input changed. + case mouseScroll(deltaX: Double, deltaY: Double, modifiers: UInt32) + + /// Discard the mirror's selection. + case clearSelection + + /// Applies one Ghostty binding action that changes visual state, such as + /// viewport scrolling, copy mode, or a surface-local font adjustment. + case bindingAction(String) +} + +/// Host-to-worker control messages. The hot frame path never uses this pipe. +public enum TerminalRenderWorkerCommand: Codable, Equatable, Sendable { + /// Establishes protocol/config/frame transport before any surface exists. + case initialize( + protocolVersion: UInt32, + workerGeneration: UInt64, + frameEndpoint: TerminalRenderFrameEndpoint, + configuration: TerminalRenderConfigurationSnapshot + ) + + /// Atomically replaces the app configuration used by all mirror surfaces. + case replaceConfiguration(TerminalRenderConfigurationSnapshot) + + /// Creates or replaces one mirror generation. + case createSurface(TerminalRenderSurfaceDescriptor) + + /// Applies a mutation only if the generation is still current. + case mutateSurface(id: UUID, generation: UInt64, mutation: TerminalRenderSurfaceMutation) + + /// Replaces a crashed worker's mirror with an atomic host snapshot. The + /// next live output byte begins at `nextOutputSequence`. + case resynchronizeSurface( + descriptor: TerminalRenderSurfaceDescriptor, + nextOutputSequence: UInt64, + screenTailVT: Data + ) + + /// Destroys one mirror generation. + case destroySurface(id: UUID, generation: UInt64) + + /// Clean worker termination used by app shutdown and tests. + case shutdown +} + +/// Worker-to-host control messages. +public enum TerminalRenderWorkerEvent: Codable, Equatable, Sendable { + /// Worker accepted the protocol and connected its frame sender. + case initialized(protocolVersion: UInt32, workerGeneration: UInt64, processIdentifier: Int32) + + /// A surface generation is ready to consume ordered output. + case surfaceCreated(id: UUID, generation: UInt64) + + /// Worker finished freeing a surface generation. + case surfaceDestroyed(id: UUID, generation: UInt64) + + /// Highest contiguous output byte position applied by a mirror. + case outputApplied(id: UUID, generation: UInt64, nextSequence: UInt64) + + /// Recoverable protocol/runtime diagnostic. User-facing UI is intentionally + /// absent; supervision logs it and respawns when required. + case failure(String) +} + +/// Validation or encoding failures in the terminal render protocol. +public enum TerminalRenderProtocolError: Error, Equatable, Sendable { + case invalidFrameEndpoint + case encodingFailed + case decodingFailed +} + +/// Binary-property-list codec used only by the low-volume control pipe. +public enum TerminalRenderControlCodec { + /// Encodes a host command without JSON/base64 expansion of PTY bytes. + public static func encode(_ command: TerminalRenderWorkerCommand) throws -> Data { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + do { + return try encoder.encode(command) + } catch { + throw TerminalRenderProtocolError.encodingFailed + } + } + + /// Decodes a host command. + public static func decodeCommand(_ data: Data) throws -> TerminalRenderWorkerCommand { + do { + return try PropertyListDecoder().decode(TerminalRenderWorkerCommand.self, from: data) + } catch { + throw TerminalRenderProtocolError.decodingFailed + } + } + + /// Encodes a worker event. + public static func encode(_ event: TerminalRenderWorkerEvent) throws -> Data { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + do { + return try encoder.encode(event) + } catch { + throw TerminalRenderProtocolError.encodingFailed + } + } + + /// Decodes a worker event. + public static func decodeEvent(_ data: Data) throws -> TerminalRenderWorkerEvent { + do { + return try PropertyListDecoder().decode(TerminalRenderWorkerEvent.self, from: data) + } catch { + throw TerminalRenderProtocolError.decodingFailed + } + } +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderFrameTransport.swift b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderFrameTransport.swift new file mode 100644 index 000000000000..525ede130be4 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderFrameTransport.swift @@ -0,0 +1,274 @@ +internal import Darwin +public import Foundation +public import IOSurface +internal import Security +internal import TerminalRenderMachIPC + +/// Metadata attached to one remotely rendered IOSurface. +public struct TerminalRenderFrameMetadata: Equatable, Sendable { + /// Stable cmux surface identity. + public let surfaceID: UUID + + /// Host-assigned worker lifetime identity. + public let workerGeneration: UInt64 + + /// Native mirror lifetime identity. + public let surfaceGeneration: UInt64 + + /// Monotonic frame identity within the generation. + public let frameSequence: UInt64 + + /// Pixel width of the IOSurface. + public let width: UInt32 + + /// Pixel height of the IOSurface. + public let height: UInt32 + + /// Creates frame metadata. + public init( + surfaceID: UUID, + workerGeneration: UInt64, + surfaceGeneration: UInt64, + frameSequence: UInt64, + width: UInt32, + height: UInt32 + ) { + self.surfaceID = surfaceID + self.workerGeneration = workerGeneration + self.surfaceGeneration = surfaceGeneration + self.frameSequence = frameSequence + self.width = width + self.height = height + } +} + +/// A received IOSurface and its generation-fenced metadata. +/// +/// IOSurface is a process-safe kernel object. The wrapper is unchecked +/// Sendable so the receive thread can hand ownership to the main actor, which +/// is the only place cmux assigns it to a presentation layer. +public final class TerminalRenderFrame: @unchecked Sendable { + /// Frame identity and dimensions. + public let metadata: TerminalRenderFrameMetadata + + /// Retained IOSurface imported from the worker's Mach capability. + public let surface: IOSurfaceRef + + init(metadata: TerminalRenderFrameMetadata, surface: IOSurfaceRef) { + self.metadata = metadata + self.surface = surface + } +} + +/// Secure IOSurface frame transport between a render worker and its host. +public enum TerminalRenderFrameTransport { + /// Authentication token length required by the C/Mach wire format. + public static let authenticationTokenLength = Int(CMUX_TERMINAL_RENDER_TOKEN_LENGTH) + + /// Creates a cryptographically random token. + public static func makeAuthenticationToken() throws -> Data { + var bytes = [UInt8](repeating: 0, count: authenticationTokenLength) + let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard result == errSecSuccess else { + throw TerminalRenderFrameTransportError.randomTokenFailed(result) + } + return Data(bytes) + } +} + +/// Host-owned receive endpoint for IOSurface frame capabilities. +public final class TerminalRenderFrameReceiver: @unchecked Sendable { + /// Endpoint sent to the worker during control initialization. + public let endpoint: TerminalRenderFrameEndpoint + + private let receivePort: mach_port_t + private let stateLock = NSLock() + private var stopped = false + + /// Registers a unique endpoint in the host's bootstrap namespace. + public init(serviceName: String? = nil, authenticationToken: Data? = nil) throws { + let resolvedName = serviceName + ?? "dev.cmux.terminal-render.\(getpid()).\(UUID().uuidString)" + let resolvedToken = try authenticationToken + ?? TerminalRenderFrameTransport.makeAuthenticationToken() + self.endpoint = try TerminalRenderFrameEndpoint( + serviceName: resolvedName, + authenticationToken: resolvedToken + ) + + var port = mach_port_t(MACH_PORT_NULL) + let result = resolvedName.withCString { + cmux_terminal_render_receiver_create($0, &port) + } + guard result == KERN_SUCCESS else { + throw TerminalRenderFrameTransportError.receiverCreationFailed(result) + } + self.receivePort = port + } + + deinit { + stop() + } + + /// Starts one blocking receive thread. Frames are delivered in Mach queue + /// order; malformed messages are discarded without ending the endpoint. + public func start( + _ handler: @escaping @Sendable (TerminalRenderFrame) -> Void + ) -> Thread { + let thread = Thread { [weak self] in + self?.receiveLoop(handler) + } + thread.name = "cmux-terminal-frame-receiver" + thread.stackSize = 1 << 20 + thread.start() + return thread + } + + /// Destroys the receive right and wakes the receive thread. + public func stop() { + let shouldStop = stateLock.withLock { + guard !stopped else { return false } + stopped = true + return true + } + if shouldStop { + cmux_terminal_render_receiver_destroy(receivePort) + } + } + + private func receiveLoop( + _ handler: @escaping @Sendable (TerminalRenderFrame) -> Void + ) { + while !stateLock.withLock({ stopped }) { + do { + if let frame = try receiveOne(timeoutMilliseconds: 250) { + handler(frame) + } + } catch TerminalRenderFrameTransportError.receiveTimedOut { + continue + } catch TerminalRenderFrameTransportError.invalidMessage { + continue + } catch { + return + } + } + } + + /// Receives one frame. Exposed for deterministic transport tests. + public func receiveOne(timeoutMilliseconds: UInt32) throws -> TerminalRenderFrame? { + var metadata = cmux_terminal_render_frame_metadata_s() + var receivedSurface: Unmanaged? + let result: kern_return_t = endpoint.authenticationToken.withUnsafeBytes { token in + guard let tokenAddress = token.bindMemory(to: UInt8.self).baseAddress else { + return KERN_INVALID_ARGUMENT + } + return cmux_terminal_render_frame_receive( + receivePort, + timeoutMilliseconds, + tokenAddress, + &metadata, + &receivedSurface + ) + } + if result == MACH_RCV_TIMED_OUT { + throw TerminalRenderFrameTransportError.receiveTimedOut + } + if result == MIG_TYPE_ERROR { + throw TerminalRenderFrameTransportError.invalidMessage + } + guard result == KERN_SUCCESS, let receivedSurface else { + throw TerminalRenderFrameTransportError.receiveFailed(result) + } + let retainedSurface = receivedSurface.takeRetainedValue() + + let surfaceID = withUnsafeBytes(of: metadata.surface_id) { raw -> UUID in + let bytes = raw.bindMemory(to: UInt8.self) + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } + return TerminalRenderFrame( + metadata: TerminalRenderFrameMetadata( + surfaceID: surfaceID, + workerGeneration: metadata.worker_generation, + surfaceGeneration: metadata.surface_generation, + frameSequence: metadata.frame_sequence, + width: metadata.width, + height: metadata.height + ), + surface: retainedSurface + ) + } +} + +/// Worker-owned sender endpoint. Sending never waits for host queue capacity. +public final class TerminalRenderFrameSender: @unchecked Sendable { + private let sendPort: mach_port_t + private let authenticationToken: Data + + /// Connects to a host endpoint inherited through the bootstrap namespace. + public init(endpoint: TerminalRenderFrameEndpoint) throws { + var port = mach_port_t(MACH_PORT_NULL) + let result = endpoint.serviceName.withCString { + cmux_terminal_render_sender_connect($0, &port) + } + guard result == KERN_SUCCESS else { + throw TerminalRenderFrameTransportError.senderConnectionFailed(result) + } + self.sendPort = port + self.authenticationToken = endpoint.authenticationToken + } + + deinit { + cmux_terminal_render_sender_destroy(sendPort) + } + + /// Transfers one IOSurface. Returns false when the host queue is full so + /// the renderer can discard an obsolete frame without blocking. + @discardableResult + public func send( + surface: IOSurfaceRef, + metadata swiftMetadata: TerminalRenderFrameMetadata + ) throws -> Bool { + var metadata = cmux_terminal_render_frame_metadata_s() + _ = authenticationToken.copyBytes( + to: withUnsafeMutableBytes(of: &metadata.authentication_token) { + $0.bindMemory(to: UInt8.self) + } + ) + var uuid = swiftMetadata.surfaceID.uuid + withUnsafeBytes(of: &uuid) { source in + withUnsafeMutableBytes(of: &metadata.surface_id) { destination in + destination.copyBytes(from: source) + } + } + metadata.worker_generation = swiftMetadata.workerGeneration + metadata.surface_generation = swiftMetadata.surfaceGeneration + metadata.frame_sequence = swiftMetadata.frameSequence + metadata.width = swiftMetadata.width + metadata.height = swiftMetadata.height + + let result = cmux_terminal_render_frame_send(sendPort, surface, &metadata) + if result == MACH_SEND_TIMED_OUT { + return false + } + guard result == KERN_SUCCESS else { + throw TerminalRenderFrameTransportError.sendFailed(result) + } + return true + } +} + +/// Mach/IOSurface transport failures. +public enum TerminalRenderFrameTransportError: Error, Equatable, Sendable { + case randomTokenFailed(Int32) + case receiverCreationFailed(kern_return_t) + case senderConnectionFailed(kern_return_t) + case sendFailed(kern_return_t) + case receiveFailed(kern_return_t) + case receiveTimedOut + case invalidMessage +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderMessageChannel.swift b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderMessageChannel.swift new file mode 100644 index 000000000000..eee9bb73048c --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderMessageChannel.swift @@ -0,0 +1,114 @@ +#if canImport(Darwin) +internal import Darwin +#endif +public import Foundation + +/// A bounded, length-prefixed control channel over two POSIX descriptors. +/// +/// Frame pixels and IOSurface capabilities use the Mach channel. This pipe +/// carries only versioned commands, acknowledgements, and diagnostics. +public struct TerminalRenderMessageChannel: Sendable { + private let readDescriptor: Int32 + private let writeDescriptor: Int32 + + /// The largest accepted control payload. + public static let maximumFrameLength = 16 * 1024 * 1024 + + /// Creates a channel and disables SIGPIPE on its writer. + public init( + readDescriptor: Int32, + writeDescriptor: Int32, + nonblockingWrites: Bool = false + ) { + self.readDescriptor = readDescriptor + self.writeDescriptor = writeDescriptor + if writeDescriptor >= 0 { + _ = fcntl(writeDescriptor, F_SETNOSIGPIPE, 1) + if nonblockingWrites { + let flags = fcntl(writeDescriptor, F_GETFL) + if flags >= 0 { + _ = fcntl(writeDescriptor, F_SETFL, flags | O_NONBLOCK) + } + } + } + } + + /// Writes one complete control payload. + public func send(_ payload: Data) throws { + guard payload.count <= Self.maximumFrameLength else { + throw TerminalRenderChannelError.frameTooLarge + } + let count = UInt32(payload.count) + let header = Data([ + UInt8((count >> 24) & 0xff), + UInt8((count >> 16) & 0xff), + UInt8((count >> 8) & 0xff), + UInt8(count & 0xff), + ]) + try writeAll(header) + if !payload.isEmpty { + try writeAll(payload) + } + } + + /// Reads one complete control payload, or nil after EOF/protocol failure. + public func receive() -> Data? { + guard let header = readExactly(4) else { return nil } + let count = (UInt32(header[0]) << 24) + | (UInt32(header[1]) << 16) + | (UInt32(header[2]) << 8) + | UInt32(header[3]) + guard count <= UInt32(Self.maximumFrameLength) else { return nil } + return count == 0 ? Data() : readExactly(Int(count)) + } + + private func writeAll(_ data: Data) throws { + try data.withUnsafeBytes { raw in + guard let baseAddress = raw.baseAddress else { return } + var offset = 0 + while offset < raw.count { + let written = Darwin.write( + writeDescriptor, + baseAddress.advanced(by: offset), + raw.count - offset + ) + if written > 0 { + offset += written + } else if written == -1, errno == EINTR { + continue + } else { + throw TerminalRenderChannelError.writeFailed + } + } + } + } + + private func readExactly(_ count: Int) -> Data? { + var bytes = [UInt8](repeating: 0, count: count) + var offset = 0 + while offset < count { + let amount = bytes.withUnsafeMutableBytes { raw -> Int in + guard let baseAddress = raw.baseAddress else { return -1 } + return Darwin.read( + readDescriptor, + baseAddress.advanced(by: offset), + count - offset + ) + } + if amount > 0 { + offset += amount + } else if amount == -1, errno == EINTR { + continue + } else { + return nil + } + } + return Data(bytes) + } +} + +/// Terminal render control-channel failures. +public enum TerminalRenderChannelError: Error, Equatable, Sendable { + case frameTooLarge + case writeFailed +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/TerminalRenderMachIPC.c b/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/TerminalRenderMachIPC.c new file mode 100644 index 000000000000..1eb825ab3ff0 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/TerminalRenderMachIPC.c @@ -0,0 +1,195 @@ +#include "TerminalRenderMachIPC.h" + +#include +#include +#include + +enum { + CMUX_TERMINAL_RENDER_FRAME_MESSAGE_ID = 0x434D5852, +}; + +typedef struct { + mach_msg_header_t header; + mach_msg_body_t body; + mach_msg_port_descriptor_t surface_port; + cmux_terminal_render_frame_metadata_s metadata; +} cmux_terminal_render_frame_message_s; + +typedef union { + cmux_terminal_render_frame_message_s message; + uint8_t bytes[ + sizeof(cmux_terminal_render_frame_message_s) + + sizeof(mach_msg_max_trailer_t) + ]; +} cmux_terminal_render_frame_receive_buffer_u; + +kern_return_t cmux_terminal_render_receiver_create( + const char *service_name, + mach_port_t *receiver +) { + if (service_name == NULL || receiver == NULL) { + return KERN_INVALID_ARGUMENT; + } + + mach_port_t port = MACH_PORT_NULL; + kern_return_t result = mach_port_allocate( + mach_task_self(), + MACH_PORT_RIGHT_RECEIVE, + &port + ); + if (result != KERN_SUCCESS) { + return result; + } + + result = mach_port_insert_right( + mach_task_self(), + port, + port, + MACH_MSG_TYPE_MAKE_SEND + ); + if (result != KERN_SUCCESS) { + mach_port_destruct(mach_task_self(), port, 0, 0); + return result; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + result = bootstrap_register(bootstrap_port, (char *)service_name, port); +#pragma clang diagnostic pop + if (result != KERN_SUCCESS) { + mach_port_destruct(mach_task_self(), port, -1, 0); + return result; + } + + *receiver = port; + return KERN_SUCCESS; +} + +void cmux_terminal_render_receiver_destroy(mach_port_t receiver) { + if (MACH_PORT_VALID(receiver)) { + mach_port_destruct(mach_task_self(), receiver, -1, 0); + } +} + +kern_return_t cmux_terminal_render_sender_connect( + const char *service_name, + mach_port_t *sender +) { + if (service_name == NULL || sender == NULL) { + return KERN_INVALID_ARGUMENT; + } + return bootstrap_look_up(bootstrap_port, service_name, sender); +} + +void cmux_terminal_render_sender_destroy(mach_port_t sender) { + if (MACH_PORT_VALID(sender)) { + mach_port_deallocate(mach_task_self(), sender); + } +} + +kern_return_t cmux_terminal_render_frame_send( + mach_port_t sender, + IOSurfaceRef surface, + const cmux_terminal_render_frame_metadata_s *metadata +) { + if (!MACH_PORT_VALID(sender) || surface == NULL || metadata == NULL) { + return KERN_INVALID_ARGUMENT; + } + + mach_port_t surface_port = IOSurfaceCreateMachPort(surface); + if (!MACH_PORT_VALID(surface_port)) { + return KERN_FAILURE; + } + + cmux_terminal_render_frame_message_s message; + memset(&message, 0, sizeof(message)); + message.header.msgh_bits = MACH_MSGH_BITS_COMPLEX | + MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0); + message.header.msgh_size = (mach_msg_size_t)sizeof(message); + message.header.msgh_remote_port = sender; + message.header.msgh_local_port = MACH_PORT_NULL; + message.header.msgh_id = CMUX_TERMINAL_RENDER_FRAME_MESSAGE_ID; + message.body.msgh_descriptor_count = 1; + message.surface_port.name = surface_port; + message.surface_port.disposition = MACH_MSG_TYPE_MOVE_SEND; + message.surface_port.type = MACH_MSG_PORT_DESCRIPTOR; + message.metadata = *metadata; + + kern_return_t result = mach_msg( + &message.header, + MACH_SEND_MSG | MACH_SEND_TIMEOUT, + message.header.msgh_size, + 0, + MACH_PORT_NULL, + 0, + MACH_PORT_NULL + ); + if (result != KERN_SUCCESS) { + mach_port_deallocate(mach_task_self(), surface_port); + } + return result; +} + +kern_return_t cmux_terminal_render_frame_receive( + mach_port_t receiver, + uint32_t timeout_ms, + const uint8_t expected_authentication_token[CMUX_TERMINAL_RENDER_TOKEN_LENGTH], + cmux_terminal_render_frame_metadata_s *metadata, + IOSurfaceRef *surface +) { + if (!MACH_PORT_VALID(receiver) || expected_authentication_token == NULL || + metadata == NULL || surface == NULL) { + return KERN_INVALID_ARGUMENT; + } + *surface = NULL; + + cmux_terminal_render_frame_receive_buffer_u buffer; + memset(&buffer, 0, sizeof(buffer)); + cmux_terminal_render_frame_message_s *message = &buffer.message; + message->header.msgh_local_port = receiver; + message->header.msgh_size = (mach_msg_size_t)sizeof(buffer); + + kern_return_t result = mach_msg( + &message->header, + MACH_RCV_MSG | MACH_RCV_TIMEOUT, + 0, + (mach_msg_size_t)sizeof(buffer), + receiver, + timeout_ms, + MACH_PORT_NULL + ); + if (result != KERN_SUCCESS) { + return result; + } + + bool valid = message->header.msgh_id == CMUX_TERMINAL_RENDER_FRAME_MESSAGE_ID && + (message->header.msgh_bits & MACH_MSGH_BITS_COMPLEX) != 0 && + message->body.msgh_descriptor_count == 1 && + message->surface_port.type == MACH_MSG_PORT_DESCRIPTOR && + MACH_PORT_VALID(message->surface_port.name) && + memcmp( + message->metadata.authentication_token, + expected_authentication_token, + CMUX_TERMINAL_RENDER_TOKEN_LENGTH + ) == 0; + if (!valid) { + if (message->body.msgh_descriptor_count == 1 && + message->surface_port.type == MACH_MSG_PORT_DESCRIPTOR && + MACH_PORT_VALID(message->surface_port.name)) { + mach_port_deallocate(mach_task_self(), message->surface_port.name); + } + return MIG_TYPE_ERROR; + } + + IOSurfaceRef received_surface = IOSurfaceLookupFromMachPort( + message->surface_port.name + ); + mach_port_deallocate(mach_task_self(), message->surface_port.name); + if (received_surface == NULL) { + return KERN_FAILURE; + } + + *metadata = message->metadata; + *surface = received_surface; + return KERN_SUCCESS; +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/include/TerminalRenderMachIPC.h b/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/include/TerminalRenderMachIPC.h new file mode 100644 index 000000000000..18b979ab478b --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/TerminalRenderMachIPC/include/TerminalRenderMachIPC.h @@ -0,0 +1,76 @@ +#ifndef CMUX_TERMINAL_RENDER_MACH_IPC_H +#define CMUX_TERMINAL_RENDER_MACH_IPC_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +CF_ASSUME_NONNULL_BEGIN + +enum { + CMUX_TERMINAL_RENDER_TOKEN_LENGTH = 16, + CMUX_TERMINAL_RENDER_SURFACE_ID_LENGTH = 16, +}; + +typedef struct { + uint8_t authentication_token[CMUX_TERMINAL_RENDER_TOKEN_LENGTH]; + uint8_t surface_id[CMUX_TERMINAL_RENDER_SURFACE_ID_LENGTH]; + uint64_t worker_generation; + uint64_t surface_generation; + uint64_t frame_sequence; + uint32_t width; + uint32_t height; +} cmux_terminal_render_frame_metadata_s; + +/// Creates a receive right and registers a random, caller-owned bootstrap name. +/// The receive right is destroyed by cmux_terminal_render_receiver_destroy. +kern_return_t cmux_terminal_render_receiver_create( + const char *service_name, + mach_port_t *receiver +); + +/// Destroys a receive right, waking any blocked receiver. +void cmux_terminal_render_receiver_destroy(mach_port_t receiver); + +/// Looks up the host's registered frame endpoint from the worker process. +kern_return_t cmux_terminal_render_sender_connect( + const char *service_name, + mach_port_t *sender +); + +/// Releases a sender right returned by cmux_terminal_render_sender_connect. +void cmux_terminal_render_sender_destroy(mach_port_t sender); + +/// Transfers a secure IOSurface Mach port with bounded, nonblocking delivery. +/// Returns KERN_SUCCESS when the frame was accepted. A full host queue returns +/// MACH_SEND_TIMED_OUT so the renderer can drop the stale frame without waiting. +kern_return_t cmux_terminal_render_frame_send( + mach_port_t sender, + IOSurfaceRef surface, + const cmux_terminal_render_frame_metadata_s *metadata +); + +/// Receives one frame and recreates its IOSurface in the host task. +/// The returned IOSurface follows the Create Rule. MACH_RCV_TIMED_OUT is a +/// normal idle result. Invalid/authentication-mismatched messages are reported +/// as MIG_TYPE_ERROR and their transferred rights are released. +kern_return_t cmux_terminal_render_frame_receive( + mach_port_t receiver, + uint32_t timeout_ms, + const uint8_t * _Nonnull expected_authentication_token, + cmux_terminal_render_frame_metadata_s *metadata, + IOSurfaceRef _Nullable * _Nonnull surface +); + +CF_ASSUME_NONNULL_END + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift new file mode 100644 index 000000000000..6e03e8a15ae2 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CmuxTerminalRenderTransport + +@Suite struct TerminalRenderControlProtocolTests { + @Test func commandsRoundTripThroughBinaryCodec() throws { + let endpoint = try TerminalRenderFrameEndpoint( + serviceName: "dev.cmux.test.endpoint", + authenticationToken: Data(repeating: 0xA5, count: 16) + ) + let configuration = TerminalRenderConfigurationSnapshot( + revision: 7, + contents: "font-size = 14\n" + ) + let id = UUID(uuidString: "184DCEB4-F88C-4A9D-89F4-03087E606B8B")! + let descriptor = TerminalRenderSurfaceDescriptor( + id: id, + generation: 3, + width: 1_600, + height: 900, + scaleX: 2, + scaleY: 2, + fontSize: 15, + context: 2 + ) + let mutations: [TerminalRenderSurfaceMutation] = [ + .processOutput(sequence: 41, bytes: Data([0, 1, 2, 0xff])), + .resize(width: 900, height: 700), + .contentScale(x: 2, y: 2), + .focus(true), + .occlusion(false), + .colorScheme(1), + .rendererRealized(false), + .refresh, + .preedit(text: "かな", selectionStart: 0, selectionLength: 1), + .mousePosition(x: 12.5, y: 9.25, modifiers: 3), + .mouseButton(state: 1, button: 0, modifiers: 4), + .mouseScroll(deltaX: 1.5, deltaY: -2.5, modifiers: 8), + .clearSelection, + .bindingAction("scroll_page_up"), + ] + let commands: [TerminalRenderWorkerCommand] = [ + .initialize( + protocolVersion: TerminalRenderProtocol.currentVersion, + workerGeneration: 12, + frameEndpoint: endpoint, + configuration: configuration + ), + .replaceConfiguration(configuration), + .createSurface(descriptor), + .resynchronizeSurface( + descriptor: descriptor, + nextOutputSequence: 99, + screenTailVT: Data("\u{1b}[2Jrestored".utf8) + ), + .destroySurface(id: id, generation: 3), + .shutdown, + ] + mutations.map { + .mutateSurface(id: id, generation: 3, mutation: $0) + } + + for command in commands { + let encoded = try TerminalRenderControlCodec.encode(command) + #expect(try TerminalRenderControlCodec.decodeCommand(encoded) == command) + } + } + + @Test func eventsRoundTripThroughBinaryCodec() throws { + let id = UUID() + let events: [TerminalRenderWorkerEvent] = [ + .initialized(protocolVersion: 1, workerGeneration: 12, processIdentifier: 123), + .surfaceCreated(id: id, generation: 9), + .surfaceDestroyed(id: id, generation: 9), + .outputApplied(id: id, generation: 9, nextSequence: 1_024), + .failure("bad command"), + ] + + for event in events { + let encoded = try TerminalRenderControlCodec.encode(event) + #expect(try TerminalRenderControlCodec.decodeEvent(encoded) == event) + } + } + + @Test func endpointRejectsWrongTokenSize() { + #expect(throws: TerminalRenderProtocolError.invalidFrameEndpoint) { + try TerminalRenderFrameEndpoint( + serviceName: "dev.cmux.test.endpoint", + authenticationToken: Data(repeating: 0, count: 15) + ) + } + } +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderFrameTransportTests.swift b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderFrameTransportTests.swift new file mode 100644 index 000000000000..f7387e87b3c2 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderFrameTransportTests.swift @@ -0,0 +1,67 @@ +import CoreFoundation +import Foundation +import IOSurface +import Testing +@testable import CmuxTerminalRenderTransport + +@Suite(.serialized) struct TerminalRenderFrameTransportTests { + @Test func securelyTransfersIOSurfaceAndMetadata() throws { + let token = Data((0..<16).map(UInt8.init)) + let receiver = try TerminalRenderFrameReceiver( + authenticationToken: token + ) + let sender = try TerminalRenderFrameSender(endpoint: receiver.endpoint) + let surface = makeSurface(width: 32, height: 24) + let metadata = TerminalRenderFrameMetadata( + surfaceID: UUID(uuidString: "184DCEB4-F88C-4A9D-89F4-03087E606B8B")!, + workerGeneration: 7, + surfaceGeneration: 4, + frameSequence: 19, + width: 32, + height: 24 + ) + + #expect(try sender.send(surface: surface, metadata: metadata)) + let received = try receiver.receiveOne(timeoutMilliseconds: 1_000) + #expect(received?.metadata == metadata) + #expect(received.map { IOSurfaceGetWidth($0.surface) } == 32) + #expect(received.map { IOSurfaceGetHeight($0.surface) } == 24) + } + + @Test func rejectsFrameWithWrongAuthenticationToken() throws { + let receiver = try TerminalRenderFrameReceiver( + authenticationToken: Data(repeating: 0x11, count: 16) + ) + let wrongEndpoint = try TerminalRenderFrameEndpoint( + serviceName: receiver.endpoint.serviceName, + authenticationToken: Data(repeating: 0x22, count: 16) + ) + let sender = try TerminalRenderFrameSender(endpoint: wrongEndpoint) + let surface = makeSurface(width: 4, height: 4) + let metadata = TerminalRenderFrameMetadata( + surfaceID: UUID(), + workerGeneration: 8, + surfaceGeneration: 1, + frameSequence: 1, + width: 4, + height: 4 + ) + + #expect(try sender.send(surface: surface, metadata: metadata)) + #expect(throws: TerminalRenderFrameTransportError.invalidMessage) { + try receiver.receiveOne(timeoutMilliseconds: 1_000) + } + } + + private func makeSurface(width: Int, height: Int) -> IOSurfaceRef { + let bytesPerRow = width * 4 + let properties: [CFString: Any] = [ + kIOSurfaceWidth: width, + kIOSurfaceHeight: height, + kIOSurfaceBytesPerElement: 4, + kIOSurfaceBytesPerRow: bytesPerRow, + kIOSurfaceAllocSize: bytesPerRow * height, + ] + return IOSurfaceCreate(properties as CFDictionary)! + } +} diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderMessageChannelTests.swift b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderMessageChannelTests.swift new file mode 100644 index 000000000000..ab0028816f68 --- /dev/null +++ b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderMessageChannelTests.swift @@ -0,0 +1,42 @@ +import Darwin +import Foundation +import Testing +@testable import CmuxTerminalRenderTransport + +@Suite struct TerminalRenderMessageChannelTests { + @Test func preservesBinaryPayloadAndEmptyFrame() throws { + var descriptors = [Int32](repeating: -1, count: 2) + #expect(pipe(&descriptors) == 0) + defer { + close(descriptors[0]) + close(descriptors[1]) + } + let channel = TerminalRenderMessageChannel( + readDescriptor: descriptors[0], + writeDescriptor: descriptors[1] + ) + + let payload = Data([0, 1, 2, 3, 0xff]) + try channel.send(payload) + #expect(channel.receive() == payload) + try channel.send(Data()) + #expect(channel.receive() == Data()) + } + + @Test func rejectsOversizedOutboundFrame() throws { + var descriptors = [Int32](repeating: -1, count: 2) + #expect(pipe(&descriptors) == 0) + defer { + close(descriptors[0]) + close(descriptors[1]) + } + let channel = TerminalRenderMessageChannel( + readDescriptor: descriptors[0], + writeDescriptor: descriptors[1] + ) + + #expect(throws: TerminalRenderChannelError.frameTooLarge) { + try channel.send(Data(count: TerminalRenderMessageChannel.maximumFrameLength + 1)) + } + } +} diff --git a/RenderWorker/GhosttyRenderWorkerMain.swift b/RenderWorker/GhosttyRenderWorkerMain.swift new file mode 100644 index 000000000000..503f9f16bfde --- /dev/null +++ b/RenderWorker/GhosttyRenderWorkerMain.swift @@ -0,0 +1,8 @@ +import CmuxGhosttyRenderWorker + +@main +enum CmuxGhosttyRenderWorkerMain { + static func main() { + runGhosttyRenderWorker() + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d4854218d79c..93aefc0bb786 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4,6 +4,7 @@ import CmuxTerminal import CmuxFoundation import CmuxPanes import CmuxTerminalCore +import CmuxTerminalRenderTransport import CmuxSettings import CmuxWorkspaces import CmuxTestSupport @@ -297,6 +298,55 @@ extension GhosttySurfaceCallbackContext { extension GhosttyNSView: TerminalSurfaceNativeViewing {} extension GhosttySurfaceScrollView: TerminalSurfacePaneHosting {} +extension GhosttyNSView { + func configureRemoteRenderer( + surfaceID: UUID, + surfaceGeneration: UInt64, + width: UInt32, + height: UInt32 + ) { + let pixelSize = GhosttyRenderPixelSize(width: width, height: height) + if let remoteLayer = layer as? GhosttyRemoteIOSurfaceLayer, + remoteLayer.surfaceID == surfaceID { + remoteLayer.updateSurfaceGeneration(surfaceGeneration) + remoteLayer.updateExpectedPixelSize(pixelSize) + return + } + + let remoteLayer = GhosttyRemoteIOSurfaceLayer( + surfaceID: surfaceID, + workerGeneration: 0, + surfaceGeneration: surfaceGeneration, + expectedPixelSize: pixelSize, + backingScaleFactor: window?.backingScaleFactor ?? 1 + ) + remoteLayer.frame = bounds + remoteLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + remoteLayer.masksToBounds = true + remoteLayer.isOpaque = false + layer = remoteLayer + } + + func updateRemoteRendererWorkerGeneration(_ generation: UInt64) { + (layer as? GhosttyRemoteIOSurfaceLayer)?.updateWorkerGeneration(generation) + } + + func invalidateRemoteRendererWorkerGeneration(_ generation: UInt64) { + (layer as? GhosttyRemoteIOSurfaceLayer)?.invalidateWorkerGeneration(generation) + } + + func updateRemoteRendererExpectedSize(width: UInt32, height: UInt32) { + (layer as? GhosttyRemoteIOSurfaceLayer)?.updateExpectedPixelSize( + GhosttyRenderPixelSize(width: width, height: height) + ) + } + + @discardableResult + func presentRemoteRendererFrame(_ frame: TerminalRenderFrame) -> Bool { + (layer as? GhosttyRemoteIOSurfaceLayer)?.present(frame) ?? false + } +} + extension TerminalSurface { /// Concrete-typed convenience over ``TerminalSurface/paneHost`` for app /// callers. The pane host is always the app's `GhosttySurfaceScrollView`: @@ -399,7 +449,8 @@ class GhosttyApp { engine: GhosttyApp.shared, viewProvider: TerminalSurfaceViewFactory(), spawnPolicy: TerminalSurfaceSpawnPolicyBridge(), - byteTee: TerminalOutputByteTeeBridge(), + byteTee: TerminalOutputByteTeeBridge(renderWorker: GhosttyRenderRuntimeBridge.shared), + renderWorker: GhosttyRenderRuntimeBridge.shared, rendererRealization: RendererRealizationController.shared, hibernationRecorder: TerminalAgentHibernationRecorder(), runtimeTeardown: GhosttyApp.terminalSurfaceRuntimeTeardown, @@ -1035,6 +1086,9 @@ class GhosttyApp { } // Notify observers that a usable config is available (initial load). + if let config { + GhosttyRenderRuntimeBridge.shared.updateConfiguration(config) + } synchronizeGhosttyRuntimeColorScheme(effectiveTerminalColorSchemePreference, source: "initialize") lastAppearanceColorScheme = initialColorScheme GhosttyConfig.invalidateLoadCache() @@ -1795,6 +1849,7 @@ class GhosttyApp { let effectiveReloadColorScheme = effectiveTerminalColorSchemePreference synchronizeGhosttyRuntimeColorScheme(effectiveReloadColorScheme, source: "reloadConfiguration:\(source):resolved") ghostty_app_update_config(app, config) + GhosttyRenderRuntimeBridge.shared.updateConfiguration(config) lastAppearanceColorScheme = reloadColorScheme GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) @@ -1838,6 +1893,7 @@ class GhosttyApp { ghostty_config_free(oldConfig) } config = newConfig + GhosttyRenderRuntimeBridge.shared.updateConfiguration(newConfig) lastAppearanceColorScheme = reloadColorScheme NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload( @@ -4146,6 +4202,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } lastDrawableSize = drawablePixelSize } + } else if let remoteLayer = layer as? GhosttyRemoteIOSurfaceLayer { + deferredSurfaceSizeNonMetalRetryCount = 0 + needsSurfaceSizeRetryAfterMetalLayerRealizes = false + remoteLayer.updateBackingScaleFactor(layerScale) + remoteLayer.updateExpectedPixelSize(GhosttyRenderPixelSize( + width: UInt32(clamping: Int(drawablePixelSize.width)), + height: UInt32(clamping: Int(drawablePixelSize.height)) + )) + lastDrawableSize = drawablePixelSize } else if deferredSurfaceSizeNonMetalRetryCount < Self.maxDeferredSurfaceSizeNonMetalRetryCount, scheduleDeferredSurfaceSizeRetryIfNeeded() { needsSurfaceSizeRetryAfterMetalLayerRealizes = true @@ -4205,6 +4270,57 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale) } + private func mirroredMousePosition( + _ surface: ghostty_surface_t, + _ x: Double, + _ y: Double, + _ modifiers: ghostty_input_mods_e + ) { + ghostty_surface_mouse_pos(surface, x, y, modifiers) + terminalSurface?.mirrorRenderMousePosition( + x: x, + y: y, + modifiers: modifiers.rawValue + ) + } + + @discardableResult + private func mirroredMouseButton( + _ surface: ghostty_surface_t, + _ state: ghostty_input_mouse_state_e, + _ button: ghostty_input_mouse_button_e, + _ modifiers: ghostty_input_mods_e + ) -> Bool { + let handled = ghostty_surface_mouse_button(surface, state, button, modifiers) + terminalSurface?.mirrorRenderMouseButton( + state: Int32(truncatingIfNeeded: state.rawValue), + button: Int32(truncatingIfNeeded: button.rawValue), + modifiers: modifiers.rawValue + ) + return handled + } + + private func mirroredMouseScroll( + _ surface: ghostty_surface_t, + _ deltaX: Double, + _ deltaY: Double, + _ modifiers: ghostty_input_scroll_mods_t + ) { + ghostty_surface_mouse_scroll(surface, deltaX, deltaY, modifiers) + terminalSurface?.mirrorRenderMouseScroll( + deltaX: deltaX, + deltaY: deltaY, + modifiers: UInt32(bitPattern: Int32(modifiers)) + ) + } + + @discardableResult + private func clearMirroredSelection(_ surface: ghostty_surface_t) -> Bool { + let cleared = GhosttyRuntimeCInterop.clearSelection(surface) + terminalSurface?.clearRenderSelection() + return cleared + } + // Convenience accessor for the ghostty surface private var surface: ghostty_surface_t? { terminalSurface?.surface @@ -4229,6 +4345,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } ghostty_surface_set_color_scheme(surface, scheme) + terminalSurface?.setRenderColorScheme(Int32(truncatingIfNeeded: scheme.rawValue)) appliedColorScheme = scheme if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" @@ -4277,10 +4394,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } func performBindingAction(_ action: String) -> Bool { - guard let surface = surface else { return false } - return action.withCString { cString in - ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) - } + terminalSurface?.performBindingAction(action) ?? false } @discardableResult @@ -4288,7 +4402,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard surface != nil else { return false } setKeyboardCopyModeActive(!keyboardCopyModeActive) if !keyboardCopyModeActive, let surface { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) } return true } @@ -4302,7 +4416,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { clearKeyboardCopyModeViewportJumpCursorSync() keyboardCopyModeActive = active if active, let surface { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) keyboardCopyModeCursor = keyboardCopyModeInitialCursor(surface: surface) syncKeyboardCopyModeCursorOverlay(surface: surface) } else { @@ -4653,22 +4767,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { rectMaxX: Double(rect.maxX), boundsWidth: Double(bounds.width) ) else { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) return false } let mods = GHOSTTY_MODS_NONE - _ = GhosttyRuntimeCInterop.clearSelection(surface) - ghostty_surface_mouse_pos(surface, xRange.startX, Double(y), mods) - guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) + mirroredMousePosition(surface, xRange.startX, Double(y), mods) + guard mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { + _ = clearMirroredSelection(surface) return false } - ghostty_surface_mouse_pos(surface, xRange.endX, Double(y), mods) + mirroredMousePosition(surface, xRange.endX, Double(y), mods) let selectedCursorCell = ghostty_surface_has_selection(surface) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) guard selectedCursorCell else { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) return false } return true @@ -4684,7 +4798,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let rows = metrics.rows let targetRow = max(0, min(rows - 1, startRow)) let endRow = min(rows - 1, targetRow + clampedCount - 1) - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) let yMax = max(bounds.height - 1, 0) @@ -4701,14 +4815,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let endX = min(metrics.xInset + (CGFloat(metrics.columns) * metrics.cellWidth) - 0.5, xMax) let mods = GHOSTTY_MODS_NONE - ghostty_surface_mouse_pos(surface, Double(startX), Double(startY), mods) - guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { + mirroredMousePosition(surface, Double(startX), Double(startY), mods) + guard mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { return false } defer { - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) } - ghostty_surface_mouse_pos(surface, Double(endX), Double(endY), mods) + mirroredMousePosition(surface, Double(endX), Double(endY), mods) return ghostty_surface_has_selection(surface) } private func keyboardCopyModePendingVisualLineScrollOffset() -> UInt64? { @@ -4752,7 +4866,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyboardCopyModeVisualActive = false keyboardCopyModeVisualLineSelection = nil keyboardCopyModeVisualLineRuntimeSelectionSynced = false - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) } private func syncKeyboardCopyModeVisualLineRuntimeSelection(surface: ghostty_surface_t) -> Bool { @@ -4761,7 +4875,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let scrollOffset = keyboardCopyModePendingVisualLineScrollOffset() ?? scrollbar?.offset ?? 0 guard let visibleRows = selection.visibleIntersection(scrollOffset: scrollOffset, viewportRows: metrics.rows) else { keyboardCopyModeVisualLineRuntimeSelectionSynced = false - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) return true } @@ -4769,7 +4883,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let lineCount = Int(clamping: visibleRows.upperBound - visibleRows.lowerBound + 1) let selected = selectKeyboardCopyModeViewportLines(surface: surface, startRow: startRow, lineCount: lineCount) keyboardCopyModeVisualLineRuntimeSelectionSynced = selected - if !selected { _ = GhosttyRuntimeCInterop.clearSelection(surface) } + if !selected { _ = clearMirroredSelection(surface) } return selected } @@ -4993,7 +5107,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { switch action { case .exit: - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) setKeyboardCopyModeActive(false) case .startSelection: if selectKeyboardCopyModeCursorCell(surface: surface) { @@ -5008,7 +5122,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { clearKeyboardCopyModeVisualLineSelection(surface: surface) syncKeyboardCopyModeCursorOverlay(surface: surface) case .copyAndExit: - if copyKeyboardCopyModeSelectionToClipboard(surface: surface) { _ = GhosttyRuntimeCInterop.clearSelection(surface); setKeyboardCopyModeActive(false) } + if copyKeyboardCopyModeSelectionToClipboard(surface: surface) { _ = clearMirroredSelection(surface); setKeyboardCopyModeActive(false) } case .copyLineAndExit: let startRow = currentKeyboardCopyModeViewportRow(surface: surface) if copyCurrentViewportLinesToClipboard( @@ -5016,7 +5130,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { startRow: startRow, lineCount: count ) { - _ = GhosttyRuntimeCInterop.clearSelection(surface) + _ = clearMirroredSelection(surface) setKeyboardCopyModeActive(false) } case let .scrollLines(delta): @@ -6190,7 +6304,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } #endif - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, point.x, bounds.height - point.y, @@ -6486,9 +6600,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Only update mouse position on the first click to prevent unwanted cursor // movement during double-click selection (issue #1698) if event.clickCount == 1 { - ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) + mirroredMousePosition(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) hasPendingLeftMouseRelease = true } @@ -6504,7 +6618,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard hasPendingLeftMouseRelease, let surface else { return false } let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) + mirroredMousePosition(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) return true } @@ -6514,7 +6628,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { hasPendingLeftMouseRelease = false guard let surface else { return false } let point = convert(event.locationInWindow, from: nil) - let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) + let consumed = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: consumed) return true } @@ -6868,7 +6982,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Refresh ghostty's cached mouse position so quicklook_word reads // up-to-date coordinates (mouseDown skips pos update on double-click). if let resolvedPoint { - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, resolvedPoint.x, bounds.height - resolvedPoint.y, @@ -6979,8 +7093,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mods = GHOSTTY_MODS_NONE window?.makeFirstResponder(self) - ghostty_surface_mouse_pos(surface, start.x, bounds.height - start.y, mods) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + mirroredMousePosition(surface, start.x, bounds.height - start.y, mods) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) let steps = max(4, Int(max(abs(end.x - start.x), abs(end.y - start.y)) / max(cellSize.width, 1))) for step in 1...steps { @@ -6990,7 +7104,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { y: start.y + ((end.y - start.y) * progress) ) let clampedIntermediatePoint = clampedDebugPoint(intermediatePoint) - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, clampedIntermediatePoint.x, bounds.height - clampedIntermediatePoint.y, @@ -6998,7 +7112,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) return ghostty_surface_has_selection(surface) } @@ -7008,7 +7122,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let flags: NSEvent.ModifierFlags = [.command] let suppressCommandPathHover = shouldSuppressCommandPathHover(for: flags) - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, clampedPoint.x, bounds.height - clampedPoint.y, @@ -7034,7 +7148,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let flags: NSEvent.ModifierFlags = [.command] let suppressCommandPathHover = shouldSuppressCommandPathHover(for: flags) - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, clampedPoint.x, bounds.height - clampedPoint.y, @@ -7073,9 +7187,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let mods = mouseModsFromFlags(flags) window?.makeFirstResponder(self) - ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, mods) - let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) - let releaseConsumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + mirroredMousePosition(surface, clampedPoint.x, bounds.height - clampedPoint.y, mods) + let pressHandled = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + let releaseConsumed = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) let resolution = handleCommandClickRelease( at: clampedPoint, modifierFlags: flags, @@ -7116,10 +7230,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } window?.makeFirstResponder(self) - ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, noMods) + mirroredMousePosition(surface, clampedPoint.x, bounds.height - clampedPoint.y, noMods) flagsChanged(with: cmdDown) - let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, commandMods) - let releaseConsumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, commandMods) + let pressHandled = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, commandMods) + let releaseConsumed = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, commandMods) flagsChanged(with: cmdUp) return [ @@ -7162,8 +7276,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { requestPointerFocusRecovery() window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseModsFromEvent(event)) + mirroredMousePosition(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mouseModsFromEvent(event)) } override func rightMouseUp(with event: NSEvent) { @@ -7173,7 +7287,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mouseModsFromEvent(event)) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mouseModsFromEvent(event)) } override func otherMouseDown(with event: NSEvent) { @@ -7186,8 +7300,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window?.makeFirstResponder(self) guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mouseModsFromEvent(event)) + mirroredMousePosition(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mouseModsFromEvent(event)) } override func otherMouseUp(with event: NSEvent) { @@ -7196,7 +7310,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } guard let surface = surface else { return } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mouseModsFromEvent(event)) + _ = mirroredMouseButton(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mouseModsFromEvent(event)) } override func menu(for event: NSEvent) -> NSMenu? { @@ -7221,8 +7335,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window?.makeFirstResponder(self) if sendsTerminalPointerEvent { let point = convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) - _ = ghostty_surface_mouse_button( + mirroredMousePosition(surface, point.x, bounds.height - point.y, mouseModsFromEvent(event)) + _ = mirroredMouseButton( surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, @@ -7363,7 +7477,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, eventPoint.x, bounds.height - eventPoint.y, @@ -7386,7 +7500,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - ghostty_surface_mouse_pos( + mirroredMousePosition( surface, eventPoint.x, bounds.height - eventPoint.y, @@ -7428,7 +7542,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if NSEvent.pressedMouseButtons != 0 { return } - ghostty_surface_mouse_pos(surface, -1, -1, mouseModsFromEvent(event)) + mirroredMousePosition(surface, -1, -1, mouseModsFromEvent(event)) } override func mouseDragged(with event: NSEvent) { @@ -7438,7 +7552,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Forward the raw drag coordinates, including out-of-bounds positions. // Selection auto-scroll depends on libghostty observing the pointer leave // the viewport rather than a cached in-bounds hover point. - ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) + mirroredMousePosition(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) } override func rightMouseDragged(with event: NSEvent) { @@ -7508,7 +7622,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) - ghostty_surface_mouse_scroll( + mirroredMouseScroll( surface, x, y, @@ -11846,11 +11960,17 @@ extension GhosttyNSView: NSTextInputClient { // Subtract 1 for the null terminator ghostty_surface_preedit(surface, ptr, UInt(len - 1)) } + terminalSurface?.mirrorRenderPreedit( + str, + selectionStart: markedSelectedRange.location, + selectionLength: markedSelectedRange.length + ) } } else if clearIfNeeded { // If we had marked text before but don't now, we're no longer // in a preedit state so we can clear it. ghostty_surface_preedit(surface, nil, 0) + terminalSurface?.mirrorRenderPreedit(nil, selectionStart: 0, selectionLength: 0) } } diff --git a/Sources/TerminalOutputTeeCallback.swift b/Sources/TerminalOutputTeeCallback.swift index 37d1de132ee6..1d5140e58ea4 100644 --- a/Sources/TerminalOutputTeeCallback.swift +++ b/Sources/TerminalOutputTeeCallback.swift @@ -9,6 +9,7 @@ let cmuxTerminalOutputTeeCallback: @convention(c) ( let count = Int(length) bytes.withMemoryRebound(to: UInt8.self, capacity: count) { rebound in let buffer = UnsafeBufferPointer(start: rebound, count: count) + context.mirrorOutput(buffer) MobileTerminalByteTee.shared.append(surfaceID: context.surfaceID, bytes: buffer) context.consume(buffer) } diff --git a/Sources/TerminalOutputTeeContext.swift b/Sources/TerminalOutputTeeContext.swift index bf30485c1174..e154906482e7 100644 --- a/Sources/TerminalOutputTeeContext.swift +++ b/Sources/TerminalOutputTeeContext.swift @@ -1,4 +1,6 @@ import CmuxTerminalCore +import CmuxTerminal +import CmuxTerminalRenderTransport import Foundation import os @@ -43,6 +45,9 @@ final class TerminalOutputTeeContext: @unchecked Sendable { let workspaceID: UUID let surfaceID: UUID + let surfaceGeneration: UInt64 + private let renderWorker: any TerminalRenderWorkerRouting + private var nextOutputSequence: UInt64 = 0 private let clock = ContinuousClock() private let notificationHandler: PromptTurnNotificationHandler private var detectors: [DetectorBinding] @@ -51,10 +56,14 @@ final class TerminalOutputTeeContext: @unchecked Sendable { init( workspaceID: UUID, surfaceID: UUID, + surfaceGeneration: UInt64, + renderWorker: any TerminalRenderWorkerRouting, agentDefinitions: [CmuxTaskManagerCodingAgentDefinition] ) { self.workspaceID = workspaceID self.surfaceID = surfaceID + self.surfaceGeneration = surfaceGeneration + self.renderWorker = renderWorker self.notificationHandler = PromptTurnNotificationHandler( workspaceID: workspaceID, surfaceID: surfaceID @@ -69,6 +78,20 @@ final class TerminalOutputTeeContext: @unchecked Sendable { } } + /// Copies borrowed PTY bytes and appends them to the worker's one ordered + /// command lane. Ghostty invokes this callback serially per surface. + func mirrorOutput(_ bytes: UnsafeBufferPointer) { + let data = Data(bytes) + guard !data.isEmpty else { return } + let sequence = nextOutputSequence + nextOutputSequence &+= UInt64(data.count) + renderWorker.enqueueRenderCommand(.mutateSurface( + id: surfaceID, + generation: surfaceGeneration, + mutation: .processOutput(sequence: sequence, bytes: data) + )) + } + func consume(_ bytes: UnsafeBufferPointer) { let now = clock.now for index in detectors.indices { diff --git a/Sources/TerminalSurfaceRuntimeWiring.swift b/Sources/TerminalSurfaceRuntimeWiring.swift index e21c60f7d856..1da67cce81bb 100644 --- a/Sources/TerminalSurfaceRuntimeWiring.swift +++ b/Sources/TerminalSurfaceRuntimeWiring.swift @@ -4,6 +4,8 @@ import CmuxTerminal import CmuxTerminalCore import GhosttyKit import CmuxSettings +import CmuxGhosttyRenderClient +import CmuxTerminalRenderTransport import struct CmuxSettings.AgentIntegrationSettingsStore // The app-side conformances and bridges injected into the CmuxTerminal @@ -73,6 +75,11 @@ final class TerminalSurfaceSpawnPolicyBridge: TerminalSurfaceSpawnPolicyProvidin /// drop/replay state by surface id (the legacy inline /// `ghostty_surface_set_pty_tee_cb` + `MobileTerminalByteTee.shared` calls). final class TerminalOutputByteTeeBridge: TerminalByteTeeBinding { + private let renderWorker: any TerminalRenderWorkerRouting + + init(renderWorker: any TerminalRenderWorkerRouting) { + self.renderWorker = renderWorker + } /// Wraps the retained tee userdata; `release()` runs exactly where the /// surface released the legacy `Unmanaged` context. /// @unchecked Sendable: the Unmanaged box is exclusively owned by this @@ -91,22 +98,23 @@ final class TerminalOutputByteTeeBridge: TerminalByteTeeBinding { } @MainActor - func installTee( - on surface: ghostty_surface_t, + func prepareTee( workspaceID: UUID, - surfaceID: UUID - ) -> any TerminalByteTeeLease { + surfaceID: UUID, + surfaceGeneration: UInt64 + ) -> TerminalByteTeeInstallation { let teeContext = Unmanaged.passRetained(TerminalOutputTeeContext( workspaceID: workspaceID, surfaceID: surfaceID, + surfaceGeneration: surfaceGeneration, + renderWorker: renderWorker, agentDefinitions: CmuxTaskManagerCodingAgentDefinition.builtIns )) - ghostty_surface_set_pty_tee_cb( - surface, - cmuxTerminalOutputTeeCallback, - teeContext.toOpaque() + return TerminalByteTeeInstallation( + callback: cmuxTerminalOutputTeeCallback, + userdata: teeContext.toOpaque(), + lease: Lease(context: teeContext) ) - return Lease(context: teeContext) } @MainActor @@ -115,6 +123,109 @@ final class TerminalOutputByteTeeBridge: TerminalByteTeeBinding { } } +// MARK: Ghostty render worker + +/// Process-wide bridge to the supervised AppKit-free Ghostty renderer. +final class GhosttyRenderRuntimeBridge: TerminalRenderWorkerRouting, @unchecked Sendable { + static let shared = GhosttyRenderRuntimeBridge() + + private let client: GhosttyRenderWorkerClient? + private let revisionLock = NSLock() + private var nextConfigurationRevision: UInt64 = 1 + private var observationStarted = false + + private init() { + do { + client = try GhosttyRenderWorkerClient.bundledWorker() + } catch { + client = nil + cmuxDebugLog("ghostty render worker unavailable: \(error)") + } + } + + func enqueueRenderCommand(_ command: TerminalRenderWorkerCommand) { + client?.commandSink.enqueue(command) + } + + func updateConfiguration(_ config: ghostty_config_t) { + guard let client else { return } + let serialized = ghostty_config_serialize(config) + defer { ghostty_string_free(serialized) } + guard let bytes = serialized.ptr else { return } + let contents = String( + decoding: UnsafeRawBufferPointer(start: bytes, count: Int(serialized.len)), + as: UTF8.self + ) + let state = revisionLock.withLock { () -> (revision: UInt64, startObservation: Bool) in + defer { nextConfigurationRevision &+= 1 } + let shouldStart = !observationStarted + observationStarted = true + return (nextConfigurationRevision, shouldStart) + } + let snapshot = TerminalRenderConfigurationSnapshot( + revision: state.revision, + contents: contents + ) + if state.startObservation { + Task { + let events = await client.subscribeEvents() + let frames = await client.subscribeFrames() + await client.updateConfiguration(snapshot) + async let eventObservation: Void = observeEvents(events) + async let frameObservation: Void = observeFrames(frames) + _ = await (eventObservation, frameObservation) + } + } else { + Task { await client.updateConfiguration(snapshot) } + } + } + + private func observeEvents( + _ events: AsyncStream + ) async { + for await event in events { + switch event { + case let .initialized(workerGeneration, processIdentifier): + Task { @MainActor in + for case let surface as TerminalSurface in GhosttyApp.terminalSurfaceRegistry.allSurfaces() { + surface.renderWorkerDidInitialize( + workerGeneration: workerGeneration, + processIdentifier: processIdentifier + ) + } + } + case let .resynchronizationRequired(surfaceID, surfaceGeneration): + Task { @MainActor in + guard let surface = GhosttyApp.terminalSurfaceRegistry.surface(id: surfaceID) + as? TerminalSurface, + let command = await surface.renderWorkerResynchronizationCommand( + surfaceGeneration: surfaceGeneration + ) else { return } + self.enqueueRenderCommand(command) + } + case let .workerExited(workerGeneration): + Task { @MainActor in + for case let surface as TerminalSurface in GhosttyApp.terminalSurfaceRegistry.allSurfaces() { + surface.renderWorkerDidExit(workerGeneration: workerGeneration) + } + } + case .surfaceCreated, .outputApplied, .failure: + break + } + } + } + + private func observeFrames(_ frames: AsyncStream) async { + for await frame in frames { + await MainActor.run { + guard let surface = GhosttyApp.terminalSurfaceRegistry + .surface(id: frame.metadata.surfaceID) as? TerminalSurface else { return } + surface.acceptRenderWorkerFrame(frame) + } + } + } +} + // MARK: Renderer reclamation extension RendererRealizationController: TerminalRendererRealizationScheduling {} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9257ced6a064..66449642bdf3 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -21,8 +21,6 @@ import CmuxTerminal /// flag (the app re-executes its own binary that way so a crash in the /// interpreter or renderer kills only the worker process), run that worker /// loop instead of the app: -/// - the render worker hosts its own faceless AppKit session and shares the -/// rendered layer tree with the host; /// - the interpreter worker (stage-1 fallback path) runs before any /// AppKit/SwiftUI setup. @main diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7ad71728c0d4..9605a8de9966 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -434,6 +434,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */; }; C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */; }; + C75061000000000000000008 /* cmux-ghostty-render-worker in Copy CLI */ = {isa = PBXBuildFile; fileRef = C75061000000000000000003 /* cmux-ghostty-render-worker */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; C0DE1A060000000000000001 /* cmux_layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A060000000000000002 /* cmux_layout.swift */; }; @@ -567,6 +568,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 5E2701040000000000000004 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 5E2701040000000000000002 /* CmuxFoundation */; }; CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CF00F00000000000000000A2 /* CmuxFoundation */; }; CF00F00000000000000000A4 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CF00F00000000000000000A2 /* CmuxFoundation */; }; + C750600000000000000000A3 /* CmuxGhosttyRenderClient in Frameworks */ = {isa = PBXBuildFile; productRef = C750600000000000000000A2 /* CmuxGhosttyRenderClient */; }; + C750600000000000000000B3 /* CmuxGhosttyRenderWorker in Frameworks */ = {isa = PBXBuildFile; productRef = C750600000000000000000B2 /* CmuxGhosttyRenderWorker */; }; C617000000000000000000A3 /* CmuxGit in Frameworks */ = {isa = PBXBuildFile; productRef = C617000000000000000000A2 /* CmuxGit */; }; C0DE34020000000000000001 /* CmuxHelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000003 /* CmuxHelpCommands.swift */; }; C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000004 /* CmuxHelpResource.swift */; }; @@ -879,6 +882,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources BDFCA25C0CE77A299C915E26 /* GhosttyOptionAsAltModsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */; }; 3069F1D10000000000000003 /* GhosttyPasteboardFidelityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3069F1D10000000000000004 /* GhosttyPasteboardFidelityTests.swift */; }; 7269A0017269A0017269A001 /* GhosttyPhysicalInputFocusReassertionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7269A0027269A0027269A002 /* GhosttyPhysicalInputFocusReassertionTests.swift */; }; + C75061000000000000000001 /* GhosttyRenderWorkerMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75061000000000000000002 /* GhosttyRenderWorkerMain.swift */; }; 5C0011AA0000000000000001 /* GhosttyScrollPreciseBoostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0011AA0000000000000002 /* GhosttyScrollPreciseBoostTests.swift */; }; 816400000000000000000001 /* GhosttyScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816400000000000000000002 /* GhosttyScrollView.swift */; }; 816400000000000000000003 /* GhosttyScrollViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816400000000000000000004 /* GhosttyScrollViewTests.swift */; }; @@ -2022,6 +2026,13 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources remoteGlobalIDString = B9000005A1B2C3D4E5F60719; remoteInfo = "cmux-cli"; }; + C7506100000000000000000D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5001070 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C75061000000000000000009; + remoteInfo = "cmux-ghostty-render-worker"; + }; D1320AA0D1320AA0D1320AA3 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A5001070 /* Project object */; @@ -2058,6 +2069,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */, C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */, + C75061000000000000000008 /* cmux-ghostty-render-worker in Copy CLI */, C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */, D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, C0DE70500000000000000002 /* start-cmux-profiling in Copy CLI */, @@ -2504,6 +2516,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-claude-wrapper"; sourceTree = SOURCE_ROOT; }; C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-codex-wrapper"; sourceTree = SOURCE_ROOT; }; + C75061000000000000000003 /* cmux-ghostty-render-worker */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "cmux-ghostty-render-worker"; sourceTree = BUILT_PRODUCTS_DIR; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; @@ -2887,6 +2900,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyOptionAsAltModsTests.swift; sourceTree = ""; }; 3069F1D10000000000000004 /* GhosttyPasteboardFidelityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPasteboardFidelityTests.swift; sourceTree = ""; }; 7269A0027269A0027269A002 /* GhosttyPhysicalInputFocusReassertionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPhysicalInputFocusReassertionTests.swift; sourceTree = ""; }; + C75061000000000000000002 /* GhosttyRenderWorkerMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRenderWorkerMain.swift; sourceTree = ""; }; 5C0011AA0000000000000002 /* GhosttyScrollPreciseBoostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollPreciseBoostTests.swift; sourceTree = ""; }; 816400000000000000000002 /* GhosttyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollView.swift; sourceTree = ""; }; 816400000000000000000004 /* GhosttyScrollViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollViewTests.swift; sourceTree = ""; }; @@ -4016,6 +4030,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */, C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */, CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, + C750600000000000000000A3 /* CmuxGhosttyRenderClient in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, 1A0B0C0D0E0F101112132013 /* CmuxIrohTransport in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, @@ -4073,6 +4088,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = ); runOnlyForDeploymentPostprocessing = 0; }; + C75061000000000000000006 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C750600000000000000000B3 /* CmuxGhosttyRenderWorker in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D1320AA0D1320AA0D1320AA7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -4292,11 +4315,20 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = path = Cloud; sourceTree = ""; }; + C75061000000000000000004 /* RenderWorker */ = { + isa = PBXGroup; + children = ( + C75061000000000000000002 /* GhosttyRenderWorkerMain.swift */, + ); + path = RenderWorker; + sourceTree = ""; + }; A5001040 = { isa = PBXGroup; children = ( A5001041 /* Sources */, B9000003A1B2C3D4E5F60719 /* CLI */, + C75061000000000000000004 /* RenderWorker */, 087C454FFF74443AB06942C3 /* Resources */, A5001101 /* Assets.xcassets */, IC000002 /* AppIcon.icon */, @@ -5501,6 +5533,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = children = ( A5001000 /* cmux.app */, B9000004A1B2C3D4E5F60719 /* cmux */, + C75061000000000000000003 /* cmux-ghostty-render-worker */, D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */, 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */, F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */, @@ -6159,6 +6192,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = ); dependencies = ( B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */, + C7506100000000000000000E /* PBXTargetDependency */, D1320AA0D1320AA0D1320AB1 /* PBXTargetDependency */, ); name = cmux; @@ -6182,6 +6216,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C5A1FED100000000000000D3 /* CmuxSwiftRenderUI */, C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */, C5A1FED200000000000000E6 /* CmuxSidebarRemoteRender */, + C750600000000000000000A2 /* CmuxGhosttyRenderClient */, 5E55200000000000000000B2 /* CmuxWindowing */, 5E55500000000000000000E2 /* CmuxCommandPalette */, C617000000000000000000A2 /* CmuxGit */, @@ -6237,6 +6272,25 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = productReference = B9000004A1B2C3D4E5F60719 /* cmux */; productType = "com.apple.product-type.tool"; }; + C75061000000000000000009 /* cmux-ghostty-render-worker */ = { + isa = PBXNativeTarget; + buildConfigurationList = C7506100000000000000000C /* Build configuration list for PBXNativeTarget "cmux-ghostty-render-worker" */; + buildPhases = ( + C75061000000000000000005 /* Sources */, + C75061000000000000000006 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "cmux-ghostty-render-worker"; + packageProductDependencies = ( + C750600000000000000000B2 /* CmuxGhosttyRenderWorker */, + ); + productName = "cmux-ghostty-render-worker"; + productReference = C75061000000000000000003 /* cmux-ghostty-render-worker */; + productType = "com.apple.product-type.tool"; + }; CB450DF0F0B3839599082C4D /* cmuxUITests */ = { isa = PBXNativeTarget; buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */; @@ -6381,6 +6435,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */, C750200000000000000000A1 /* XCLocalSwiftPackageReference "CmuxTerminalCore" */, C750500000000000000000A1 /* XCLocalSwiftPackageReference "CmuxTerminal" */, + C750600000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGhosttyRenderService" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CFF12E0000000000000000A1 /* XCLocalSwiftPackageReference "CmuxTestSupport" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, @@ -6404,6 +6459,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001050 /* cmux */, D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */, B9000005A1B2C3D4E5F60719 /* cmux-cli */, + C75061000000000000000009 /* cmux-ghostty-render-worker */, CB450DF0F0B3839599082C4D /* cmuxUITests */, F1000004A1B2C3D4E5F60718 /* cmuxTests */, ); @@ -7934,6 +7990,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = ); runOnlyForDeploymentPostprocessing = 0; }; + C75061000000000000000005 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C75061000000000000000001 /* GhosttyRenderWorkerMain.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D1320AA0D1320AA0D1320AB0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -8545,6 +8609,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = target = B9000005A1B2C3D4E5F60719 /* cmux-cli */; targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */; }; + C7506100000000000000000E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C75061000000000000000009 /* cmux-ghostty-render-worker */; + targetProxy = C7506100000000000000000D /* PBXContainerItemProxy */; + }; D1320AA0D1320AA0D1320AB1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */; @@ -8771,6 +8840,47 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = }; name = Release; }; + C7506100000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_MODULE_NAME = CmuxGhosttyRenderWorkerExecutable; + PRODUCT_NAME = "cmux-ghostty-render-worker"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C7506100000000000000000B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_MODULE_NAME = CmuxGhosttyRenderWorkerExecutable; + PRODUCT_NAME = "cmux-ghostty-render-worker"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; C117776A77E71D1432F570D7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8913,6 +9023,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C7506100000000000000000C /* Build configuration list for PBXNativeTarget "cmux-ghostty-render-worker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C7506100000000000000000A /* Debug */, + C7506100000000000000000B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D1320AA0D1320AA0D1320AB4 /* Build configuration list for PBXNativeTarget "CmuxDockTilePlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -9070,6 +9189,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxTerminal; }; + C750600000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGhosttyRenderService" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxGhosttyRenderService; + }; CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxSettings; @@ -9302,6 +9425,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C750500000000000000000A1 /* XCLocalSwiftPackageReference "CmuxTerminal" */; productName = CmuxTerminal; }; + C750600000000000000000A2 /* CmuxGhosttyRenderClient */ = { + isa = XCSwiftPackageProductDependency; + package = C750600000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGhosttyRenderService" */; + productName = CmuxGhosttyRenderClient; + }; + C750600000000000000000B2 /* CmuxGhosttyRenderWorker */ = { + isa = XCSwiftPackageProductDependency; + package = C750600000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGhosttyRenderService" */; + productName = CmuxGhosttyRenderWorker; + }; CD0CFE5200000000CD0CFE52 /* CmuxSettings */ = { isa = XCSwiftPackageProductDependency; package = CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 140db5c3bd9f..1734a8dc212f 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -129,6 +129,9 @@ + + @@ -183,6 +186,9 @@ + + diff --git a/docs/ghostty-render-worker.md b/docs/ghostty-render-worker.md index 3c88485f1742..72cbb9ab4012 100644 --- a/docs/ghostty-render-worker.md +++ b/docs/ghostty-render-worker.md @@ -12,12 +12,11 @@ work. It does not move the complete terminal engine out of the app process. | App process | AppKit and SwiftUI, shell child and PTY, authoritative Ghostty parser and terminal model, input and IME, accessibility and terminal queries, worker supervision, and final IOSurface assignment to a `CALayer` | | Render worker | One Ghostty app, one manual-IO visual mirror per live terminal surface, active Ghostty renderer threads, Metal resources, and IOSurface production | -The app reexecutes its signed executable with -`--cmux-ghostty-render-worker`. `CmuxMain` handles that argument before -initializing `NSApplication` or SwiftUI and enters `runGhosttyRenderWorker()`. -The worker implementation imports no AppKit and creates no AppKit objects. The -current worker is still the main app executable, so its image remains linked -against the app's frameworks. +The app bundles a dedicated `cmux-ghostty-render-worker` command-line +executable under `Contents/Resources/bin`. The helper links only the render +worker package, Ghostty, and its graphics and transport dependencies. It does +not import or link AppKit, SwiftUI, or the app target. The app launches this +helper directly and supervises its lifetime. The app creates the authoritative surface with Ghostty's external Metal platform and a no-op present callback. It immediately marks that surface @@ -116,9 +115,6 @@ the authority instead of stalling a caller. - Recovery reconstructs at most 4,000 physical rows and 8 MiB of VT data for a mirror. Older history remains authoritative in the app but is not included in the restarted mirror's visual reconstruction. -- The worker is a reexecution mode of the signed app binary, not a separate - minimal helper executable. It avoids AppKit initialization and use, but the - binary is still linked with AppKit. - The host main actor still handles input integration, pane geometry, and the final constant-time IOSurface-to-layer swap. This design removes active Ghostty Metal rendering from that process boundary, not all terminal-related @@ -130,6 +126,8 @@ the authority instead of stalling a caller. protocol, framed pipe channel, and authenticated Mach IOSurface transfer. - `Packages/macOS/CmuxGhosttyRenderService` contains the supervisor client and AppKit-free worker runtime. +- `RenderWorker/GhosttyRenderWorkerMain.swift` is the dedicated worker + executable entry point. - `Packages/macOS/CmuxTerminal` owns the authority-to-mirror routing, atomic recovery snapshots, generation fencing, and remote presentation layer. - `Sources/TerminalSurfaceRuntimeWiring.swift` installs the process-wide worker, diff --git a/ghostty b/ghostty index bb30526cdab8..0f400d0f5c12 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bb30526cdab8f5fb08ae43e404e3aacc40d3ffc3 +Subproject commit 0f400d0f5c12c5be4534f3397af999eca6a3411d diff --git a/scripts/strip-release-bundle.sh b/scripts/strip-release-bundle.sh index 687c7c83cd70..9318ddb1c85c 100755 --- a/scripts/strip-release-bundle.sh +++ b/scripts/strip-release-bundle.sh @@ -43,6 +43,7 @@ strip_if_macho() { strip_if_macho "$APP_PATH/Contents/MacOS/cmux" strip_if_macho "$APP_PATH/Contents/Resources/bin/cmux" strip_if_macho "$APP_PATH/Contents/Resources/bin/cmux-diff-sidecar" +strip_if_macho "$APP_PATH/Contents/Resources/bin/cmux-ghostty-render-worker" if [ -d "$APP_PATH/Contents/PlugIns" ]; then while IFS= read -r -d '' binary; do From 1fa047fa6437a67c3f5589c0f978219a9688bbb2 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:03:22 -0700 Subject: [PATCH 06/13] test: cover stale render worker recovery config --- .../cmux-ghostty-render-fixture/main.swift | 16 ++++++- .../GhosttyRenderWorkerClientTests.swift | 48 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift index c84e59a7639e..90570779e503 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift @@ -5,19 +5,33 @@ let channel = TerminalRenderMessageChannel(readDescriptor: 0, writeDescriptor: 1 let environment = ProcessInfo.processInfo.environment let crashAfterInitialize = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH"] == "1" let crashOnceFile = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH_ONCE_FILE"] +let initializationLog = environment["CMUX_GHOSTTY_RENDER_FIXTURE_INITIALIZATION_LOG"] func send(_ event: TerminalRenderWorkerEvent) { guard let payload = try? TerminalRenderControlCodec.encode(event) else { return } try? channel.send(payload) } +func recordInitializationRevision(_ revision: UInt64) { + guard let initializationLog else { return } + let url = URL(fileURLWithPath: initializationLog) + if !FileManager.default.fileExists(atPath: url.path) { + _ = FileManager.default.createFile(atPath: url.path, contents: Data()) + } + guard let handle = try? FileHandle(forWritingTo: url) else { return } + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data("\(revision)\n".utf8)) +} + while let payload = channel.receive() { guard let command = try? TerminalRenderControlCodec.decodeCommand(payload) else { send(.failure("decode failed")) continue } switch command { - case let .initialize(version, generation, _, _): + case let .initialize(version, generation, _, configuration): + recordInitializationRevision(configuration.revision) send(.initialized( protocolVersion: version, workerGeneration: generation, diff --git a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift index f86e2a306f04..988c78720ae2 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift @@ -278,9 +278,53 @@ import Testing ))) } - private func configuration() -> TerminalRenderConfigurationSnapshot { + @Test func restartRetainsNewestConfigurationRevision() async throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + let identifier = UUID().uuidString + let crashMarker = temporaryDirectory + .appendingPathComponent("cmux-ghostty-render-config-crash-\(identifier)") + let initializationLog = temporaryDirectory + .appendingPathComponent("cmux-ghostty-render-config-log-\(identifier)") + defer { + try? FileManager.default.removeItem(at: crashMarker) + try? FileManager.default.removeItem(at: initializationLog) + } + + let client = try GhosttyRenderWorkerClient( + executableURL: fixtureURL(), + environment: [ + "CMUX_GHOSTTY_RENDER_FIXTURE_CRASH_ONCE_FILE": crashMarker.path, + "CMUX_GHOSTTY_RENDER_FIXTURE_INITIALIZATION_LOG": initializationLog.path, + ] + ) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration(revision: 2)) + _ = await collector.waitUntil { events in + events.contains(.workerExited(workerGeneration: 1)) + } + + await client.updateConfiguration(configuration(revision: 1)) + client.commandSink.enqueue(.createSurface(surfaceDescriptor( + id: UUID(), + generation: 1 + ))) + _ = await collector.waitUntil { events in + events.contains { event in + guard case let .initialized(workerGeneration, _) = event else { return false } + return workerGeneration == 2 + } + } + await client.shutdown() + + let revisions = (try? String(contentsOf: initializationLog, encoding: .utf8))? + .split(whereSeparator: \.isNewline) + .compactMap { UInt64($0) } + #expect(revisions == [2, 2]) + } + + private func configuration(revision: UInt64 = 1) -> TerminalRenderConfigurationSnapshot { TerminalRenderConfigurationSnapshot( - revision: 1, + revision: revision, contents: "font-size = 14\n" ) } From 9a20a040703de7b91737171d1aaa9f54c6412799 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:03:45 -0700 Subject: [PATCH 07/13] fix: preserve newest render worker configuration --- .../CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift index b74cf84383c2..60b341efc9ae 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift @@ -176,6 +176,9 @@ public actor GhosttyRenderWorkerClient { broadcast(.failure("host attempted to enqueue worker initialization")) case let .replaceConfiguration(snapshot): + if let configuration, snapshot.revision <= configuration.revision { + return + } let hadConfiguration = configuration != nil configuration = snapshot do { From 86e741dffc33ca474b5d12a2607b22985efc7009 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:11:43 -0700 Subject: [PATCH 08/13] fix: arm late render mirrors for active worker --- .../Surface/TerminalSurface+Renderer.swift | 7 ++--- Sources/TerminalSurfaceRuntimeWiring.swift | 27 +++++++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 069ca701f3b5..5d1e696e8c96 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -217,12 +217,9 @@ extension TerminalSurface { )) } - /// Updates the view's frame fence after the worker accepts initialization. + /// Updates the view's frame fence after the worker or this mirror becomes ready. @MainActor - public func renderWorkerDidInitialize( - workerGeneration: UInt64, - processIdentifier: Int32 - ) { + public func renderWorkerDidBecomeReady(workerGeneration: UInt64) { guard renderMirrorDescriptor != nil else { return } surfaceView.updateRemoteRendererWorkerGeneration(workerGeneration) } diff --git a/Sources/TerminalSurfaceRuntimeWiring.swift b/Sources/TerminalSurfaceRuntimeWiring.swift index 1da67cce81bb..54f5732e98c0 100644 --- a/Sources/TerminalSurfaceRuntimeWiring.swift +++ b/Sources/TerminalSurfaceRuntimeWiring.swift @@ -183,17 +183,23 @@ final class GhosttyRenderRuntimeBridge: TerminalRenderWorkerRouting, @unchecked private func observeEvents( _ events: AsyncStream ) async { + var activeWorkerGeneration: UInt64? for await event in events { switch event { - case let .initialized(workerGeneration, processIdentifier): - Task { @MainActor in + case let .initialized(workerGeneration, _): + activeWorkerGeneration = workerGeneration + await MainActor.run { for case let surface as TerminalSurface in GhosttyApp.terminalSurfaceRegistry.allSurfaces() { - surface.renderWorkerDidInitialize( - workerGeneration: workerGeneration, - processIdentifier: processIdentifier - ) + surface.renderWorkerDidBecomeReady(workerGeneration: workerGeneration) } } + case let .surfaceCreated(surfaceID, _): + guard let activeWorkerGeneration else { continue } + await MainActor.run { + guard let surface = GhosttyApp.terminalSurfaceRegistry.surface(id: surfaceID) + as? TerminalSurface else { return } + surface.renderWorkerDidBecomeReady(workerGeneration: activeWorkerGeneration) + } case let .resynchronizationRequired(surfaceID, surfaceGeneration): Task { @MainActor in guard let surface = GhosttyApp.terminalSurfaceRegistry.surface(id: surfaceID) @@ -204,13 +210,18 @@ final class GhosttyRenderRuntimeBridge: TerminalRenderWorkerRouting, @unchecked self.enqueueRenderCommand(command) } case let .workerExited(workerGeneration): - Task { @MainActor in + if activeWorkerGeneration == workerGeneration { + activeWorkerGeneration = nil + } + await MainActor.run { for case let surface as TerminalSurface in GhosttyApp.terminalSurfaceRegistry.allSurfaces() { surface.renderWorkerDidExit(workerGeneration: workerGeneration) } } - case .surfaceCreated, .outputApplied, .failure: + case .outputApplied: break + case let .failure(message): + cmuxDebugLog("ghostty render worker: \(message)") } } } From fcdc3bfbee9bd697157828b17d918dc22c52cbcb Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:13:07 -0700 Subject: [PATCH 09/13] fix: qualify pull request icon color capture --- .../Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..a7ccb3f42023 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -98,7 +98,7 @@ final class SidebarRowPullRequestIconView: NSView { // transparent backing draws nothing (no destination pixels). let tinted = NSImage(size: image.size, flipped: false) { drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) - color.set() + self.color.set() drawRect.fill(using: .sourceAtop) return true } From c05c52e7bd1878f844c6d16c50a7104e68771cd8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:37:59 -0700 Subject: [PATCH 10/13] test: cover remote renderer backing layer resize --- cmuxTests/GhosttyDrawableSizeRetryTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmuxTests/GhosttyDrawableSizeRetryTests.swift b/cmuxTests/GhosttyDrawableSizeRetryTests.swift index a83d1c84253e..9d507b7036c7 100644 --- a/cmuxTests/GhosttyDrawableSizeRetryTests.swift +++ b/cmuxTests/GhosttyDrawableSizeRetryTests.swift @@ -12,6 +12,30 @@ import CmuxTerminal @MainActor @Suite struct GhosttyDrawableSizeRetryTests { + @Test func remoteRendererBackingLayerLeavesRootGeometryToAppKit() throws { + _ = NSApplication.shared + + let initialSize = CGSize(width: 760, height: 644) + let resizedSize = CGSize(width: 379.5, height: 644) + let surfaceView = GhosttyNSView( + frame: NSRect(origin: .zero, size: initialSize) + ) + surfaceView.configureRemoteRenderer( + surfaceID: UUID(), + surfaceGeneration: 1, + width: 1_520, + height: 1_288 + ) + + let remoteLayer = try #require(surfaceView.layer as? GhosttyRemoteIOSurfaceLayer) + #expect(remoteLayer.autoresizingMask.isEmpty) + + surfaceView.setFrameSize(resizedSize) + surfaceView.layoutSubtreeIfNeeded() + + #expect(remoteLayer.frame == surfaceView.bounds) + } + @Test func reconcilesDrawableAfterFullSizeUpdateRunsBeforeMetalLayerRealizes() throws { _ = NSApplication.shared From bd94bf1b05de5df5c244ba37be14664c1cb5703d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 23:39:34 -0700 Subject: [PATCH 11/13] fix: keep remote backing layer sized by AppKit --- Sources/GhosttyTerminalView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 93aefc0bb786..55032d7071d1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -321,7 +321,9 @@ extension GhosttyNSView { backingScaleFactor: window?.backingScaleFactor ?? 1 ) remoteLayer.frame = bounds - remoteLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + // AppKit owns the geometry of a view's root backing layer. Giving that + // layer an autoresizing mask applies the view's size delta a second + // time and can collapse a terminal to one point during split resize. remoteLayer.masksToBounds = true remoteLayer.isOpaque = false layer = remoteLayer From 637e1f2cfc762d8edc36c773b083c480fb31487b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 11:53:38 -0700 Subject: [PATCH 12/13] test: expose remote resize frame starvation --- .../GhosttyRemoteIOSurfaceLayerTests.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift index 60a176345141..5a75c917ccf2 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift @@ -106,18 +106,24 @@ struct GhosttyRemoteIOSurfaceLayerTests { #expect(layer.retainedSurface == nil) } - @Test func sizeAndScaleUpdatesRetainLastFrameUntilMatchingReplacement() throws { + @Test func liveResizeAcceptsNewerConsistentFrameAtPreviouslyRenderedSize() throws { let layer = makeLayer() let accepted = try makeFrame(sequence: 3) #expect(layer.present(accepted)) - let retainedID = IOSurfaceGetID(accepted.surface) let newSize = GhosttyRenderPixelSize(width: 48, height: 40) layer.updateExpectedPixelSize(newSize) layer.updateBackingScaleFactor(3) #expect(layer.contentsScale == 3) - expectRetainsSurface(layer, id: retainedID, sequence: 3) - #expect(!layer.present(try makeFrame(sequence: 4))) + + let newerFrameAtPreviousSize = try makeFrame(sequence: 4) + try #require(layer.present(newerFrameAtPreviousSize)) + expectRetainsSurface( + layer, + id: IOSurfaceGetID(newerFrameAtPreviousSize.surface), + sequence: 4 + ) + #expect(layer.present(try makeFrame(sequence: 5, metadataSize: newSize, surfaceSize: newSize))) } From fb1f4b3d157a5f0479a45a65c5ccbcc83e1c8096 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 17 Jul 2026 12:06:16 -0700 Subject: [PATCH 13/13] fix: bound remote terminal resize backlog --- .../GhosttyRenderWorkerClient.swift | 126 ++++++++++++-- .../RunGhosttyRenderWorker.swift | 6 + .../cmux-ghostty-render-fixture/main.swift | 64 ++++++- .../GhosttyRenderWorkerClientTests.swift | 159 ++++++++++++++++++ .../Hosting/GhosttyRemoteIOSurfaceLayer.swift | 14 +- .../GhosttyRemoteIOSurfaceLayerTests.swift | 6 +- .../TerminalRenderControlProtocol.swift | 6 +- .../TerminalRenderControlProtocolTests.swift | 1 + 8 files changed, 356 insertions(+), 26 deletions(-) diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift index 60b341efc9ae..7e1e2ba22d6a 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderClient/GhosttyRenderWorkerClient.swift @@ -40,6 +40,7 @@ public actor GhosttyRenderWorkerClient { private var desiredSurfaces: [UUID: TerminalRenderSurfaceDescriptor] = [:] private var resynchronizingSurfaces: Set = [] private var pendingMutations: [UUID: [TerminalRenderWorkerCommand]] = [:] + private var inFlightResizes: [UUID: GhosttyRenderInFlightResize] = [:] private var eventSubscribers: [Int: AsyncStream.Continuation] = [:] private var frameSubscribers: [Int: AsyncStream.Continuation] = [:] @@ -164,6 +165,8 @@ public actor GhosttyRenderWorkerClient { frameContinuation.finish() controlContinuation.finish() frameReceiver.stop() + pendingMutations.removeAll() + inFlightResizes.removeAll() for continuation in eventSubscribers.values { continuation.finish() } for continuation in frameSubscribers.values { continuation.finish() } eventSubscribers.removeAll() @@ -194,6 +197,7 @@ public actor GhosttyRenderWorkerClient { desiredSurfaces[descriptor.id] = descriptor resynchronizingSurfaces.remove(descriptor.id) pendingMutations[descriptor.id] = nil + inFlightResizes[descriptor.id] = nil guard configuration != nil else { return } do { let launched = try ensureRunning() @@ -207,18 +211,20 @@ public actor GhosttyRenderWorkerClient { case let .mutateSurface(id, generation, _): guard desiredSurfaces[id]?.generation == generation else { return } guard configuration != nil else { - pendingMutations[id, default: []].append(command) + enqueuePendingMutation(command) return } do { _ = try ensureRunning() - if resynchronizingSurfaces.contains(id) { - pendingMutations[id, default: []].append(command) - } else if let channel = child?.channel { - try send(command, through: channel) + guard !resynchronizingSurfaces.contains(id), + inFlightResizes[id] == nil, + let channel = child?.channel else { + enqueuePendingMutation(command) + return } + try sendScheduledMutation(command, through: channel) } catch { - pendingMutations[id, default: []].append(command) + enqueuePendingMutation(command) loseWorker(reason: "surface mutation send failed: \(error)") } @@ -229,10 +235,13 @@ public actor GhosttyRenderWorkerClient { guard let channel = child?.channel else { return } try send(command, through: channel) resynchronizingSurfaces.remove(descriptor.id) + inFlightResizes[descriptor.id] = nil let queued = pendingMutations.removeValue(forKey: descriptor.id) ?? [] - for pending in queued.compactMap({ trim($0, before: nextSequence) }) { - try send(pending, through: channel) + let trimmed = queued.compactMap { trim($0, before: nextSequence) } + if !trimmed.isEmpty { + pendingMutations[descriptor.id] = trimmed } + try drainPendingMutations(for: descriptor.id, through: channel) } catch { loseWorker(reason: "surface resynchronization failed: \(error)") } @@ -242,6 +251,7 @@ public actor GhosttyRenderWorkerClient { desiredSurfaces[id] = nil resynchronizingSurfaces.remove(id) pendingMutations[id] = nil + inFlightResizes[id] = nil if let channel = child?.channel { do { try send(command, through: channel) } catch { loseWorker(reason: "surface destroy send failed: \(error)") } @@ -326,10 +336,9 @@ public actor GhosttyRenderWorkerClient { for descriptor in desiredSurfaces.values { try send(.createSurface(descriptor), through: channel) } - for commands in pendingMutations.values { - for pending in commands { try send(pending, through: channel) } + for id in Array(pendingMutations.keys) { + try drainPendingMutations(for: id, through: channel) } - pendingMutations.removeAll() } hasLaunchedWorker = true return true @@ -387,6 +396,25 @@ public actor GhosttyRenderWorkerClient { surfaceGeneration: surfaceGeneration, nextSequence: nextSequence )) + case let .resizeApplied(id, surfaceGeneration, width, height): + guard initializedGeneration == generation else { + loseWorker(reason: "worker applied a resize before initialization") + return + } + guard desiredSurfaces[id]?.generation == surfaceGeneration, + let inFlight = inFlightResizes[id], + inFlight.generation == surfaceGeneration, + inFlight.width == width, + inFlight.height == height else { + return + } + inFlightResizes[id] = nil + guard let channel = child?.channel else { return } + do { + try drainPendingMutations(for: id, through: channel) + } catch { + loseWorker(reason: "queued surface mutation send failed: \(error)") + } case let .failure(message): broadcast(.failure(message)) } @@ -413,6 +441,7 @@ public actor GhosttyRenderWorkerClient { endedChild.process.terminate() } child = nil + inFlightResizes.removeAll() resynchronizingSurfaces.formUnion(desiredSurfaces.keys) broadcast(.workerExited(workerGeneration: generation)) } @@ -430,6 +459,7 @@ public actor GhosttyRenderWorkerClient { lostChild.process.terminate() } child = nil + inFlightResizes.removeAll() resynchronizingSurfaces.formUnion(desiredSurfaces.keys) broadcast(.failure(reason)) broadcast(.workerExited(workerGeneration: generation)) @@ -477,6 +507,74 @@ public actor GhosttyRenderWorkerClient { try channel.send(TerminalRenderControlCodec.encode(command)) } + private func sendScheduledMutation( + _ command: TerminalRenderWorkerCommand, + through channel: TerminalRenderMessageChannel + ) throws { + try send(command, through: channel) + guard let resize = resizeIdentity(for: command) else { return } + inFlightResizes[resize.id] = resize.inFlight + } + + private func enqueuePendingMutation(_ command: TerminalRenderWorkerCommand) { + guard case let .mutateSurface(id, generation, mutation) = command else { return } + var queued = pendingMutations[id] ?? [] + if case .resize = mutation, + let last = queued.last, + case let .mutateSurface(lastID, lastGeneration, lastMutation) = last, + lastID == id, + lastGeneration == generation, + case .resize = lastMutation { + queued[queued.index(before: queued.endIndex)] = command + } else { + queued.append(command) + } + pendingMutations[id] = queued + } + + private func drainPendingMutations( + for id: UUID, + through channel: TerminalRenderMessageChannel + ) throws { + guard inFlightResizes[id] == nil, + let queued = pendingMutations.removeValue(forKey: id) else { + return + } + + for index in queued.indices { + do { + try sendScheduledMutation(queued[index], through: channel) + } catch { + pendingMutations[id] = Array(queued[index...]) + throw error + } + guard inFlightResizes[id] == nil else { + let nextIndex = queued.index(after: index) + if nextIndex < queued.endIndex { + pendingMutations[id] = Array(queued[nextIndex...]) + } + return + } + } + } + + private func resizeIdentity( + for command: TerminalRenderWorkerCommand + ) -> (id: UUID, inFlight: GhosttyRenderInFlightResize)? { + guard case let .mutateSurface(id, generation, mutation) = command, + case let .resize(width, height) = mutation else { + return nil + } + return ( + id, + GhosttyRenderInFlightResize( + generation: generation, + width: width, + height: height + ) + ) + } + private func trim( _ command: TerminalRenderWorkerCommand, before nextSequence: UInt64 @@ -533,6 +631,12 @@ private struct GhosttyRenderChild { let stdout: Pipe } +private struct GhosttyRenderInFlightResize { + let generation: UInt64 + let width: UInt32 + let height: UInt32 +} + private enum GhosttyRenderChildControlIngress: Sendable { case event(TerminalRenderWorkerEvent, generation: UInt64) case ended(generation: UInt64) diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift index 50cd2c127723..2c4967754022 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/CmuxGhosttyRenderWorker/RunGhosttyRenderWorker.swift @@ -274,6 +274,12 @@ private final class GhosttyRenderWorkerEngine: @unchecked Sendable { applyOutput(sequence: sequence, bytes: bytes, to: surface) case let .resize(width, height): ghostty_surface_set_size(handle, max(width, 1), max(height, 1)) + send(.resizeApplied( + id: surface.descriptor.id, + generation: surface.descriptor.generation, + width: width, + height: height + )) case let .contentScale(x, y): ghostty_surface_set_content_scale(handle, x, y) case let .focus(focused): diff --git a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift index 90570779e503..22753f4d019a 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Sources/cmux-ghostty-render-fixture/main.swift @@ -6,6 +6,11 @@ let environment = ProcessInfo.processInfo.environment let crashAfterInitialize = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH"] == "1" let crashOnceFile = environment["CMUX_GHOSTTY_RENDER_FIXTURE_CRASH_ONCE_FILE"] let initializationLog = environment["CMUX_GHOSTTY_RENDER_FIXTURE_INITIALIZATION_LOG"] +let mutationLog = environment["CMUX_GHOSTTY_RENDER_FIXTURE_MUTATION_LOG"] +let resizeReleaseRevision = environment["CMUX_GHOSTTY_RENDER_FIXTURE_RESIZE_RELEASE_REVISION"] + .flatMap(UInt64.init) +var shouldHoldResizeAcknowledgements = resizeReleaseRevision != nil +var heldResizeAcknowledgements: [TerminalRenderWorkerEvent] = [] func send(_ event: TerminalRenderWorkerEvent) { guard let payload = try? TerminalRenderControlCodec.encode(event) else { return } @@ -24,6 +29,38 @@ func recordInitializationRevision(_ revision: UInt64) { try? handle.write(contentsOf: Data("\(revision)\n".utf8)) } +func appendMutationLog(_ line: String) { + guard let mutationLog else { return } + let url = URL(fileURLWithPath: mutationLog) + if !FileManager.default.fileExists(atPath: url.path) { + _ = FileManager.default.createFile(atPath: url.path, contents: Data()) + } + guard let handle = try? FileHandle(forWritingTo: url) else { return } + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: Data("\(line)\n".utf8)) +} + +@MainActor +func acknowledgeResize( + id: UUID, + generation: UInt64, + width: UInt32, + height: UInt32 +) { + let event = TerminalRenderWorkerEvent.resizeApplied( + id: id, + generation: generation, + width: width, + height: height + ) + if shouldHoldResizeAcknowledgements { + heldResizeAcknowledgements.append(event) + } else { + send(event) + } +} + while let payload = channel.receive() { guard let command = try? TerminalRenderControlCodec.decodeCommand(payload) else { send(.failure("decode failed")) @@ -55,17 +92,38 @@ while let payload = channel.receive() { nextSequence: nextSequence )) case let .mutateSurface(id, generation, mutation): - if case let .processOutput(sequence, bytes) = mutation { + switch mutation { + case let .processOutput(sequence, bytes): + appendMutationLog("output \(sequence) \(bytes.base64EncodedString())") send(.outputApplied( id: id, generation: generation, nextSequence: sequence + UInt64(bytes.count) )) + case let .resize(width, height): + appendMutationLog("resize \(width) \(height)") + acknowledgeResize( + id: id, + generation: generation, + width: width, + height: height + ) + default: + continue } case let .destroySurface(id, generation): send(.surfaceDestroyed(id: id, generation: generation)) - case .replaceConfiguration: - continue + case let .replaceConfiguration(configuration): + guard let resizeReleaseRevision, + configuration.revision >= resizeReleaseRevision else { + continue + } + shouldHoldResizeAcknowledgements = false + let acknowledgements = heldResizeAcknowledgements + heldResizeAcknowledgements.removeAll(keepingCapacity: false) + for acknowledgement in acknowledgements { + send(acknowledgement) + } case .shutdown: exit(0) } diff --git a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift index 988c78720ae2..c6d012db2b9c 100644 --- a/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift +++ b/Packages/macOS/CmuxGhosttyRenderService/Tests/CmuxGhosttyRenderClientTests/GhosttyRenderWorkerClientTests.swift @@ -168,6 +168,128 @@ import Testing } } + @Test func resizeBurstCoalescesToFinalSizeWhileFirstResizeIsInFlight() async throws { + let mutationLog = temporaryMutationLogURL() + defer { try? FileManager.default.removeItem(at: mutationLog) } + let client = try GhosttyRenderWorkerClient( + executableURL: fixtureURL(), + environment: [ + "CMUX_GHOSTTY_RENDER_FIXTURE_MUTATION_LOG": mutationLog.path, + "CMUX_GHOSTTY_RENDER_FIXTURE_RESIZE_RELEASE_REVISION": "2", + ] + ) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 6) + client.commandSink.enqueue(.createSurface(descriptor)) + _ = await collector.waitUntil { events in + events.contains(.surfaceCreated(surfaceID: id, surfaceGeneration: 6)) + } + + for offset in 1...128 { + client.commandSink.enqueue(resizeCommand( + id: id, + generation: 6, + width: UInt32(800 + offset), + height: UInt32(600 + offset) + )) + } + client.commandSink.enqueue(outputCommand( + id: id, + generation: 6, + sequence: 0, + text: "z" + )) + await client.updateConfiguration(configuration(revision: 2)) + + let events = await collector.waitUntil { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 6, + nextSequence: 1 + )) + } + await client.shutdown() + + #expect(events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 6, + nextSequence: 1 + ))) + #expect(try mutationLogLines(at: mutationLog) == [ + "resize 801 601", + "resize 928 728", + "output 0 eg==", + ]) + } + + @Test func processOutputRemainsAnOrderingBarrierBetweenResizeBursts() async throws { + let mutationLog = temporaryMutationLogURL() + defer { try? FileManager.default.removeItem(at: mutationLog) } + let client = try GhosttyRenderWorkerClient( + executableURL: fixtureURL(), + environment: [ + "CMUX_GHOSTTY_RENDER_FIXTURE_MUTATION_LOG": mutationLog.path, + "CMUX_GHOSTTY_RENDER_FIXTURE_RESIZE_RELEASE_REVISION": "2", + ] + ) + let collector = EventCollector(stream: await client.subscribeEvents()) + await client.updateConfiguration(configuration()) + + let id = UUID() + let descriptor = surfaceDescriptor(id: id, generation: 7) + client.commandSink.enqueue(.createSurface(descriptor)) + _ = await collector.waitUntil { events in + events.contains(.surfaceCreated(surfaceID: id, surfaceGeneration: 7)) + } + + client.commandSink.enqueue(resizeCommand(id: id, generation: 7, width: 900, height: 700)) + client.commandSink.enqueue(resizeCommand(id: id, generation: 7, width: 910, height: 710)) + client.commandSink.enqueue(outputCommand( + id: id, + generation: 7, + sequence: 0, + text: "a" + )) + client.commandSink.enqueue(resizeCommand(id: id, generation: 7, width: 920, height: 720)) + client.commandSink.enqueue(resizeCommand(id: id, generation: 7, width: 930, height: 730)) + client.commandSink.enqueue(outputCommand( + id: id, + generation: 7, + sequence: 1, + text: "b" + )) + await client.updateConfiguration(configuration(revision: 2)) + + let events = await collector.waitUntil { events in + events.contains(.outputApplied( + surfaceID: id, + surfaceGeneration: 7, + nextSequence: 2 + )) + } + await client.shutdown() + + let outputPositions = events.compactMap { event -> UInt64? in + guard case let .outputApplied(surfaceID, generation, nextSequence) = event, + surfaceID == id, + generation == 7 else { + return nil + } + return nextSequence + } + #expect(outputPositions == [1, 2]) + #expect(try mutationLogLines(at: mutationLog) == [ + "resize 900 700", + "resize 910 710", + "output 0 YQ==", + "resize 930 730", + "output 1 Yg==", + ]) + } + @Test func restartsHungWorkerOnceWithoutDuplicateExitEvents() async throws { let client = try GhosttyRenderWorkerClient( executableURL: URL(fileURLWithPath: "/bin/sleep"), @@ -344,6 +466,32 @@ import Testing context: 1 ) } + + private func resizeCommand( + id: UUID, + generation: UInt64, + width: UInt32, + height: UInt32 + ) -> TerminalRenderWorkerCommand { + .mutateSurface( + id: id, + generation: generation, + mutation: .resize(width: width, height: height) + ) + } + + private func outputCommand( + id: UUID, + generation: UInt64, + sequence: UInt64, + text: String + ) -> TerminalRenderWorkerCommand { + .mutateSurface( + id: id, + generation: generation, + mutation: .processOutput(sequence: sequence, bytes: Data(text.utf8)) + ) + } } private actor EventCollector { @@ -428,6 +576,17 @@ private func realWorkerURL() -> URL { builtExecutableURL(named: "cmux-ghostty-render-worker-test-host") } +private func temporaryMutationLogURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ghostty-render-mutations-\(UUID().uuidString).log") +} + +private func mutationLogLines(at url: URL) throws -> [String] { + try String(contentsOf: url, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map(String.init) +} + private func builtExecutableURL(named name: String) -> URL { let fileManager = FileManager.default var candidates: [URL] = [] diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift index c60ca6015d18..f9b9511f8b8c 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/GhosttyRemoteIOSurfaceLayer.swift @@ -59,7 +59,7 @@ public final class GhosttyRemoteIOSurfaceLayer: CALayer { /// Native mirror generation currently allowed to present. @MainActor public var surfaceGeneration: UInt64 { presentationState.surfaceGeneration } - /// Pixel dimensions frames must match before presentation. + /// Pixel dimensions currently desired by AppKit for diagnostics and scheduling. @MainActor public var expectedPixelSize: GhosttyRenderPixelSize { presentationState.expectedPixelSize } @@ -138,7 +138,7 @@ public final class GhosttyRemoteIOSurfaceLayer: CALayer { presentationState.lastAcceptedFrameSequence = nil } - /// Updates the required pixel dimensions without clearing the last frame. + /// Updates AppKit's desired pixel dimensions without clearing the last frame. @MainActor public func updateExpectedPixelSize(_ size: GhosttyRenderPixelSize) { presentationState.expectedPixelSize = size } @@ -148,8 +148,8 @@ public final class GhosttyRemoteIOSurfaceLayer: CALayer { contentsScale = Self.validatedBackingScaleFactor(backingScaleFactor) } - /// Presents a frame when every identity, generation, sequence, and size - /// fence matches. Rejected frames leave the current contents untouched. + /// Presents a frame when every identity, generation, sequence, and internal + /// dimension fence matches. Rejected frames leave the current contents untouched. @discardableResult @MainActor public func present(_ frame: TerminalRenderFrame) -> Bool { let metadata = frame.metadata @@ -157,10 +157,8 @@ public final class GhosttyRemoteIOSurfaceLayer: CALayer { let workerGeneration = presentationState.workerGeneration, metadata.workerGeneration == workerGeneration, metadata.surfaceGeneration == presentationState.surfaceGeneration, - metadata.width == presentationState.expectedPixelSize.width, - metadata.height == presentationState.expectedPixelSize.height, - IOSurfaceGetWidth(frame.surface) == Int(presentationState.expectedPixelSize.width), - IOSurfaceGetHeight(frame.surface) == Int(presentationState.expectedPixelSize.height), + IOSurfaceGetWidth(frame.surface) == Int(metadata.width), + IOSurfaceGetHeight(frame.surface) == Int(metadata.height), presentationState.lastAcceptedFrameSequence.map({ metadata.frameSequence > $0 }) ?? true else { return false } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift index 5a75c917ccf2..55de02f95ddb 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyRemoteIOSurfaceLayerTests.swift @@ -82,7 +82,7 @@ struct GhosttyRemoteIOSurfaceLayerTests { #expect(layer.present(try makeFrame(sequence: 9))) } - @Test func rejectsMetadataDimensionsThatDoNotMatchExpectedSize() throws { + @Test func rejectsMetadataWidthThatDoesNotMatchIOSurface() throws { let layer = makeLayer() let rejected = try makeFrame( metadataSize: GhosttyRenderPixelSize(width: 31, height: 24), @@ -94,11 +94,11 @@ struct GhosttyRemoteIOSurfaceLayerTests { #expect(layer.retainedSurface == nil) } - @Test func rejectsIOSurfaceDimensionsThatDoNotMatchMetadata() throws { + @Test func rejectsMetadataHeightThatDoesNotMatchIOSurface() throws { let layer = makeLayer() let rejected = try makeFrame( metadataSize: pixelSize, - surfaceSize: GhosttyRenderPixelSize(width: 31, height: 24) + surfaceSize: GhosttyRenderPixelSize(width: 32, height: 23) ) #expect(!layer.present(rejected)) diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift index 708a84c4d846..199776752244 100644 --- a/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift +++ b/Packages/macOS/CmuxTerminalRenderTransport/Sources/CmuxTerminalRenderTransport/TerminalRenderControlProtocol.swift @@ -4,7 +4,7 @@ public import Foundation /// faceless Ghostty render worker. public enum TerminalRenderProtocol { /// Bump when either side can no longer decode the other side's messages. - public static let currentVersion: UInt32 = 1 + public static let currentVersion: UInt32 = 2 } /// A bootstrap endpoint for the separate Mach channel that transfers frames. @@ -184,6 +184,10 @@ public enum TerminalRenderWorkerEvent: Codable, Equatable, Sendable { /// Highest contiguous output byte position applied by a mirror. case outputApplied(id: UUID, generation: UInt64, nextSequence: UInt64) + /// Pixel geometry applied by a mirror. The host uses this acknowledgement + /// to release the next ordered resize without accumulating stale sizes. + case resizeApplied(id: UUID, generation: UInt64, width: UInt32, height: UInt32) + /// Recoverable protocol/runtime diagnostic. User-facing UI is intentionally /// absent; supervision logs it and respawns when required. case failure(String) diff --git a/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift index 6e03e8a15ae2..cdb2f34bdb8f 100644 --- a/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift +++ b/Packages/macOS/CmuxTerminalRenderTransport/Tests/CmuxTerminalRenderTransportTests/TerminalRenderControlProtocolTests.swift @@ -72,6 +72,7 @@ import Testing .surfaceCreated(id: id, generation: 9), .surfaceDestroyed(id: id, generation: 9), .outputApplied(id: id, generation: 9, nextSequence: 1_024), + .resizeApplied(id: id, generation: 9, width: 1_600, height: 900), .failure("bad command"), ]