From 6bc79bfb1d4b2faf80178747f886bd32abd6c0da Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:55:52 -0700 Subject: [PATCH 1/6] test: cover responder walks through a SwiftUI KeyViewProxy that outlived its host --- cmux.xcodeproj/project.pbxproj | 4 + ...iftUIKeyViewProxyResponderGuardTests.swift | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b8f65d1e86b7..4ca6710f2d35 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3612,6 +3612,7 @@ C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; 6CDB46F9F4D5380D8631CD23 /* SurfaceWorkspaceLayoutSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */; }; 9666A2019666A2019666A201 /* SwiftTestingAssertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */; }; + E112778D0CA1F725A9F2677C /* SwiftUIKeyViewProxyResponderGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */; }; 372153DEDA910ADC47F88E87 /* SyntheticProcessSnapshotReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */; }; A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206A020000000000000001 /* SystemAppearanceObserver.swift */; }; A7206F010000000000000001 /* SystemAppearanceObserverEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206F020000000000000001 /* SystemAppearanceObserverEnvironment.swift */; }; @@ -8011,6 +8012,7 @@ C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = ""; }; 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceWorkspaceLayoutSyncing.swift; sourceTree = ""; }; 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingAssertions.swift; sourceTree = ""; }; + F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUIKeyViewProxyResponderGuardTests.swift"; sourceTree = ""; }; 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyntheticProcessSnapshotReader.swift"; sourceTree = ""; }; A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = ""; }; A7206F020000000000000001 /* SystemAppearanceObserverEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserverEnvironment.swift; sourceTree = ""; }; @@ -13458,6 +13460,7 @@ 87830970BF70E00C4E0C9D77 /* CloudTreeTestSupport.swift */, 3D3B724235A7A6C21D3D5F25 /* NewMachineSheetLayoutTests.swift */, A0BBED2106E124DBB2E6A88A /* CLIVersionSkewMessageTests.swift */, + F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18345,6 +18348,7 @@ A10020000000000000000009 /* SurfaceSelectionTests.swift in Sources */, 97003C82A6CDC90F8D20503F /* SurfaceSocketCommandTests.swift in Sources */, 9666A2019666A2019666A201 /* SwiftTestingAssertions.swift in Sources */, + E112778D0CA1F725A9F2677C /* SwiftUIKeyViewProxyResponderGuardTests.swift in Sources */, 372153DEDA910ADC47F88E87 /* SyntheticProcessSnapshotReader.swift in Sources */, A7206B010000000000000001 /* SystemAppearanceObserverTests.swift in Sources */, FBE660ABA1854983B84368C6 /* SystemWideHotkeyShortcutPolicyTests.swift in Sources */, diff --git a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift new file mode 100644 index 000000000000..2455828da8d2 --- /dev/null +++ b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift @@ -0,0 +1,105 @@ +import AppKit +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for the Sentry crash family "Attempted to read an unowned reference" +/// in `KeyViewProxy.nextResponder.getter` (CMUXTERM-MACOS-3Z73 and siblings). SwiftUI's +/// `KeyViewProxy` returns its `FocusBridge`'s unowned host as `nextResponder`, so walking a +/// proxy that outlived its `NSHostingView` aborts the app. +@MainActor +@Suite(.serialized) struct SwiftUIKeyViewProxyResponderGuardTests { + private struct FocusableContent: View { + @FocusState private var focused: Bool + + var body: some View { + VStack { + Text("a").focusable().focused($focused) + Text("b").focusable() + } + .onAppear { focused = true } + } + } + + private func isKeyViewProxy(_ view: NSView) -> Bool { + String(cString: class_getName(type(of: view))) == "SwiftUI.KeyViewProxy" + } + + private func spinRunLoop(_ iterations: Int = 10) { + for _ in 0.. NSView { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let container = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 200)) + window.contentView = container + + var proxy: NSView? + weak var weakHost: NSView? + autoreleasepool { + let host = NSHostingView(rootView: FocusableContent()) + host.frame = container.bounds + container.addSubview(host) + weakHost = host + spinRunLoop() + window.makeFirstResponder(host) + spinRunLoop() + window.selectNextKeyView(nil) + spinRunLoop() + proxy = window.firstResponder as? NSView + host.removeFromSuperview() + } + spinRunLoop(20) + + let stale = try #require(proxy) + try #require(isKeyViewProxy(stale)) + try #require(weakHost == nil) + return stale + } + + @Test func walkingAProxyThatOutlivedItsHostEndsTheChain() throws { + AppDelegate.installWindowResponderSwizzlesForTesting() + let proxy = try makeProxyThatOutlivedItsHost() + + #expect(proxy.superview == nil) + #expect(proxy.nextResponder == nil) + } + + @Test func attachedProxyStillForwardsToItsHost() throws { + AppDelegate.installWindowResponderSwizzlesForTesting() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let host = NSHostingView(rootView: FocusableContent()) + host.frame = NSRect(x: 0, y: 0, width: 300, height: 200) + window.contentView = host + spinRunLoop() + window.makeFirstResponder(host) + spinRunLoop() + window.selectNextKeyView(nil) + spinRunLoop() + + let proxy = try #require(window.firstResponder as? NSView) + try #require(isKeyViewProxy(proxy)) + #expect(proxy.nextResponder === host) + } +} From d4b065c5406ed00c59e056db51b0068a01a7dcfa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:56:02 -0700 Subject: [PATCH 2/6] fix: end responder walks at a SwiftUI KeyViewProxy that outlived its host --- .../SwiftUIKeyViewProxyResponderGuard.swift | 49 +++++++++++++++++++ Sources/AppDelegate.swift | 2 + cmux.xcodeproj/project.pbxproj | 4 ++ 3 files changed, 55 insertions(+) create mode 100644 Sources/App/SwiftUIKeyViewProxyResponderGuard.swift diff --git a/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift b/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift new file mode 100644 index 000000000000..4bb16d5ec7e9 --- /dev/null +++ b/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift @@ -0,0 +1,49 @@ +import AppKit +import ObjectiveC + +/// Keeps responder-chain walks from aborting on a SwiftUI `KeyViewProxy` that outlived its host. +/// +/// SwiftUI inserts a private `KeyViewProxy` subview into an `NSHostingView` for each focusable +/// element. Its `nextResponder` getter returns `focusBridge.host`, an unowned reference to that +/// hosting view. When the hosting view is freed while anything still holds the proxy, every +/// `nextResponder` read traps with "Attempted to read an unowned reference but object was already +/// deallocated". cmux's shortcut routing, AppKit's event routing and SwiftUI's own first-responder +/// observer all walk the chain, so the crash surfaces from many call sites. +/// +/// While the proxy is attached, its host is its superview and that is the value `nextResponder` +/// returns. A freed host clears its subviews' `superview`, so a proxy with no superview cannot +/// reach a live host. The guard ends the chain there and leaves attached proxies untouched. +enum SwiftUIKeyViewProxyResponderGuard { + private static let proxyClassName = "SwiftUI.KeyViewProxy" + + /// Installs the guard once. Does nothing when SwiftUI no longer ships the class. + static func install() { + _ = didInstall + } + + private static let didInstall: Void = { + guard let proxyClass = NSClassFromString(proxyClassName) else { + return + } + let selector = #selector(getter: NSResponder.nextResponder) + // Resolves to the inherited getter if SwiftUI ever stops overriding it, and + // class_replaceMethod then adds the override on the proxy class alone. + guard let method = class_getInstanceMethod(proxyClass, selector), + let types = method_getTypeEncoding(method) else { + return + } + typealias Getter = @convention(c) (AnyObject, Selector) -> NSResponder? + let original = unsafeBitCast(method_getImplementation(method), to: Getter.self) + + let guarded: @convention(block) (AnyObject) -> NSResponder? = { proxy in + if Thread.isMainThread, let view = proxy as? NSView { + let isDetached = MainActor.assumeIsolated { view.superview == nil } + if isDetached { + return nil + } + } + return original(proxy, selector) + } + class_replaceMethod(proxyClass, selector, imp_implementationWithBlock(guarded), types) + }() +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 67420d787082..4b0547536b68 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14386,6 +14386,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle _ = didInstallWindowSendEventSwizzle + SwiftUIKeyViewProxyResponderGuard.install() #if DEBUG installShortcutRoutingFocusedWindowSwizzleForTesting() #endif @@ -14410,6 +14411,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle _ = Self.didInstallWindowSendEventSwizzle + SwiftUIKeyViewProxyResponderGuard.install() } private func installShortcutMonitor() { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4ca6710f2d35..cf5e8705b5b3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3612,6 +3612,7 @@ C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; 6CDB46F9F4D5380D8631CD23 /* SurfaceWorkspaceLayoutSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */; }; 9666A2019666A2019666A201 /* SwiftTestingAssertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */; }; + DF4FAEF2EFFDC7957B6EA7D6 /* SwiftUIKeyViewProxyResponderGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */; }; E112778D0CA1F725A9F2677C /* SwiftUIKeyViewProxyResponderGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */; }; 372153DEDA910ADC47F88E87 /* SyntheticProcessSnapshotReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */; }; A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206A020000000000000001 /* SystemAppearanceObserver.swift */; }; @@ -8012,6 +8013,7 @@ C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = ""; }; 72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceWorkspaceLayoutSyncing.swift; sourceTree = ""; }; 9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingAssertions.swift; sourceTree = ""; }; + 4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SwiftUIKeyViewProxyResponderGuard.swift; sourceTree = ""; }; F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUIKeyViewProxyResponderGuardTests.swift"; sourceTree = ""; }; 6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyntheticProcessSnapshotReader.swift"; sourceTree = ""; }; A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = ""; }; @@ -11941,6 +11943,7 @@ A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */, 36FABB797A3D46B70F073485 /* CmuxSettingsFileStore+SleepyModeSection.swift */, 6C858D764447A3E9A2C7EE35 /* CmuxSettingsFileStore+FontSections.swift */, + 4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */, ); path = Sources; sourceTree = ""; @@ -16250,6 +16253,7 @@ 6F8CE4B13D081CF9C5C370B3 /* SurfaceSocketCommands.swift in Sources */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, 6CDB46F9F4D5380D8631CD23 /* SurfaceWorkspaceLayoutSyncing.swift in Sources */, + DF4FAEF2EFFDC7957B6EA7D6 /* SwiftUIKeyViewProxyResponderGuard.swift in Sources */, A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */, A7206F010000000000000001 /* SystemAppearanceObserverEnvironment.swift in Sources */, C7B0FACE000000000000000C /* SystemCommandRunner.swift in Sources */, From 527c79ad6d85bac3a33ab973cce6c12a55d5504d Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:21 -0700 Subject: [PATCH 3/6] test: wait for SwiftUI focus proxy lifecycle conditions Replace fixed run-loop delays with bounded AppKit state waits for proxy creation, keyboard focus, and host deallocation. Close each fixture window after use. Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> --- ...iftUIKeyViewProxyResponderGuardTests.swift | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift index 2455828da8d2..8f103173c078 100644 --- a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift +++ b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift @@ -30,15 +30,26 @@ import Testing String(cString: class_getName(type(of: view))) == "SwiftUI.KeyViewProxy" } - private func spinRunLoop(_ iterations: Int = 10) { - for _ in 0.. NSView { + host.layoutSubtreeIfNeeded() + let proxyCreated = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + host.subviews.contains(where: isKeyViewProxy) } + try #require(proxyCreated, "SwiftUI should create a focus proxy in the hosting view") + + try #require(window.makeFirstResponder(host)) + window.selectNextKeyView(nil) + let proxyFocused = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + guard let responder = window.firstResponder as? NSView else { return false } + return isKeyViewProxy(responder) + } + try #require(proxyFocused, "Keyboard focus should reach a SwiftUI focus proxy") + return try #require(window.firstResponder as? NSView) } /// Focuses a SwiftUI element, then frees its hosting view while something still holds the /// proxy, which is the state every crashing walker reached. - private func makeProxyThatOutlivedItsHost() throws -> NSView { + private func makeProxyThatOutlivedItsHost() async throws -> NSView { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), styleMask: [.titled], @@ -46,41 +57,41 @@ import Testing defer: false ) window.isReleasedWhenClosed = false + defer { + window.contentView = nil + window.close() + } let container = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 200)) window.contentView = container - var proxy: NSView? - weak var weakHost: NSView? + var host: NSHostingView? = NSHostingView(rootView: FocusableContent()) + weak var weakHost = host + autoreleasepool { + host?.frame = container.bounds + if let host { container.addSubview(host) } + } + let proxy = try await focusProxy(in: window, host: #require(host)) autoreleasepool { - let host = NSHostingView(rootView: FocusableContent()) - host.frame = container.bounds - container.addSubview(host) - weakHost = host - spinRunLoop() - window.makeFirstResponder(host) - spinRunLoop() - window.selectNextKeyView(nil) - spinRunLoop() - proxy = window.firstResponder as? NSView - host.removeFromSuperview() + host?.removeFromSuperview() + host = nil + } + let hostReleased = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + weakHost == nil } - spinRunLoop(20) - let stale = try #require(proxy) - try #require(isKeyViewProxy(stale)) - try #require(weakHost == nil) - return stale + try #require(hostReleased, "The proxy must outlive its host to exercise the crash") + return proxy } - @Test func walkingAProxyThatOutlivedItsHostEndsTheChain() throws { + @Test func walkingAProxyThatOutlivedItsHostEndsTheChain() async throws { AppDelegate.installWindowResponderSwizzlesForTesting() - let proxy = try makeProxyThatOutlivedItsHost() + let proxy = try await makeProxyThatOutlivedItsHost() #expect(proxy.superview == nil) #expect(proxy.nextResponder == nil) } - @Test func attachedProxyStillForwardsToItsHost() throws { + @Test func attachedProxyStillForwardsToItsHost() async throws { AppDelegate.installWindowResponderSwizzlesForTesting() let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), @@ -89,17 +100,14 @@ import Testing defer: false ) window.isReleasedWhenClosed = false + defer { + window.contentView = nil + window.close() + } let host = NSHostingView(rootView: FocusableContent()) host.frame = NSRect(x: 0, y: 0, width: 300, height: 200) window.contentView = host - spinRunLoop() - window.makeFirstResponder(host) - spinRunLoop() - window.selectNextKeyView(nil) - spinRunLoop() - - let proxy = try #require(window.firstResponder as? NSView) - try #require(isKeyViewProxy(proxy)) + let proxy = try await focusProxy(in: window, host: host) #expect(proxy.nextResponder === host) } } From 4912620462167780ba69fd61df79d35f64cf2220 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:58:52 -0700 Subject: [PATCH 4/6] test: request focus and terminal startup before awaiting readiness The hosted run on 527c79ad6d8 showed that a root NSHostingView creates its proxy lazily after a focus request. Request focus before waiting for actual proxy ownership. Explicitly demand a native runtime for the two IME fixtures that failed before synthetic input; retain their bounded readiness checks and reconcile geometry. Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> --- cmuxTests/CJKIMEMarkedSelectionTests.swift | 2 ++ cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift | 6 +----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index 92a9e0d5bf30..ae7e3c84e100 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -45,8 +45,10 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { contentView.layoutSubtreeIfNeeded() hostedView.setVisibleInUI(true) hostedView.setActive(true) + surface.requestInputDemandSurfaceStartIfNeeded() _ = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { surface.surface != nil } _ = try XCTUnwrap(surface.surface, "Expected native surface before dispatching composed input") + hostedView.reconcileGeometryNow() let surfaceView = try XCTUnwrap(findGhosttyNSView(in: hostedView)) return HostedTerminalWindow( diff --git a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift index 8f103173c078..7a5c62eafac5 100644 --- a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift +++ b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift @@ -32,11 +32,7 @@ import Testing private func focusProxy(in window: NSWindow, host: NSView) async throws -> NSView { host.layoutSubtreeIfNeeded() - let proxyCreated = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { - host.subviews.contains(where: isKeyViewProxy) - } - try #require(proxyCreated, "SwiftUI should create a focus proxy in the hosting view") - + // SwiftUI can create its proxy lazily when keyboard focus is requested. try #require(window.makeFirstResponder(host)) window.selectNextKeyView(nil) let proxyFocused = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { From fcda01111ba236c8bea73a13464afdd32d4844c7 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:05:05 -0700 Subject: [PATCH 5/6] fix: defer preview cache cleanup until a preview is opened Repeated app-host runs stalled in the mention suggestions test while all 14 utility workers enumerated the temporary directory from unused Files stores. Start cleanup only on first materialization, avoid prefetching metadata for unrelated files, and honor cache teardown cancellation. --- Sources/CloudFilePreviewCache.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/CloudFilePreviewCache.swift b/Sources/CloudFilePreviewCache.swift index 92eaa32c283a..f5ea8a01aff0 100644 --- a/Sources/CloudFilePreviewCache.swift +++ b/Sources/CloudFilePreviewCache.swift @@ -19,17 +19,19 @@ actor CloudFilePreviewCache { isDirectory: true ) self.maximumEntries = maximumEntries - cleanupTask = Task.detached(priority: .utility) { Self.removeStaleDirectories(in: directory) } } private static func removeStaleDirectories(in directory: URL) { + guard !Task.isCancelled else { return } guard let urls = try? FileManager.default.contentsOfDirectory( at: directory, - includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + // Fetch metadata only after filtering out unrelated temporary files. + includingPropertiesForKeys: [], options: [.skipsHiddenFiles] ) else { return } let cutoff = Date().addingTimeInterval(-staleDirectoryAge) for url in urls where url.lastPathComponent.hasPrefix("cmux-cloud-previews-") { + guard !Task.isCancelled else { return } let components = url.lastPathComponent.split(separator: "-") guard components.count > 3, let owner = Int32(components[3]) else { continue } if owner == ProcessInfo.processInfo.processIdentifier || kill(owner, 0) == 0 || errno == EPERM { @@ -52,6 +54,12 @@ actor CloudFilePreviewCache { let directory = root.appendingPathComponent(UUID().uuidString, isDirectory: true) let filename = (path as NSString).lastPathComponent guard !filename.isEmpty, filename != ".", filename != ".." else { throw FileExplorerError.providerUnavailable } + if cleanupTask == nil { + // Most Files stores never open a remote preview. Scanning on creation + // lets workspace churn saturate the cooperative executor with file I/O. + let parent = root.deletingLastPathComponent() + cleanupTask = Task.detached(priority: .utility) { Self.removeStaleDirectories(in: parent) } + } let url = directory.appendingPathComponent(filename, isDirectory: false) entries.insert(url) do { From 83c41ce33b372bb360c8a1712c139a8579e69db5 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:31:03 -0700 Subject: [PATCH 6/6] docs: clarify responder guard and document reviewed methods --- Sources/App/SwiftUIKeyViewProxyResponderGuard.swift | 8 ++++++-- Sources/AppDelegate.swift | 2 ++ Sources/CloudFilePreviewCache.swift | 3 +++ cmuxTests/CJKIMEMarkedSelectionTests.swift | 1 + cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift | 4 ++++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift b/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift index 4bb16d5ec7e9..6ea880cdf2e6 100644 --- a/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift +++ b/Sources/App/SwiftUIKeyViewProxyResponderGuard.swift @@ -11,8 +11,12 @@ import ObjectiveC /// observer all walk the chain, so the crash surfaces from many call sites. /// /// While the proxy is attached, its host is its superview and that is the value `nextResponder` -/// returns. A freed host clears its subviews' `superview`, so a proxy with no superview cannot -/// reach a live host. The guard ends the chain there and leaves attached proxies untouched. +/// returns. A freed host clears its subviews' `superview`, so forwarding from a detached proxy +/// is unsafe. The guard conservatively ends the chain while detached, including during a +/// temporary detach from a still-live host, and leaves attached proxies untouched. +/// +/// Focus recovery belongs to window event routing. This getter must not change first responder +/// while AppKit or SwiftUI is traversing the responder chain. enum SwiftUIKeyViewProxyResponderGuard { private static let proxyClassName = "SwiftUI.KeyViewProxy" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4b0547536b68..acf39ce04c22 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14379,6 +14379,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return notificationStore?.notifications.first(where: { $0.id == openedId }) } + /// Installs the production responder guards plus the test window-routing override. static func installWindowResponderSwizzlesForTesting() { _ = didInstallApplicationAccessibilitySwizzle _ = didInstallApplicationSendActionSwizzle @@ -14404,6 +14405,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + /// Installs event routing and stale SwiftUI proxy guards once during application setup. private func installWindowResponderSwizzles() { _ = Self.didInstallApplicationAccessibilitySwizzle _ = Self.didInstallApplicationSendActionSwizzle diff --git a/Sources/CloudFilePreviewCache.swift b/Sources/CloudFilePreviewCache.swift index f5ea8a01aff0..c51e92f50cc2 100644 --- a/Sources/CloudFilePreviewCache.swift +++ b/Sources/CloudFilePreviewCache.swift @@ -12,6 +12,7 @@ actor CloudFilePreviewCache { private var cleanupTask: Task? private var refreshTasks: [URL: Task] = [:] + /// Reserves a unique cache root without creating files or scanning until a preview is requested. init(directory: URL = FileManager.default.temporaryDirectory, maximumEntries: Int = 32) { let owner = ProcessInfo.processInfo.processIdentifier root = directory.appendingPathComponent( @@ -21,6 +22,7 @@ actor CloudFilePreviewCache { self.maximumEntries = maximumEntries } + /// Removes old preview directories owned by exited processes, preserving every live owner's files. private static func removeStaleDirectories(in directory: URL) { guard !Task.isCancelled else { return } guard let urls = try? FileManager.default.contentsOfDirectory( @@ -44,6 +46,7 @@ actor CloudFilePreviewCache { } } + /// Downloads a read-only preview held by a lease and starts this cache's one-time stale-file cleanup. func materialize(path: String, provider: any RemoteFileExplorerProvider) async throws -> CloudFilePreviewLease { guard !ManagedFileTransferPolicy.isDisabled else { throw ManagedFileTransferPolicy.refusalError() diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index ae7e3c84e100..466c4e3d23c7 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -17,6 +17,7 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { let surfaceView: GhosttyNSView } + /// Creates an attached terminal with a live native surface and reconciled geometry for IME input. private func makeHostedTerminalWindow() async throws -> HostedTerminalWindow { _ = NSApplication.shared diff --git a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift index 7a5c62eafac5..8dbaa1e5816d 100644 --- a/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift +++ b/cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift @@ -26,10 +26,12 @@ import Testing } } + /// Identifies the private proxy class so the fixture cannot silently test a different responder. private func isKeyViewProxy(_ view: NSView) -> Bool { String(cString: class_getName(type(of: view))) == "SwiftUI.KeyViewProxy" } + /// Requests keyboard focus before waiting for SwiftUI's lazily created focus proxy. private func focusProxy(in window: NSWindow, host: NSView) async throws -> NSView { host.layoutSubtreeIfNeeded() // SwiftUI can create its proxy lazily when keyboard focus is requested. @@ -79,6 +81,7 @@ import Testing return proxy } + /// Exercises responder-chain access only after the proxy's unowned host has deallocated. @Test func walkingAProxyThatOutlivedItsHostEndsTheChain() async throws { AppDelegate.installWindowResponderSwizzlesForTesting() let proxy = try await makeProxyThatOutlivedItsHost() @@ -87,6 +90,7 @@ import Testing #expect(proxy.nextResponder == nil) } + /// Verifies that the guard preserves responder forwarding while the hosting view is alive. @Test func attachedProxyStillForwardsToItsHost() async throws { AppDelegate.installWindowResponderSwizzlesForTesting() let window = NSWindow(