From b655fed3f164efe46f749dfdb72baf5023a49a39 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 11 Jun 2026 06:57:26 -0700 Subject: [PATCH 1/3] Add failing regression test for the keyDown replay loop NSWindow.cmux_performKeyEquivalent force-dispatches certain key events straight into the focused responder's keyDown. When the responder does not consume the key, AppKit routes the same event back into performKeyEquivalent while the first dispatch is still on the stack (WebKit replays unhandled keys, and on macOS 26 -[NSWindow keyDown:] re-enters performKeyEquivalent). The printable-Option-text bypass has no re-entry guard, so the event ping-pongs between the swizzle and the responder until the main-thread stack overflows. The test drives the real chokepoint: a window whose first responder re-invokes performKeyEquivalent with the same event from keyDown, bounded so the pre-fix failure is a clean assertion instead of a crash. It asserts the force-dispatch happens exactly once per event, that distinct events (autorepeat) still each dispatch, and that the same event may dispatch again once the prior dispatch has unwound (WebKit's legitimate replay). Repro of https://github.com/manaflow-ai/cmux/issues/5887: Option+A with a browser pane focused on non-editable content crashes with "Thread stack size exceeded due to excessive recursion" (incident C9470E41-11A7-4A04-874F-C8AE5DF1CA06 on a debug build, mirroring incident 96E09E5C-19CC-49D4-B068-7A666CE784A9 from cmux NIGHTLY 0.64.14). Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/WindowKeyDownReplayGuardTests.swift | 149 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 cmuxTests/WindowKeyDownReplayGuardTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2ebd65ecb175..c78e74eb3cfd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -691,6 +691,7 @@ 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 */; }; + 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 */; }; @@ -1441,6 +1442,7 @@ 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 = ""; }; + 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 = ""; }; @@ -2275,6 +2277,7 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */, D1FFC0DE000000000000C001 /* DiffCommentStoreTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, FEED49850000000000000002 /* FeedEventClassificationTests.swift */, @@ -3411,6 +3414,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) + } +} From c4f61dd1addae009df06dda5ca7f1b0aa3e642c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 11 Jun 2026 07:03:38 -0700 Subject: [PATCH 2/3] Guard every performKeyEquivalent keyDown force-dispatch against replay loops Replace the seven per-branch forwarding-depth counters in NSWindow.cmux_performKeyEquivalent with one shared chokepoint, cmuxForceDispatchKeyDownOnce. The helper tracks the identity (window number, event type, keyCode, modifiers, timestamp) of every key event whose force-dispatch is currently on the stack and refuses to dispatch the same event a second time, returning false so the caller falls through to default AppKit handling. This closes the unguarded printable-Option-text bypass that crashed cmux NIGHTLY 0.64.14 (https://github.com/manaflow-ai/cmux/issues/5887): WebKit replays an unhandled key through the responder chain, macOS 26 -[NSWindow keyDown:] re-enters performKeyEquivalent, and the bypass force-dispatched the same event back into CmuxWebView.keyDown forever until the main-thread stack overflowed. It also guards the previously unguarded ghostty zoom, stale-menu-bypass, and menu-miss keyDown dispatches, and protects against cross-branch ping-pong that per-branch counters cannot see (the first responder can change while a dispatch is in flight). The guard is stack-scoped (insert before keyDown, remove via defer), so WebKit's legitimate single replay of an unhandled key, which arrives after the original dispatch has unwound, still force-dispatches normally. Key autorepeat produces distinct events with fresh timestamps, so repeat typing is never throttled, and the dispatching window's number is part of the identity so multiple windows cannot suppress each other. Fixes https://github.com/manaflow-ai/cmux/issues/5887 Co-Authored-By: Claude Fable 5 --- Sources/AppDelegate.swift | 252 +++++++++++++++++++++----------------- 1 file changed, 142 insertions(+), 110 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index aad9176e2311..d1acc3bbee31 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16552,13 +16552,36 @@ 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 +/// 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() private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -17193,6 +17216,43 @@ private extension NSWindow { #endif } + /// 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. + private 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 + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() @@ -17249,11 +17309,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 } @@ -17273,8 +17335,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 @@ -17314,11 +17380,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 } } @@ -17327,26 +17395,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 } @@ -17356,12 +17414,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 } @@ -17371,22 +17428,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( @@ -17395,12 +17447,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 } @@ -17410,12 +17461,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 } @@ -17429,12 +17479,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,21 +17497,13 @@ private extension NSWindow { firstResponderHasMarkedText: firstResponderHasMarkedText, flags: event.modifierFlags ) { + guard let target = self.firstResponder else { return false } + if cmuxForceDispatchKeyDownOnce(event, to: target, reason: "browser Return/Enter") { + 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. - if cmuxBrowserReturnForwardingDepth > 0 { -#if DEBUG - cmuxDebugLog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return cmux_performKeyEquivalent(with: event) - } - 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 + return cmux_performKeyEquivalent(with: event) } // Some browser content (notably Google Docs) loses plain arrows when @@ -17477,15 +17518,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 { @@ -17508,32 +17540,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, @@ -17594,8 +17620,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) From 7da82ad58c5318f5322040048be0b776b4f5d63b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 11 Jun 2026 11:56:51 -0700 Subject: [PATCH 3/3] Move the keyDown replay guard into its own file to satisfy the Swift file length budget No behavior change. The guard helper, identity struct, and in-flight set move from Sources/AppDelegate.swift (which the fix had pushed 32 lines over its 18057-line budget) into Sources/App/WindowKeyDownReplayGuard.swift, leaving AppDelegate.swift 35 lines under budget. The new file stays below the 500-line tracking threshold. Co-Authored-By: Claude Fable 5 --- Sources/App/WindowKeyDownReplayGuard.swift | 71 ++++++++++++++++++++++ Sources/AppDelegate.swift | 67 -------------------- cmux.xcodeproj/project.pbxproj | 4 ++ 3 files changed, 75 insertions(+), 67 deletions(-) create mode 100644 Sources/App/WindowKeyDownReplayGuard.swift 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 d1acc3bbee31..7f7071049d2c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16552,36 +16552,6 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView? private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? -/// 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() private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -17216,43 +17186,6 @@ private extension NSWindow { #endif } - /// 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. - private 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 - } - @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c78e74eb3cfd..23043e2aec19 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -691,6 +691,7 @@ 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 */; }; @@ -1442,6 +1443,7 @@ 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 = ""; }; @@ -1790,6 +1792,7 @@ 47D5AA7D29C94F5CA865B2BF /* ScreenIdentity.swift */, C1713001C1713001C1713001 /* CommandPaletteShortcutRouting.swift */, B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */, + 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */, AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */, E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */, C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */, @@ -3127,6 +3130,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 */,