diff --git a/Sources/App/WindowKeyDownReplayGuard.swift b/Sources/App/WindowKeyDownReplayGuard.swift new file mode 100644 index 000000000000..c610c3ffecbd --- /dev/null +++ b/Sources/App/WindowKeyDownReplayGuard.swift @@ -0,0 +1,71 @@ +import AppKit + +/// Identity of a key event currently being force-dispatched into a responder's +/// `keyDown(with:)` by `NSWindow.cmux_performKeyEquivalent(with:)`. +/// +/// Forwarding keyDown can re-enter `performKeyEquivalent` with the same event +/// while the dispatch is still on the stack: WebKit replays unhandled keys +/// through the responder chain, and on macOS 26 `-[NSWindow keyDown:]` +/// re-enters `performKeyEquivalent`. Without a replay guard at the dispatch +/// chokepoint the same event ping-pongs between the swizzle and the focused +/// responder until the main-thread stack overflows +/// (https://github.com/manaflow-ai/cmux/issues/5887). +/// +/// Identity is the event's stable field tuple rather than object identity so +/// the guard still holds if AppKit/WebKit re-deliver the event as an equal +/// copy. Key autorepeat produces distinct events (fresh timestamps), so +/// repeat typing is never throttled. The dispatching window's number is part +/// of the identity so windows cannot suppress each other's dispatches. +private struct CmuxForceDispatchedKeyEventIdentity: Hashable { + let windowNumber: Int + let eventType: UInt + let keyCode: UInt16 + let modifierFlags: UInt + let timestamp: TimeInterval +} + +/// Events whose force-dispatch is currently on the main-thread stack. +/// Main-thread only (key-event dispatch); entries are stack-scoped, inserted +/// before `keyDown(with:)` and removed when the dispatch unwinds, so WebKit's +/// legitimate replay of an unhandled key (which arrives after the original +/// dispatch has fully unwound) is still force-dispatched normally. +private var cmuxInFlightForceDispatchedKeyEventIdentities = Set() + +extension NSWindow { + /// Single chokepoint for every direct `keyDown(with:)` force-dispatch made + /// by `cmux_performKeyEquivalent(with:)`. + /// + /// Dispatches `event` into `target`'s `keyDown(with:)` unless the same + /// event is already being force-dispatched lower on this window's call + /// stack, and returns whether the dispatch happened. Callers that get + /// `false` back must decline the event (fall through to default AppKit + /// handling) instead of dispatching themselves; re-dispatching the same + /// in-flight event is the infinite key-routing loop from + /// https://github.com/manaflow-ai/cmux/issues/5887. + func cmuxForceDispatchKeyDownOnce( + _ event: NSEvent, + to target: NSResponder, + reason: @autoclosure () -> String + ) -> Bool { + let identity = CmuxForceDispatchedKeyEventIdentity( + windowNumber: self.windowNumber, + eventType: event.type.rawValue, + keyCode: event.keyCode, + modifierFlags: event.modifierFlags.rawValue, + timestamp: event.timestamp + ) + guard !cmuxInFlightForceDispatchedKeyEventIdentities.contains(identity) else { +#if DEBUG + cmuxDebugLog(" → \(reason()) reentry; declining force-dispatch of in-flight key event") +#endif + return false + } + cmuxInFlightForceDispatchedKeyEventIdentities.insert(identity) + defer { cmuxInFlightForceDispatchedKeyEventIdentities.remove(identity) } +#if DEBUG + cmuxDebugLog(" → \(reason()) routed to firstResponder.keyDown") +#endif + target.keyDown(with: event) + return true + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 498a342ef2ea..4bc99bba54c2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16552,13 +16552,6 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView? private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? -private var cmuxBrowserReturnForwardingDepth = 0 -private var cmuxBrowserArrowForwardingDepth = 0 -private var cmuxBrowserOmnibarMarkedTextForwardingDepth = 0 -private var cmuxCommandPaletteArrowForwardingDepth = 0 -private var cmuxTextBoxInputArrowForwardingDepth = 0 -private var cmuxTextBoxInputControlNavForwardingDepth = 0 -private var cmuxEditableTextViewArrowForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -17251,11 +17244,13 @@ private extension NSWindow { ?? firstResponderWebView ?? self.firstResponder if let textInputTarget, textInputTarget !== self { - textInputTarget.keyDown(with: event) -#if DEBUG - cmuxDebugLog(" → printable Option text routed to keyDown") -#endif - return true + if cmuxForceDispatchKeyDownOnce(event, to: textInputTarget, reason: "printable Option text") { + return true + } + // Same event already in flight on this stack (WebKit replay / + // macOS 26 NSWindow.keyDown re-entry): decline so default + // AppKit handling proceeds instead of looping. + return false } return false } @@ -17275,8 +17270,12 @@ private extension NSWindow { #endif return true } - if let firstResponderGhosttyView { - firstResponderGhosttyView.keyDown(with: event) + if let firstResponderGhosttyView, + cmuxForceDispatchKeyDownOnce( + event, + to: firstResponderGhosttyView, + reason: "stale cmux menu shortcut terminal bypass" + ) { #if DEBUG cmuxDebugLog(" → terminal received command equivalent bypassing stale cmux menu shortcut") #endif @@ -17316,11 +17315,13 @@ private extension NSWindow { keyCode: event.keyCode, literalChars: event.characters ) { - ghosttyView.keyDown(with: event) + if cmuxForceDispatchKeyDownOnce(event, to: ghosttyView, reason: "terminal font zoom") { #if DEBUG - cmuxDebugLog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") + cmuxDebugLog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") #endif - return true + return true + } + return false } } @@ -17329,26 +17330,16 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxBrowserOmnibarMarkedTextForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser omnibar marked-text reentry; leaving unhandled") -#endif + guard let target = self.firstResponder, + cmuxForceDispatchKeyDownOnce( + event, + to: target, + reason: "browser omnibar marked-text " + + "panel=\(firstResponderOmnibarPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil")" + ) + else { return false } - cmuxBrowserOmnibarMarkedTextForwardingDepth += 1 - defer { - cmuxBrowserOmnibarMarkedTextForwardingDepth = max( - 0, - cmuxBrowserOmnibarMarkedTextForwardingDepth - 1 - ) - } -#if DEBUG - cmuxDebugLog( - " → browser omnibar marked-text routed to firstResponder.keyDown " + - "panel=\(firstResponderOmnibarPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil")" - ) -#endif - self.firstResponder?.keyDown(with: event) return true } @@ -17358,12 +17349,11 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxCommandPaletteArrowForwardingDepth > 0 { + guard let target = self.firstResponder, + cmuxForceDispatchKeyDownOnce(event, to: target, reason: "command palette arrow") + else { return false } - cmuxCommandPaletteArrowForwardingDepth += 1 - defer { cmuxCommandPaletteArrowForwardingDepth = max(0, cmuxCommandPaletteArrowForwardingDepth - 1) } - self.firstResponder?.keyDown(with: event) return true } @@ -17373,22 +17363,17 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxBrowserArrowForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser omnibar arrow reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) + guard let target = self.firstResponder else { return false } + if cmuxForceDispatchKeyDownOnce( + event, + to: target, + reason: "browser omnibar arrow " + + "panel=\(firstResponderOmnibarPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil")" + ) { + return true } - cmuxBrowserArrowForwardingDepth += 1 - defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } -#if DEBUG - cmuxDebugLog( - " → browser omnibar arrow routed to firstResponder.keyDown " + - "panel=\(firstResponderOmnibarPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil")" - ) -#endif - self.firstResponder?.keyDown(with: event) - return true + // Reentry of the same in-flight event: use normal dispatch. + return cmux_performKeyEquivalent(with: event) } if shouldDispatchTextBoxInputArrowViaFirstResponderKeyDown( @@ -17397,12 +17382,11 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxTextBoxInputArrowForwardingDepth > 0 { + guard let target = self.firstResponder, + cmuxForceDispatchKeyDownOnce(event, to: target, reason: "text-box input arrow") + else { return false } - cmuxTextBoxInputArrowForwardingDepth += 1 - defer { cmuxTextBoxInputArrowForwardingDepth = max(0, cmuxTextBoxInputArrowForwardingDepth - 1) } - self.firstResponder?.keyDown(with: event) return true } @@ -17412,12 +17396,11 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxTextBoxInputControlNavForwardingDepth > 0 { + guard let target = self.firstResponder, + cmuxForceDispatchKeyDownOnce(event, to: target, reason: "text-box input control nav") + else { return false } - cmuxTextBoxInputControlNavForwardingDepth += 1 - defer { cmuxTextBoxInputControlNavForwardingDepth = max(0, cmuxTextBoxInputControlNavForwardingDepth - 1) } - self.firstResponder?.keyDown(with: event) return true } @@ -17431,12 +17414,11 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { - if cmuxEditableTextViewArrowForwardingDepth > 0 { + guard let target = self.firstResponder, + cmuxForceDispatchKeyDownOnce(event, to: target, reason: "editable text view arrow") + else { return false } - cmuxEditableTextViewArrowForwardingDepth += 1 - defer { cmuxEditableTextViewArrowForwardingDepth = max(0, cmuxEditableTextViewArrowForwardingDepth - 1) } - self.firstResponder?.keyDown(with: event) return true } @@ -17448,20 +17430,13 @@ private extension NSWindow { flags: event.modifierFlags ) { if browserWebKitKeyDownReentry { return false } - // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - if cmuxBrowserReturnForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) + guard let target = self.firstResponder else { return false } + if cmuxForceDispatchKeyDownOnce(event, to: target, reason: "browser Return/Enter") { + return true } - cmuxBrowserReturnForwardingDepth += 1 - defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } -#if DEBUG - cmuxDebugLog(" → browser Return/Enter routed to firstResponder.keyDown") -#endif - self.firstResponder?.keyDown(with: event) - return true + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + return cmux_performKeyEquivalent(with: event) } // Browser content can lose plain arrows when performKeyEquivalent claims them before WebKit. @@ -17475,15 +17450,6 @@ private extension NSWindow { if let focusedOmnibarField = AppDelegate.shared?.focusedBrowserOmnibarField(for: event, in: self), browserOmnibarPanelId(for: self.firstResponder) == nil, focusedOmnibarField.window === self { - if cmuxBrowserArrowForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser arrow omnibar restore reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) - } - cmuxBrowserArrowForwardingDepth += 1 - defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } - var currentEditorResponder: NSResponder? = focusedOmnibarField.currentEditor() if currentEditorResponder == nil || self.firstResponder !== currentEditorResponder { guard self.makeFirstResponder(focusedOmnibarField) else { @@ -17506,32 +17472,26 @@ private extension NSWindow { #endif return false } -#if DEBUG - if browserResponderHasMarkedText(omnibarResponder) { - cmuxDebugLog(" → browser arrow restored focused omnibar with marked text before keyDown") - } else { - cmuxDebugLog(" → browser arrow restored focused omnibar before keyDown") + if cmuxForceDispatchKeyDownOnce( + event, + to: omnibarResponder, + reason: browserResponderHasMarkedText(omnibarResponder) + ? "browser arrow restored focused omnibar with marked text" + : "browser arrow restored focused omnibar" + ) { + return true } -#endif - omnibarResponder.keyDown(with: event) - return true + // Reentry of the same in-flight event: use normal dispatch. + return cmux_performKeyEquivalent(with: event) } // Match the Return/Enter forwarding guard: AppKit/WebKit can re-enter // performKeyEquivalent while the synthesized keyDown is in flight. - if cmuxBrowserArrowForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser arrow reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) + guard let target = self.firstResponder else { return false } + if cmuxForceDispatchKeyDownOnce(event, to: target, reason: "browser arrow") { + return true } - cmuxBrowserArrowForwardingDepth += 1 - defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } -#if DEBUG - cmuxDebugLog(" → browser arrow routed to firstResponder.keyDown") -#endif - self.firstResponder?.keyDown(with: event) - return true + return cmux_performKeyEquivalent(with: event) } if let firstResponderWebView, @@ -17592,8 +17552,14 @@ private extension NSWindow { if let firstResponderGhosttyView, shouldRouteCommandEquivalentDirectlyToMainMenu(event) { if AppDelegate.shared?.shouldForwardBrowserSurfaceShortcutToTerminal(event) == true { if firstResponderGhosttyView.performKeyEquivalentAfterMenuMiss(with: event) { return true } - firstResponderGhosttyView.keyDown(with: event) - return true + if cmuxForceDispatchKeyDownOnce( + event, + to: firstResponderGhosttyView, + reason: "browser surface shortcut to terminal" + ) { + return true + } + return false } guard let mainMenu = NSApp.mainMenu else { return false } let consumedByMenu = mainMenu.performKeyEquivalent(with: event) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1fafa40e65a3..df23b52660c9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -693,6 +693,8 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */; }; D0B10024A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10025A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift */; }; + 313585583035A1E685010A97 /* WindowKeyDownReplayGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */; }; + 8770D0F2D2BB45359D6BE5E3 /* WindowKeyDownReplayGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; 9FC97DA1B422776BB1EEC821 /* Workspace+CustomSidebarPullRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C2726A34F27E7A43B77368 /* Workspace+CustomSidebarPullRequests.swift */; }; D7AB00000000000000000015 /* Workspace+DetachedSurfaceTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000016 /* Workspace+DetachedSurfaceTransfer.swift */; }; @@ -1445,6 +1447,8 @@ B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowGlassEffect.swift; sourceTree = ""; }; D0B10025A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowInputRoutingContext.swift; sourceTree = ""; }; + 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WindowKeyDownReplayGuard.swift; sourceTree = ""; }; + B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowKeyDownReplayGuardTests.swift; sourceTree = ""; }; A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = ""; }; 42C2726A34F27E7A43B77368 /* Workspace+CustomSidebarPullRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CustomSidebarPullRequests.swift"; sourceTree = ""; }; D7AB00000000000000000016 /* Workspace+DetachedSurfaceTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+DetachedSurfaceTransfer.swift"; sourceTree = ""; }; @@ -1792,6 +1796,7 @@ 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */, C1713001C1713001C1713001 /* CommandPaletteShortcutRouting.swift */, B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */, + 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */, AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */, E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */, C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */, @@ -2281,6 +2286,7 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */, D1FFC0DE000000000000C001 /* DiffCommentStoreTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, @@ -3131,6 +3137,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */, D0B10024A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift in Sources */, + 313585583035A1E685010A97 /* WindowKeyDownReplayGuard.swift in Sources */, A5001209 /* WindowToolbarController.swift in Sources */, 9FC97DA1B422776BB1EEC821 /* Workspace+CustomSidebarPullRequests.swift in Sources */, D7AB00000000000000000015 /* Workspace+DetachedSurfaceTransfer.swift in Sources */, @@ -3419,6 +3426,7 @@ C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */, + 8770D0F2D2BB45359D6BE5E3 /* WindowKeyDownReplayGuardTests.swift in Sources */, 7F0A0E04A8CADC84BB4F1DF7 /* WorkspaceActionDispatcherTests.swift in Sources */, D7AB00000000000000000011 /* WorkspaceAdjacentPaneMoveTests.swift in Sources */, A11EAE000000000000000000 /* WorkspaceAppearanceConfigResolutionTests.swift in Sources */, diff --git a/cmuxTests/WindowKeyDownReplayGuardTests.swift b/cmuxTests/WindowKeyDownReplayGuardTests.swift new file mode 100644 index 000000000000..da980ab72cfa --- /dev/null +++ b/cmuxTests/WindowKeyDownReplayGuardTests.swift @@ -0,0 +1,149 @@ +import AppKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/5887. +/// +/// `NSWindow.cmux_performKeyEquivalent(with:)` force-dispatches certain key +/// events straight into the focused responder's `keyDown(with:)`. When the +/// responder does not consume the key, AppKit can route the very same event +/// back into `performKeyEquivalent` while the first dispatch is still on the +/// stack (WebKit replays unhandled keys through the responder chain, and on +/// macOS 26 `-[NSWindow keyDown:]` re-enters `performKeyEquivalent`). Without +/// a replay guard at the dispatch chokepoint the event ping-pongs forever and +/// overflows the main-thread stack. +@MainActor +final class WindowKeyDownReplayGuardTests: XCTestCase { + + /// First responder stub that models the re-entrant AppKit behavior: an + /// unhandled keyDown flows back into `NSWindow.performKeyEquivalent` with + /// the exact same event while the original dispatch is still on the stack. + /// Bounded so the pre-fix failure mode is a clean assertion failure + /// instead of a stack overflow. + private final class ReplayingKeyDownView: NSView { + private(set) var keyDownEvents: [NSEvent] = [] + var replaysRemaining = 5 + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + keyDownEvents.append(event) + guard replaysRemaining > 0 else { return } + replaysRemaining -= 1 + _ = window?.performKeyEquivalent(with: event) + } + } + + private func makeWindowWithReplayingResponder() -> (NSWindow, ReplayingKeyDownView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let responder = ReplayingKeyDownView(frame: NSRect(x: 0, y: 0, width: 64, height: 32)) + container.addSubview(responder) + XCTAssertTrue(window.makeFirstResponder(responder)) + return (window, responder) + } + + /// Option+A producing printable text ("å"). The printable-Option-text + /// bypass in `cmux_performKeyEquivalent` force-dispatches this into the + /// first responder's `keyDown`, which is the unguarded dispatch the + /// https://github.com/manaflow-ai/cmux/issues/5887 crash looped through. + private func makeOptionTextKeyDownEvent( + windowNumber: Int, + timestamp: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.option], + timestamp: timestamp, + windowNumber: windowNumber, + context: nil, + characters: "å", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + ) + } + + func testPrintableOptionTextKeyDownIsForceDispatchedExactlyOncePerEvent() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let (window, responder) = makeWindowWithReplayingResponder() + guard let event = makeOptionTextKeyDownEvent(windowNumber: window.windowNumber) else { + XCTFail("Failed to construct Option+A key event") + return + } + + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + responder.keyDownEvents.count, + 1, + "The same in-flight key event must not be force-dispatched into keyDown again " + + "while the first dispatch is still on the stack; unbounded re-dispatch is the " + + "infinite key-routing loop from " + + "https://github.com/manaflow-ai/cmux/issues/5887" + ) + } + + func testDistinctKeyDownEventsAreEachForceDispatched() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let (window, responder) = makeWindowWithReplayingResponder() + responder.replaysRemaining = 0 + + let baseTimestamp = ProcessInfo.processInfo.systemUptime + guard + let first = makeOptionTextKeyDownEvent( + windowNumber: window.windowNumber, + timestamp: baseTimestamp + ), + let second = makeOptionTextKeyDownEvent( + windowNumber: window.windowNumber, + timestamp: baseTimestamp + 0.05 + ) + else { + XCTFail("Failed to construct Option+A key events") + return + } + + // Distinct events (key autorepeat, repeat typing) must each be + // force-dispatched; the replay guard is per-event, not a throttle. + XCTAssertTrue(window.performKeyEquivalent(with: first)) + XCTAssertTrue(window.performKeyEquivalent(with: second)) + XCTAssertEqual(responder.keyDownEvents.count, 2) + } + + func testSameEventIsForceDispatchedAgainAfterPriorDispatchUnwinds() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let (window, responder) = makeWindowWithReplayingResponder() + responder.replaysRemaining = 0 + + guard let event = makeOptionTextKeyDownEvent(windowNumber: window.windowNumber) else { + XCTFail("Failed to construct Option+A key event") + return + } + + // WebKit legitimately re-sends an unhandled key event through + // NSApp.sendEvent after the original dispatch has fully unwound. The + // guard is stack-scoped, so the same event must dispatch again here. + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual(responder.keyDownEvents.count, 2) + } +}