diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift index edcf8838f874..f072852e6dbc 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift @@ -263,16 +263,6 @@ public final class GhosttyRuntime { return true } - if action.tag == GHOSTTY_ACTION_RENDER { - guard target.tag == GHOSTTY_TARGET_SURFACE, - let surface = target.target.surface, - let bridge = GhosttySurfaceBridge.fromOpaque(ghostty_surface_userdata(surface)) else { return false } - Task { @MainActor [bridge] in - bridge.surfaceView?.drawForWakeup() - } - return true - } - if action.tag == GHOSTTY_ACTION_SET_TITLE { guard target.tag == GHOSTTY_TARGET_SURFACE, let surface = target.target.surface, diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift index 6ce70cf99a81..e94ec3dd5619 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift @@ -11,12 +11,16 @@ import UIKit final class GhosttySurfaceBridge: @unchecked Sendable { // lint:allow lock — sanctioned carve-out: serial low-level primitive hidden behind the type, guarding a single weak ref on the libghostty-callback / typing-latency path; actor rewrite tracked as the GhosttySurfaceView split follow-up. private let lock = NSLock() - // Deliberately STRONG: libghostty holds the raw view pointer - // (`ghostty_platform_ios_s.uiview`, passUnretained in `makeSurface`), so - // the view must outlive queued surface operations. Surface creation gives - // libghostty an owned bridge retain; dismantle detaches this reference to - // break the view<->bridge cycle, and final C-surface destruction releases - // the bridge only after internal callbacks and app-action leases stop. + // Deliberately STRONG despite forming a view<->bridge cycle: libghostty + // holds the raw view pointer (`ghostty_platform_ios_s.uiview`, + // passUnretained in `makeSurface`), so the view must outlive every queued + // surface operation. A weak back-reference would let the view deallocate + // while queued renderer work still dereferences that pointer + // (use-after-free). The cycle means a closed terminal's view/bridge/ + // surface are reclaimed only by the render-pipeline recovery rebuild, not + // by dismantle; fixing the leak needs retained-uiview / free-on-dismantle + // choreography, tracked in + // https://github.com/manaflow-ai/cmux/issues/7199. private var _surfaceView: GhosttySurfaceView? var surfaceView: GhosttySurfaceView? { @@ -68,11 +72,6 @@ final class GhosttySurfaceBridge: @unchecked Sendable { guard let userdata else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } - - static func releaseRetainedOpaque(_ userdata: UnsafeMutableRawPointer?) { - guard let userdata else { return } - Unmanaged.fromOpaque(userdata).release() - } } #endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift index ee6e635033ff..e872737116f3 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift @@ -207,6 +207,7 @@ extension GhosttySurfaceView { pendingSurfaceFreeCount += 1 enqueueSurfaceFree( oldSurface, + bridge: oldBridge, generation: surfaceGeneration, on: oldQueue ) { [weak self] in diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 1de2f599ed4b..5f5ffd1c4e35 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -2495,18 +2495,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // never use-after-free against the free, and no two of them ever touch // the surface concurrently. `processOutput`'s main-actor guard stops new // work from being enqueued once `surface` is nil, so only the bounded - // backlog drains before the free. libghostty owns bridge userdata through final destruction and every app-action lease. - enqueueSurfaceFree(surface, generation: surfaceGeneration, on: currentQueue) + // backlog drains before the free. (Retain the bridge across the hop; it + // owns the userdata libghostty still references until the free.) + enqueueSurfaceFree(surface, bridge: currentBridge, generation: surfaceGeneration, on: currentQueue) } func enqueueSurfaceFree( _ surface: ghostty_surface_t, + bridge: GhosttySurfaceBridge, generation: UInt64, on queue: GhosttySurfaceWorkQueue, completion: (@MainActor @Sendable () -> Void)? = nil ) { + let retainedBridge = Unmanaged.passRetained(bridge) surfaceFreeDrainWatchdog.start(generation: generation) { [weak self] in self?.pendingSurfaceFreeCount ?? 0 } queue.async { [weak self] in ghostty_surface_free(surface) + retainedBridge.release() Task { @MainActor in self?.surfaceFreeDrainWatchdog.cancel(generation: generation); completion?() } } } @@ -3461,8 +3465,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private func makeSurface(app: ghostty_app_t) -> ghostty_surface_t? { var surfaceConfig = ghostty_surface_config_new() - let retainedBridge = Unmanaged.passRetained(bridge) - let bridgePointer = retainedBridge.toOpaque() + let bridgePointer = Unmanaged.passUnretained(bridge).toOpaque() surfaceConfig.userdata = bridgePointer surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS surfaceConfig.platform = ghostty_platform_u( @@ -3481,10 +3484,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } surfaceConfig.io_write_userdata = bridgePointer - guard let createdSurface = ghostty_surface_new_with_owned_userdata( - app, &surfaceConfig, GhosttySurfaceBridge.releaseRetainedOpaque - ) else { - retainedBridge.release() + guard let createdSurface = ghostty_surface_new(app, &surfaceConfig) else { return nil } guard ghostty_surface_set_render_presented_callback( diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttyRuntimeActionTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttyRuntimeActionTests.swift deleted file mode 100644 index 3362dc2e46e1..000000000000 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttyRuntimeActionTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -#if canImport(UIKit) -import CMUXMobileCore -import GhosttyKit -import Testing -import UIKit - -@testable import CmuxMobileTerminal - -@Suite("Ghostty runtime actions") -struct GhosttyRuntimeActionTests { - @MainActor - @Test("renderer continuation actions request another frame") - func rendererContinuationActionRequestsAnotherFrame() async throws { - let runtime = try GhosttyRuntime.shared() - let delegate = RendererContinuationTestDelegate() - let view = GhosttySurfaceView(runtime: runtime, delegate: delegate) - let controller = UIViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - controller.view.addSubview(view) - window.rootViewController = controller - window.makeKeyAndVisible() - defer { - view.prepareForDismantle() - window.isHidden = true - } - - let surface = try #require(view.surface) - view.needsDraw = false - #expect( - GhosttyRuntime.simulateSurfaceActionForTesting( - surface: surface, - tag: GHOSTTY_ACTION_RENDER - ) - ) - for _ in 0..<10 where !view.needsDraw { - await Task.yield() - } - #expect(view.needsDraw) - } - - @MainActor - @Test("stale renderer continuations do not follow reused surface addresses") - func staleRendererContinuationDoesNotTargetReplacementView() async throws { - let runtime = try GhosttyRuntime.shared() - let delegate = RendererContinuationTestDelegate() - let sourceView = GhosttySurfaceView(runtime: runtime, delegate: delegate) - let replacementView = GhosttySurfaceView(runtime: runtime, delegate: delegate) - let controller = UIViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) - controller.view.addSubview(sourceView) - controller.view.addSubview(replacementView) - window.rootViewController = controller - window.makeKeyAndVisible() - defer { - sourceView.prepareForDismantle() - replacementView.prepareForDismantle() - window.isHidden = true - } - - let sourceSurface = try #require(sourceView.surface) - let bridge = try #require( - GhosttySurfaceBridge.fromOpaque(ghostty_surface_userdata(sourceSurface)) - ) - replacementView.stopDisplayLink() - replacementView.needsDraw = false - - #expect( - GhosttyRuntime.simulateSurfaceActionForTesting( - surface: sourceSurface, - tag: GHOSTTY_ACTION_RENDER - ) - ) - - // Model the source surface being detached and its raw address being - // reused before the queued MainActor continuation gets a turn. - bridge.detach() - GhosttySurfaceView.register(surface: sourceSurface, for: replacementView) - - for _ in 0..<10 where !replacementView.needsDraw { - await Task.yield() - } - #expect(!replacementView.needsDraw) - } -} - -@MainActor -private final class RendererContinuationTestDelegate: GhosttySurfaceViewDelegate { - func ghosttySurfaceView( - _ surfaceView: GhosttySurfaceView, - didProduceInput data: Data - ) {} - - func ghosttySurfaceView( - _ surfaceView: GhosttySurfaceView, - didResize size: TerminalGridSize, - reportID: UInt64 - ) {} -} -#endif diff --git a/Sources/App/ShortcutBareStartRouting.swift b/Sources/App/ShortcutBareStartRouting.swift index bdb0916cbead..12971d5807d0 100644 --- a/Sources/App/ShortcutBareStartRouting.swift +++ b/Sources/App/ShortcutBareStartRouting.swift @@ -69,43 +69,6 @@ func bareShortcutFastPathKey(for event: NSEvent) -> String? { } extension AppDelegate { -#if DEBUG - /// Process environment is fixed at launch; snapshot this DEBUG-only trace - /// opt-in instead of rebuilding the environment on every keystroke. - static let shortcutMonitorTraceEnvironmentEnabled = - ProcessInfo.processInfo.environment["CMUX_SHORTCUT_MONITOR_TRACE"] == "1" -#endif - - /// Returns the already-resolved tab manager when plain terminal text can - /// bypass browser, palette, and workspace shortcut-context resolution. - func terminalTextShortcutBypassTabManagerBeforeContextResolution( - event: NSEvent, - normalizedFlags: NSEvent.ModifierFlags - ) -> TabManager? { - guard normalizedFlags.isEmpty, - activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - event.cmuxIsPrintableTextInput, - let window = event.window ?? shortcutRoutingKeyWindow, - window.firstResponder is GhosttyNSView, - NSApp.modalWindow == nil, - window.attachedSheet == nil, - let windowId = mainWindowId(from: window), - let tabManager = tabManagerFor(windowId: windowId), - !commandPaletteWindowStore.isVisible(windowId), - !commandPaletteWindowStore.isPendingOpenRaw(windowId), - !NotificationsPopoverVisibilityState.shared.isShown(in: window.windowNumber) else { - return nil - } - - guard shouldBypassPlainKeyShortcutRouting( - event: event, - normalizedFlags: normalizedFlags - ) else { - return nil - } - return tabManager - } - func shouldBypassPlainKeyShortcutRouting( event: NSEvent, normalizedFlags: NSEvent.ModifierFlags @@ -129,13 +92,3 @@ extension AppDelegate { } } } - -private extension NSEvent { - var cmuxIsPrintableTextInput: Bool { - guard let characters, !characters.isEmpty else { return false } - return characters.unicodeScalars.allSatisfy { scalar in - !CharacterSet.controlCharacters.contains(scalar) - && (scalar.value < 0xF700 || scalar.value > 0xF8FF) - } - } -} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4f9d1ead30ba..6a0e6d7c08a8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -12552,7 +12552,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var shortcutMs: Double = 0 CmuxTypingTiming.logEventDelay(path: "appMonitor", event: event) let shortcutMonitorTraceEnabled = - Self.shortcutMonitorTraceEnvironmentEnabled + ProcessInfo.processInfo.environment["CMUX_SHORTCUT_MONITOR_TRACE"] == "1" || UserDefaults.standard.bool(forKey: "cmuxShortcutMonitorTrace") if shortcutMonitorTraceEnabled { let frType = shortcutRoutingKeyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -12932,31 +12932,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // charactersIgnoringModifiers returns non-ASCII characters that never match // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. + let chars = KeyboardLayout.normalizedCharacters(for: event) let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasControl = flags.contains(.control) + let hasCommand = flags.contains(.command) + let hasOption = flags.contains(.option) + let isControlOnly = hasControl && !hasCommand && !hasOption + let controlDChar = chars == "d" || event.characters == "\u{04}" + let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) if let pendingConfiguredShortcutChord, - pendingConfiguredShortcutChord.windowNumber == configuredShortcutChordWindowNumber(for: event) { + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke } else { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } pendingConfiguredShortcutChord = nil defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil; clearShortcutEventFocusContextCache(for: event) } - - if let textBoxShortcutTabManager = terminalTextShortcutBypassTabManagerBeforeContextResolution( - event: event, - normalizedFlags: flags.subtracting([.numericPad, .function, .capsLock]) - ) { - textBoxShortcutTabManager.clearFocusedTerminalTextBoxHideEscapeArm() - return false - } - - let chars = KeyboardLayout.normalizedCharacters(for: event) - let hasControl = flags.contains(.control) - let hasCommand = flags.contains(.command) - let hasOption = flags.contains(.option) - let isControlOnly = hasControl && !hasCommand && !hasOption - let controlDChar = chars == "d" || event.characters == "\u{04}" - let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) #if DEBUG if isControlD { writeChildExitKeyboardProbe( diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index b12bb957b51d..52b0d4f1fcad 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -278,13 +278,10 @@ extension TerminalController { ) content.apply(to: alert, presentingWindow: nil) - switch alert.runModal() { - case .alertFirstButtonReturn: - return .auto - case .alertSecondButtonReturn: - return .prompt - default: - return .manual + return switch alert.runModal() { + case .alertFirstButtonReturn: .auto + case .alertSecondButtonReturn: .prompt + default: .manual } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3c07dcfed45b..17240a0acc17 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -190,7 +190,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; C6711A030000000000000001 /* AppDelegateSurfaceResumeTerminalIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B030000000000000001 /* AppDelegateSurfaceResumeTerminalIdTests.swift */; }; 4E6A6F5C1D2B4980A1234568 /* AppDelegateSurfaceShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */; }; - F8808A000000000000000001 /* AppDelegateTerminalTypingShortcutFastPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8808A000000000000000002 /* AppDelegateTerminalTypingShortcutFastPathTests.swift */; }; F47126CFDAE244988A92E2D0 /* AppDelegateWindowFrameReconcileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD21AD844E5D4D50A6DFC442 /* AppDelegateWindowFrameReconcileTests.swift */; }; A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAB000000000000000001 /* AppearanceSettings.swift */; }; A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAA000000000000000001 /* AppearanceSettingsTests.swift */; }; @@ -2501,7 +2500,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; C6711B030000000000000001 /* AppDelegateSurfaceResumeTerminalIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateSurfaceResumeTerminalIdTests.swift; sourceTree = ""; }; 4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateSurfaceShortcutRoutingTests.swift; sourceTree = ""; }; - F8808A000000000000000002 /* AppDelegateTerminalTypingShortcutFastPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateTerminalTypingShortcutFastPathTests.swift; sourceTree = ""; }; FD21AD844E5D4D50A6DFC442 /* AppDelegateWindowFrameReconcileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateWindowFrameReconcileTests.swift; sourceTree = ""; }; A11EAB000000000000000001 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; A11EAA000000000000000001 /* AppearanceSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsTests.swift; sourceTree = ""; }; @@ -6363,7 +6361,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F5588001A1B2C3D4E5F60718 /* AppDelegateOptionDigitShortcutRoutingTests.swift */, - F8808A000000000000000002 /* AppDelegateTerminalTypingShortcutFastPathTests.swift */, 6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */, 4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */, 6512F0C06512F0C06512F001 /* MainWindowFocusRestoreTests.swift */, @@ -8938,7 +8935,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, C6711A030000000000000001 /* AppDelegateSurfaceResumeTerminalIdTests.swift in Sources */, 4E6A6F5C1D2B4980A1234568 /* AppDelegateSurfaceShortcutRoutingTests.swift in Sources */, - F8808A000000000000000001 /* AppDelegateTerminalTypingShortcutFastPathTests.swift in Sources */, F47126CFDAE244988A92E2D0 /* AppDelegateWindowFrameReconcileTests.swift in Sources */, A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */, A7206E010000000000000001 /* AppIconAppearanceObserverTests.swift in Sources */, diff --git a/cmuxTests/AppDelegateTerminalTypingShortcutFastPathTests.swift b/cmuxTests/AppDelegateTerminalTypingShortcutFastPathTests.swift deleted file mode 100644 index 0e8eb325ff87..000000000000 --- a/cmuxTests/AppDelegateTerminalTypingShortcutFastPathTests.swift +++ /dev/null @@ -1,154 +0,0 @@ -import AppKit -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -@Suite(.serialized) -struct AppDelegateTerminalTypingShortcutFastPathTests { -#if DEBUG - @Test - func plainTerminalTextDoesNotResolveAppShortcutContext() throws { - let appDelegate = try #require(AppDelegate.shared) - appDelegate.debugResetShortcutRoutingStateForTesting() - NotificationsPopoverVisibilityState.shared.resetForTesting() - - let windowId = appDelegate.createMainWindow() - defer { - KeyboardShortcutSettings.shortcutLookupObserver = nil - closeWindow(withId: windowId) - appDelegate.debugResetShortcutRoutingStateForTesting() - } - - let terminalWindow = try #require(findMainWindow(withId: windowId)) - appDelegate.debugSetShortcutRoutingFocusedWindowForTesting(terminalWindow) - let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) - let workspace = try #require(manager.selectedWorkspace) - let panelId = try #require(workspace.focusedPanelId) - let terminalPanel = try #require(workspace.terminalPanel(for: panelId)) - - terminalWindow.makeKeyAndOrderFront(nil) - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - terminalWindow.displayIfNeeded() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - #expect( - terminalWindow.firstResponder === terminalPanel.hostedView.surfaceView, - "The regression must exercise a terminal-owned key event" - ) - - var resolvedActions: [KeyboardShortcutSettings.Action] = [] - KeyboardShortcutSettings.shortcutLookupObserver = { action in - resolvedActions.append(action) - } - - let event = try keyEvent(characters: "a", keyCode: 0) - - #expect( - event.window == nil, - "The regression must match the nil-window event shape from the local monitor" - ) - #expect(!appDelegate.debugHandleCustomShortcut(event: event)) - #expect( - resolvedActions.isEmpty, - "Plain terminal text must bypass browser, palette, workspace, and app-wide shortcut resolution" - ) - } - - @Test - func plainTerminalTextDisarmsSecondEscapeTextBoxHide() throws { - let appDelegate = try #require(AppDelegate.shared) - appDelegate.debugResetShortcutRoutingStateForTesting() - - let windowId = appDelegate.createMainWindow() - defer { - closeWindow(withId: windowId) - appDelegate.debugResetShortcutRoutingStateForTesting() - } - - let terminalWindow = try #require(findMainWindow(withId: windowId)) - let contentView = try #require(terminalWindow.contentView) - appDelegate.debugSetShortcutRoutingFocusedWindowForTesting(terminalWindow) - let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) - let workspace = try #require(manager.selectedWorkspace) - let panelId = try #require(workspace.focusedPanelId) - let terminalPanel = try #require(workspace.terminalPanel(for: panelId)) - let textBoxView = TextBoxInputTextView( - frame: NSRect(x: 0, y: 0, width: 240, height: 30) - ) - let textBoxScrollView = NSScrollView( - frame: NSRect(x: 0, y: 0, width: 240, height: 30) - ) - textBoxScrollView.documentView = textBoxView - contentView.addSubview(textBoxScrollView) - defer { textBoxScrollView.removeFromSuperview() } - - terminalWindow.makeKeyAndOrderFront(nil) - terminalWindow.displayIfNeeded() - terminalPanel.hostedView.setVisibleInUI(true) - terminalPanel.hostedView.setActive(true) - terminalPanel.hostedView.moveFocus() - terminalPanel.registerTextBoxInputView(textBoxView) - #expect(terminalPanel.toggleTextBoxInput()) - RunLoop.main.run(until: Date.now.addingTimeInterval(0.05)) - #expect(terminalWindow.firstResponder === textBoxView) - - terminalPanel.handleTextBoxEscape() - RunLoop.main.run(until: Date.now.addingTimeInterval(0.05)) - #expect(terminalPanel.hostedView.isSurfaceViewFirstResponder()) - #expect(terminalPanel.debugHasTextBoxHideEscapeArm) - - #expect(!appDelegate.debugHandleCustomShortcut( - event: try keyEvent(characters: "a", keyCode: 0) - )) - #expect( - !terminalPanel.debugHasTextBoxHideEscapeArm, - "Ordinary terminal typing must break the consecutive-Escape sequence" - ) - - #expect(!appDelegate.debugHandleCustomShortcut( - event: try keyEvent(characters: "\u{1B}", keyCode: 53) - )) - #expect( - terminalPanel.isTextBoxActive, - "Escape after intervening terminal text must not hide the text box" - ) - } - - private func keyEvent(characters: String, keyCode: UInt16) throws -> NSEvent { - try #require( - NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - // Local CGEvent monitors synthesize key events without an - // attached NSWindow, even when a cmux terminal is key. - windowNumber: 0, - context: nil, - characters: characters, - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: keyCode - ) - ) - } - - private func findMainWindow(withId windowId: UUID) -> NSWindow? { - let identifier = "cmux.main.\(windowId.uuidString)" - return NSApp.windows.first { $0.identifier?.rawValue == identifier } - } - - private func closeWindow(withId windowId: UUID) { - guard let window = findMainWindow(withId: windowId) else { return } - window.close() - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - } -#endif -} diff --git a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift index caf8c95cab95..5b4dd0370c5e 100644 --- a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift +++ b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift @@ -1,5 +1,4 @@ import AppKit -import CmuxSettings import CmuxSidebar import CmuxWorkspaces import Testing diff --git a/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift b/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift index 55529889002c..99dc201989fd 100644 --- a/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift +++ b/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift @@ -1,5 +1,4 @@ import AppKit -import CmuxFoundation import SwiftUI import Testing @testable import cmux_DEV diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 39e24b02d191..30ad120b8654 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -13,19 +13,9 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes The submodule pinned by this branch is -`50ad1963d9c73ee957932ccb4d26bf6d15575ee7`, the current -`manaflow-ai/ghostty` `main`. The complete renderer scheduling hardening landed -through https://github.com/manaflow-ai/ghostty/pull/136 after the initial -bounded-turn fix in https://github.com/manaflow-ai/ghostty/pull/135. Reliable -external redraw delivery and surface lifetime retention landed through -https://github.com/manaflow-ai/ghostty/pull/139. Embedder userdata ownership -and callback lifetime hardening landed through -https://github.com/manaflow-ai/ghostty/pull/140. Serial frame-lease rotation -landed through https://github.com/manaflow-ai/ghostty/pull/145. Dead PTY reader -and child cleanup landed through -https://github.com/manaflow-ai/ghostty/pull/143. The cumulative external -frontend integration landed through -https://github.com/manaflow-ai/ghostty/pull/128, and the earlier stacked PRs +`c55514dd52d806e9aa661ee20381aa19c91c1c09`, the current +`manaflow-ai/ghostty` `main`. The cumulative integration landed through +https://github.com/manaflow-ai/ghostty/pull/128; the earlier stacked PRs https://github.com/manaflow-ai/ghostty/pull/127, https://github.com/manaflow-ai/ghostty/pull/123, and https://github.com/manaflow-ai/ghostty/pull/122 are now merged or superseded. @@ -34,100 +24,12 @@ https://github.com/manaflow-ai/ghostty/pull/132 before that cumulative merge. The resulting main line supplies the external-frontend renderer contract used by cmux Browser, exact cursor state for process-separated terminal mirrors, mutable-default color reset semantics, nonblocking embedded lifecycle updates, -and the product-main renderer/link fixes described below. It also bounds each -renderer mailbox drain turn so continuous producers cannot starve lifecycle -processing or rendering. +and the product-main renderer/link fixes described below. Its universal ReleaseFast GhosttyKit archive is published at -https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-50ad1963d9c73ee957932ccb4d26bf6d15575ee7-crashsubdir-cmux-crash-v1 +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-c55514dd52d806e9aa661ee20381aa19c91c1c09-crashsubdir-cmux-crash-v1 and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. -### Bounded renderer mailbox turns and continuation recovery - -- Commits: - - `188d31a97` (fix: bound renderer mailbox drain turns) - - `18c3fd311` (renderer: preserve progress across wake errors) - - `727a7dc02` (fix: drain external renderer continuations) - - `994fee1b0` (merge the complete bounded-drain follow-up) -- Files: - - `src/datastruct/blocking_queue.zig` - - `src/renderer/Thread.zig` -- Summary: - - Limits one renderer turn to the mailbox depth observed when the turn - begins. Messages added by concurrent producers remain FIFO-ordered for the - next turn. - - Applies latest-value lifecycle state and performs the pending render after - every bounded batch, even when terminal output keeps refilling the mailbox. - - Rechecks after rendering and explicitly re-wakes the normal renderer when - work arrived during either the drain or render, because producer - notifications may have coalesced with the wake being handled. - - External iOS rendering, which permanently disables the xev callback, drains - each finite continuation batch until quiescent on its serial render queue. - - Restores failed focus/display lifecycle requests only when their atomic - slots are still empty, preserving newer concurrent publications and making - focus application transactional for a later retry. - - Conflict note: future renderer-loop changes must preserve bounded progress - for lifecycle state and rendering, normal-path post-render re-wakes, and - external-path continuation consumption. Do not replace the snapshot drain - with an unbounded producer-refillable drain-until-empty loop. - -### External redraw delivery and surface lifetime - -- Commits: - - `d1efafd78` (fix: retain rejected external redraw requests) - - `62e1de720` (fix: ticket external redraw deliveries) - - `741b11662` (fix: bind redraw tickets to surface lifetimes) - - `cf1dee45d` (fix: retain surfaces through app action dispatch) - - `d3265f4c5` (merge the reviewed redraw-delivery follow-up) -- Files: - - `src/App.zig` - - `src/Surface.zig` - - `src/apprt/embedded.zig` - - `src/apprt/gtk/Surface.zig` - - `src/renderer/Thread.zig` -- Summary: - - Assigns one generation-scoped redraw ticket to each external surface so a - rejected app-mailbox enqueue has one retained retry owner. - - Distinguishes queued work from enqueue failure, retries only after mailbox - capacity returns, and rejects stale acknowledgments or allocator-address - reuse from an older surface lifetime. - - Retains the surface allocation while the host render action is dispatched, - allowing reentrant teardown to unregister immediately while deferring final - destruction until the callback returns. - - Conflict note: external redraw changes must preserve per-surface ticket - ownership, generation checks, enqueue-failure retry ownership, and the app - action lifetime lease. A raw surface pointer is not a sufficient delivery - identity across asynchronous dispatch. - -### Embedder userdata ownership and callback lifetime - -- Commits: - - `289097387` (fix: bind embedder userdata to surface lifetime) - - `76c8b03d8` (fix: retain userdata across every host callback) - - `365fe1d2c` (fix: lease setter-installed PTY tee callbacks) - - `98288feb2` (merge the owned-userdata lifetime fix) -- Files: - - `include/ghostty.h` - - `src/apprt/embedded.zig` -- Summary: - - Adds `ghostty_surface_new_with_owned_userdata` without changing - `ghostty_surface_config_s`, preserving the existing C ABI for borrowed - callers. - - Tracks embedder userdata through explicit borrowed, owned, and released - states. A successful owned construction transfers the host reference to - Ghostty; failed construction leaves ownership with the caller. - - Leases owned userdata across surface-targeted app actions and every host - callback, including PTY tee callbacks installed both during and after - construction. - - Defers the exactly-once final release until surface teardown and all - in-flight callbacks have quiesced, preventing host bridge destruction while - Ghostty can still call through its userdata. - - Conflict note: future embedder callback or teardown changes must acquire a - userdata lease before leaving Ghostty-owned synchronization, and must retain - the failed-creation ownership contract. Do not restore split host/Ghostty - release ownership or release the owned userdata directly from surface-free - call sites. - ### Nonblocking renderer lifecycle state - Commits: @@ -189,28 +91,6 @@ and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. Platform enum values and the combined surface ABI are externally consumed and must not be renumbered implicitly. -### Serial frame-lease rotation - -- Commits: - - `3a43d5edc` (test: require serial frame slot rotation) - - `fcafac572` (fix: rotate serial frame leases) - - `50ad1963d` (merge the frame-lease rotation fix) -- File: - - `src/renderer/frame_lease.zig` -- Summary: - - Rotates the free-slot search after every successful acquisition. A serial - producer therefore presents distinct IOSurface objects even when each Metal - frame completes before the next input event. - - Preserves exact-slot ownership, generation tokens, out-of-order release - safety, and semaphore backpressure; only the choice among currently free - slots changes. - - Prevents Core Animation from deduplicating repeated assignments of one - IOSurface while its pixels change underneath it, which otherwise batches - low-rate terminal echo until unrelated layer activity triggers recomposition. - - Conflict note: future lease-pool refactors must retain round-robin selection - among free slots. A fixed first-free scan reintroduces serial-render stalls - even when every GPU completion and renderer wake is timely. - ### Cursor visual and replay continuity state - Commits: @@ -248,53 +128,6 @@ and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. - Conflict note: reset must continue to mean "no override"; snapshotting the current default recreates stale colors after a later frontend theme update. -### Unindented hard-newline link continuations - -- Pull request: https://github.com/manaflow-ai/ghostty/pull/134 -- Commits: - - `823641e234c3c6bf4bc5badb72261d8a6fc37232` (fix: join unindented wrapped links) - - `f6b47c8371991a4555f907737e808f161c368661` (merge the link continuation fix) -- Files: - - `src/Surface.zig` - - `src/link.zig` - - `src/link_wrap.zig` -- Summary: - - Uses one shared continuation classifier for terminal-grid candidate - expansion and newline normalization, so hover, copy, preview, and open all - resolve the same complete link. - - Recognizes unindented hard-newline continuations after link punctuation - while preserving the existing indented continuation behavior. - - Keeps conservative boundaries for explicit schemes and roots, semantic - prompt transitions, unrelated indentation, and trailing sentence - punctuation. - - Conflict note: link-grid expansion and newline normalization must continue - to share the classifier; duplicating the continuation decision can make - hover and activation disagree. - -### Bounded Kitty graphics state - -- Pull request: https://github.com/manaflow-ai/ghostty/pull/137 -- Commit: - - `b7feeea5c0ee041f8cb79aace2129efad31df19d` (merge bounded Kitty graphics state) -- Files: - - `include/ghostty/vt/{kitty_graphics.h,terminal.h,types.h}` - - `src/lib_vt.zig` - - `src/terminal/{Screen.zig,Terminal.zig}` - - `src/terminal/c/{kitty_graphics.zig,main.zig,terminal.zig}` - - `src/terminal/kitty/{graphics.zig,graphics_exec.zig,graphics_image.zig,graphics_storage.zig,graphics_unicode.zig}` -- Summary: - - Bounds per-screen Kitty image and placement storage, in-progress image - loads, allocation sizes, and eviction scans. - - Exposes the lib-vt C ABI for image and placement enumeration, restores - image-number aliases, and reports anonymous placement identity. - - Adds renderer-owned graphics dirty/damage state for incremental external - snapshots. - - Applies limit changes atomically and preserves replacements while cleaning - placement pins during replacement and eviction. - - Conflict note: future Kitty storage changes must preserve bounded resource - use, atomic limit updates, alias/enumeration ABI behavior, and placement-pin - cleanup. - ## Reconciled product-main line The product-main line had advanced independently to `b211341be` while the diff --git a/ghostty b/ghostty index 50ad1963d9c7..c55514dd52d8 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 50ad1963d9c73ee957932ccb4d26bf6d15575ee7 +Subproject commit c55514dd52d806e9aa661ee20381aa19c91c1c09 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index a0713649da6d..f3263353e1da 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -80,8 +80,3 @@ fedd33703c3c49641e4d8b18ca969b5274cd0a91 321b2c79ea9bb00d4285492ab6a705330650fa5 b211341be1ba902e772f57fc67c3e65d35205676 09aa0ae53edc7ef2ca04e13ea820f6f0861b24f851ac7f2fae28b978eec980a3 ade1de1f4213bdf34a387b255906ce2aae0c4da2 6ee306df0f7faf6aa7fd3af5cebaf1795cb378e284e0f78e6e6ed28210a6a2d9 c55514dd52d806e9aa661ee20381aa19c91c1c09 7eafa164c893dbb28a7cfbfd92035c5095e289c6389699cdd285c10dbed37aa2 -188d31a97733fe6717acf8203f76a8bb20cddc19 ffe5320ee0f0add04146f319c44345262eba4351bc7650f06595f926f0ea6109 -994fee1b053820dc6a93658901024289372a6a5c c28f0dfbb274963f0a1a0d7a3a1186a3e1daa25ddcfdab7bed2f4e9ededb5c47 -d3265f4c5ea9985de34e488319e7fb2d0b2693c8 024113eb95ee73324a0714f0b6eab6cccc505c01ff211e95d710fd6c998da1cd -98288feb22e7625f3ec0856573107b814686ab5f 133a76df05c7a2411b35dd340648952b9e0b70891adee5f280c7f9158d129872 -50ad1963d9c73ee957932ccb4d26bf6d15575ee7 a15d3b58c46cac5ec8cf804b50a5bf3f75ecb390037d6959c5bc3d1864a7e83c