Skip to content
Merged
53 changes: 53 additions & 0 deletions Sources/App/SwiftUIKeyViewProxyResponderGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 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"

/// 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)
}()
}
4 changes: 4 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14379,13 +14379,15 @@ 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
_ = didInstallApplicationSendEventSwizzle
_ = didInstallWindowKeyEquivalentSwizzle
_ = didInstallWindowFirstResponderSwizzle
_ = didInstallWindowSendEventSwizzle
SwiftUIKeyViewProxyResponderGuard.install()
#if DEBUG
installShortcutRoutingFocusedWindowSwizzleForTesting()
#endif
Expand All @@ -14403,13 +14405,15 @@ 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
_ = Self.didInstallApplicationSendEventSwizzle
_ = Self.didInstallWindowKeyEquivalentSwizzle
_ = Self.didInstallWindowFirstResponderSwizzle
_ = Self.didInstallWindowSendEventSwizzle
SwiftUIKeyViewProxyResponderGuard.install()
}

private func installShortcutMonitor() {
Expand Down
15 changes: 13 additions & 2 deletions Sources/CloudFilePreviewCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,28 @@ actor CloudFilePreviewCache {
private var cleanupTask: Task<Void, Never>?
private var refreshTasks: [URL: Task<Void, Error>] = [:]

/// 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(
"cmux-cloud-previews-\(owner)-\(UUID().uuidString)",
isDirectory: true
)
self.maximumEntries = maximumEntries
cleanupTask = Task.detached(priority: .utility) { Self.removeStaleDirectories(in: directory) }
}

/// 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(
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 {
Expand All @@ -42,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()
Expand All @@ -52,6 +57,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 {
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3613,6 +3613,8 @@
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 */; };
A7206F010000000000000001 /* SystemAppearanceObserverEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206F020000000000000001 /* SystemAppearanceObserverEnvironment.swift */; };
Expand Down Expand Up @@ -8013,6 +8015,8 @@
C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = "<group>"; };
72C02896AFDD3AADD6A11F1A /* SurfaceWorkspaceLayoutSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceWorkspaceLayoutSyncing.swift; sourceTree = "<group>"; };
9666A2029666A2029666A202 /* SwiftTestingAssertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingAssertions.swift; sourceTree = "<group>"; };
4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SwiftUIKeyViewProxyResponderGuard.swift; sourceTree = "<group>"; };
F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUIKeyViewProxyResponderGuardTests.swift"; sourceTree = "<group>"; };
6E4BCCFBEFDD1E77E23E7D94 /* SyntheticProcessSnapshotReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyntheticProcessSnapshotReader.swift"; sourceTree = "<group>"; };
A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = "<group>"; };
A7206F020000000000000001 /* SystemAppearanceObserverEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserverEnvironment.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -11941,6 +11945,7 @@
A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */,
36FABB797A3D46B70F073485 /* CmuxSettingsFileStore+SleepyModeSection.swift */,
6C858D764447A3E9A2C7EE35 /* CmuxSettingsFileStore+FontSections.swift */,
4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -13460,6 +13465,7 @@
87830970BF70E00C4E0C9D77 /* CloudTreeTestSupport.swift */,
3D3B724235A7A6C21D3D5F25 /* NewMachineSheetLayoutTests.swift */,
A0BBED2106E124DBB2E6A88A /* CLIVersionSkewMessageTests.swift */,
F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */,
03F486001BC03CEE3611842F /* GhosttyExternalDisplayScalingTests.swift */,
);
path = cmuxTests;
Expand Down Expand Up @@ -16250,6 +16256,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 */,
Expand Down Expand Up @@ -18349,6 +18356,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 */,
Expand Down
3 changes: 3 additions & 0 deletions cmuxTests/CJKIMEMarkedSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -45,8 +46,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(
Expand Down
113 changes: 113 additions & 0 deletions cmuxTests/SwiftUIKeyViewProxyResponderGuardTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 }
}
}

/// 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.
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() async throws -> NSView {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 200),
styleMask: [.titled],
backing: .buffered,
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 host: NSHostingView<FocusableContent>? = 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 {
host?.removeFromSuperview()
host = nil
}
let hostReleased = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) {
weakHost == nil
}

try #require(hostReleased, "The proxy must outlive its host to exercise the crash")
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()

#expect(proxy.superview == nil)
#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(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 200),
styleMask: [.titled],
backing: .buffered,
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
let proxy = try await focusProxy(in: window, host: host)
#expect(proxy.nextResponder === host)
}
}
Loading