Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34179 CLI/cmux.swift
17636 Sources/AppDelegate.swift
17606 Sources/AppDelegate.swift
16025 Sources/ContentView.swift
14100 Sources/TerminalController.swift
12654 Sources/Workspace.swift
12219 cmuxTests/AppDelegateShortcutRoutingTests.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
11827 Sources/GhosttyTerminalView.swift
11411 Sources/Panels/BrowserPanel.swift
9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
Expand Down
11 changes: 3 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,9 @@ jobs:
run: bun run test:db:behavior

tests:
# TEMPORARY (2026-06-18): pinned to hosted runners. The self-hosted austin
# mac-minis in the MACOS_RUNNER_15 pool cannot broker the XCTest control
# session with testmanagerd ("Timed out 120s initiating control session
# with daemon" -> Executed 0 tests -> idle-timeout), so this required check
# can never go green there. Revert to
# `${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }}` once the austin
# runners are repaired (GUI login session + automation mode + unwedged
# testmanagerd). Tracked by the CI-flakiness handoff.
# App-host XCTest needs a runner that can broker testmanagerd control
# sessions. Keep this job on the hosted GUI-capable pool until the
# MACOS_RUNNER_15/Austin runner-infra issue is fixed separately.
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 75
env:
Expand Down
73 changes: 73 additions & 0 deletions Sources/AppDelegate+ShortcutRoutingTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#if DEBUG
import AppKit
import ObjectiveC.runtime

final class DebugShortcutRoutingFocusedWindowOverrideForTesting {
weak var window: NSWindow?
weak var keyRepairFirstResponder: NSResponder?
var focusedWindowCaptureDepth = 0

var shouldCaptureFocusedWindow: Bool {
focusedWindowCaptureDepth > 0
}
}

let debugShortcutRoutingFocusedWindowOverrideForTesting = DebugShortcutRoutingFocusedWindowOverrideForTesting()

private let didInstallShortcutRoutingWindowMakeKeyAndOrderFrontSwizzleForTesting: Void = {
let targetClass: AnyClass = NSWindow.self
let originalSelector = #selector(NSWindow.makeKeyAndOrderFront(_:))
let swizzledSelector = #selector(NSWindow.cmux_makeKeyAndOrderFront(_:))
guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector),
let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else {
return
}
method_exchangeImplementations(originalMethod, swizzledMethod)
}()

extension AppDelegate {
func debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: Bool = true) {
clearConfiguredShortcutChordState()
shortcutEventFocusContextCache = nil
debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder = nil
debugFocusedTerminalKeyRepairObserverForTesting = nil
if clearFocusedWindowOverride {
debugShortcutRoutingFocusedWindowOverrideForTesting.window = nil
}
}

func debugSetShortcutRoutingFocusedWindowForTesting(_ window: NSWindow?) {
debugShortcutRoutingFocusedWindowOverrideForTesting.window = window
shortcutEventFocusContextCache = nil
}

func debugSetShortcutRoutingKeyRepairFirstResponderForTesting(_ responder: NSResponder?) {
debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder = responder
}

func debugBeginShortcutRoutingFocusedWindowCaptureForTesting() {
debugShortcutRoutingFocusedWindowOverrideForTesting.focusedWindowCaptureDepth += 1
debugResetShortcutRoutingStateForTesting()
}

func debugEndShortcutRoutingFocusedWindowCaptureForTesting() {
let override = debugShortcutRoutingFocusedWindowOverrideForTesting
override.focusedWindowCaptureDepth = max(override.focusedWindowCaptureDepth - 1, 0)
debugResetShortcutRoutingStateForTesting()
}

static func installShortcutRoutingFocusedWindowSwizzleForTesting() {
_ = didInstallShortcutRoutingWindowMakeKeyAndOrderFrontSwizzleForTesting
}
}

extension NSWindow {
@objc func cmux_makeKeyAndOrderFront(_ sender: Any?) {
cmux_makeKeyAndOrderFront(sender)
guard debugShortcutRoutingFocusedWindowOverrideForTesting.shouldCaptureFocusedWindow else {
return
}
AppDelegate.shared?.debugSetShortcutRoutingFocusedWindowForTesting(self)
}
}
#endif
80 changes: 80 additions & 0 deletions Sources/AppDelegate+ShortcutRoutingWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import AppKit

extension AppDelegate {
var shortcutRoutingKeyWindow: NSWindow? {
#if DEBUG
if let window = debugShortcutRoutingFocusedWindowOverrideForTesting.window {
if debugShortcutRoutingFocusedWindowOverrideForTesting.shouldCaptureFocusedWindow {
return window
}
if contextForMainWindow(window) != nil
|| isMainTerminalWindow(window)
|| cmuxWindowShouldOwnCloseShortcut(window) {
return window
}
debugShortcutRoutingFocusedWindowOverrideForTesting.window = nil
}
#endif
return NSApp.keyWindow
}

var shortcutRoutingActiveWindow: NSWindow? {
shortcutRoutingKeyWindow ?? NSApp.mainWindow
}

func shortcutRoutingFirstResponder(preferredWindow: NSWindow? = nil) -> NSResponder? {
preferredWindow?.firstResponder
?? shortcutRoutingKeyWindow?.firstResponder
?? NSApp.mainWindow?.firstResponder
}

func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? {
guard let window else { return nil }
return contextForMainTerminalWindow(window)
}

func activeTabManagerForCommands(preferredWindow: NSWindow? = nil) -> TabManager? {
if let context = contextForMainWindow(preferredWindow) {
return context.tabManager
}
if let context = contextForMainWindow(shortcutRoutingKeyWindow) {
return context.tabManager
}
if let context = contextForMainWindow(NSApp.mainWindow) {
return context.tabManager
}
if let activeManager = tabManager,
let activeContext = liveMainWindowContext(for: activeManager) {
return activeContext.tabManager
}
return mainWindowContexts.values.first { context in
resolvedWindow(for: context) != nil
}?.tabManager
}
Comment on lines +36 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Snapshot mainWindowContexts.values before iterating with resolvedWindow(for:).

Line 70 correctly uses Array(mainWindowContexts.values) before calling resolvedWindow(for:), but the fallback at lines 47-49 iterates the dictionary directly. Since resolvedWindow(for:) may reindex/mutate mainWindowContexts, this can cause mutation-during-enumeration issues.

🔧 Suggested fix
-        return mainWindowContexts.values.first { context in
+        return Array(mainWindowContexts.values).first { context in
             resolvedWindow(for: context) != nil
         }?.tabManager

Based on learnings: "avoid iterating mainWindowContexts.values directly while calling resolvedWindow(for:). Since resolvedWindow(for:) may reindex/mutate mainWindowContexts, this can cause mutation-during-enumeration issues. Instead, snapshot first with Array(mainWindowContexts.values)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppDelegate`+ShortcutRoutingWindow.swift around lines 33 - 50, The
method activeTabManagerForCommands directly iterates over
mainWindowContexts.values while calling resolvedWindow(for:), which can cause
mutation-during-enumeration issues since resolvedWindow(for:) may mutate
mainWindowContexts during iteration. To fix this, snapshot the values before
iterating by wrapping mainWindowContexts.values with Array() in the same way it
is correctly done at line 70, ensuring the dictionary values are captured as an
immutable array before the first call to resolvedWindow(for:).

Source: Learnings


func repairFocusedTerminalKeyboardRoutingIfNeeded(
window: NSWindow,
event: NSEvent
) {
let firstResponderOverride: NSResponder?
#if DEBUG
firstResponderOverride = debugShortcutRoutingFocusedWindowOverrideForTesting.keyRepairFirstResponder
#else
firstResponderOverride = nil
#endif
repairFocusedTerminalKeyboardRoutingIfNeeded(
window: window,
event: event,
firstResponderOverride: firstResponderOverride
)
}

private func liveMainWindowContext(for tabManager: TabManager) -> MainWindowContext? {
for context in Array(mainWindowContexts.values) where context.tabManager === tabManager {
if resolvedWindow(for: context) != nil {
return context
}
}
return nil
}
}
Loading
Loading