From 0164c870f1b922e3bada5adf24ab02037553f062 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:24:20 -0800 Subject: [PATCH 1/2] Harden Sentry guards for 0.61.0 issue burst --- Sources/AppDelegate.swift | 17 ++--- Sources/GhosttyTerminalView.swift | 43 +++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 44 ++++++++++++ cmuxTests/GhosttyConfigTests.swift | 68 +++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a771d24d..7c45d269 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -826,12 +826,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Performance tracing (10% of transactions) options.tracesSampleRate = 0.1 - // App hang timeout (default is 2s, be explicit) - options.appHangTimeoutInterval = 2.0 + // Keep app-hang tracking enabled, but avoid reporting short main-thread stalls + // as hangs in normal user interaction flows. + options.appHangTimeoutInterval = 8.0 // Attach stack traces to all events options.attachStacktrace = true - // Capture failed HTTP requests - options.enableCaptureFailedRequests = true + // Avoid recursively capturing failed requests from Sentry's own ingestion endpoint. + options.enableCaptureFailedRequests = false } if !isRunningUnderXCTest { @@ -6857,12 +6858,8 @@ private extension NSWindow { return webView } - if let textView = responder as? NSTextView, - let delegateView = textView.delegate as? NSView, - let webView = cmuxOwningWebView(for: delegateView) { - return webView - } - + // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while + // a responder chain is tearing down can trap with "unowned reference". var current = responder.nextResponder while let next = current { if let webView = next as? CmuxWebView { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 24be16e7..9932b45d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -321,7 +321,11 @@ class GhosttyApp { private var scrollLagSampleCount = 0 private var scrollLagTotalMs: Double = 0 private var scrollLagMaxMs: Double = 0 - private let scrollLagThresholdMs: Double = 25 // Alert if tick takes >25ms during scroll + private let scrollLagThresholdMs: Double = 40 + private let scrollLagMinimumSamples = 8 + private let scrollLagMinimumAverageMs: Double = 12 + private let scrollLagReportCooldownSeconds: TimeInterval = 300 + private var lastScrollLagReportUptime: TimeInterval? private var scrollEndTimer: DispatchWorkItem? func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) { @@ -356,7 +360,18 @@ class GhosttyApp { let maxLag = scrollLagMaxMs let samples = scrollLagSampleCount let threshold = scrollLagThresholdMs - if maxLag > threshold { + let nowUptime = ProcessInfo.processInfo.systemUptime + if Self.shouldCaptureScrollLagEvent( + samples: samples, + averageMs: avgLag, + maxMs: maxLag, + thresholdMs: threshold, + minimumSamples: scrollLagMinimumSamples, + minimumAverageMs: scrollLagMinimumAverageMs, + nowUptime: nowUptime, + lastReportedUptime: lastScrollLagReportUptime, + cooldown: scrollLagReportCooldownSeconds + ) { SentrySDK.capture(message: "Scroll lag detected") { scope in scope.setLevel(.warning) scope.setContext(value: [ @@ -366,6 +381,7 @@ class GhosttyApp { "threshold_ms": threshold ], key: "scroll_lag") } + lastScrollLagReportUptime = nowUptime } // Reset stats scrollLagSampleCount = 0 @@ -624,6 +640,29 @@ class GhosttyApp { previousColorScheme != currentColorScheme } + static func shouldCaptureScrollLagEvent( + samples: Int, + averageMs: Double, + maxMs: Double, + thresholdMs: Double, + minimumSamples: Int = 8, + minimumAverageMs: Double = 12, + nowUptime: TimeInterval, + lastReportedUptime: TimeInterval?, + cooldown: TimeInterval = 300 + ) -> Bool { + guard samples >= minimumSamples else { return false } + guard averageMs.isFinite, maxMs.isFinite, thresholdMs.isFinite, nowUptime.isFinite, cooldown.isFinite else { + return false + } + guard averageMs >= minimumAverageMs else { return false } + guard maxMs > thresholdMs else { return false } + if let lastReportedUptime, nowUptime - lastReportedUptime < cooldown { + return false + } + return true + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ed9203e6..e4714326 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -113,6 +113,20 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { override var acceptsFirstResponder: Bool { true } } + private final class DelegateProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -377,6 +391,36 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") } + @MainActor + func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + 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 textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40)) + container.addSubview(textView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + _ = window.makeFirstResponder(nil) + _ = window.makeFirstResponder(textView) + + XCTAssertEqual( + textView.delegateReadCount, + 0, + "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)" + ) + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 0d912bb7..6e62f8c4 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -225,6 +225,74 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testScrollLagCaptureRequiresSustainedLag() { + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 4, + averageMs: 18, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 6, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 18, + maxMs: 35, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + XCTAssertTrue( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 10, + averageMs: 18, + maxMs: 85, + thresholdMs: 40, + nowUptime: 1000, + lastReportedUptime: nil + ) + ) + } + + func testScrollLagCaptureRespectsCooldownWindow() { + XCTAssertFalse( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 12, + averageMs: 22, + maxMs: 90, + thresholdMs: 40, + nowUptime: 1200, + lastReportedUptime: 1005, + cooldown: 300 + ) + ) + XCTAssertTrue( + GhosttyApp.shouldCaptureScrollLagEvent( + samples: 12, + averageMs: 22, + maxMs: 90, + thresholdMs: 40, + nowUptime: 1406, + lastReportedUptime: 1005, + cooldown: 300 + ) + ) + } + func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { From 575b53526c7e43dbdc40f35355330b1e7b9a74f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:39:22 -0800 Subject: [PATCH 2/2] Restore safe webview resolution for field-editor responders --- Sources/AppDelegate.swift | 129 +++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 79 +++++++++++ 2 files changed, 186 insertions(+), 22 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7c45d269..219d5b19 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6617,9 +6617,23 @@ private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? private var cmuxFirstResponderGuardHitViewOverride: NSView? #endif private var cmuxBrowserReturnForwardingDepth = 0 +private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 + +private final class CmuxFieldEditorOwningWebViewBox: NSObject { + weak var webView: CmuxWebView? + + init(webView: CmuxWebView?) { + self.webView = webView + } +} private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + let currentEvent = Self.cmuxCurrentEvent(for: self) + let responderWebView = responder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent) + } + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( window: self, responder: responder @@ -6634,9 +6648,8 @@ private extension NSWindow { } if let responder, - let webView = Self.cmuxOwningWebView(for: responder), + let webView = responderWebView, !webView.allowsFirstResponderAcquisitionEffective { - let currentEvent = Self.cmuxCurrentEvent(for: self) let pointerInitiatedFocus = Self.cmuxShouldAllowPointerInitiatedWebViewFocus( window: self, webView: webView, @@ -6669,7 +6682,7 @@ private extension NSWindow { } #if DEBUG if let responder, - let webView = Self.cmuxOwningWebView(for: responder) { + let webView = responderWebView { dlog( "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + "window=\(ObjectIdentifier(self)) " + @@ -6679,7 +6692,15 @@ private extension NSWindow { ) } #endif - return cmux_makeFirstResponder(responder) + let result = cmux_makeFirstResponder(responder) + if result { + if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { + Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { + Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } + } + return result } @objc func cmux_sendEvent(_ event: NSEvent) { @@ -6734,7 +6755,9 @@ private extension NSWindow { // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) - let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) } + let firstResponderWebView = self.firstResponder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: event) + } if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. @@ -6875,6 +6898,28 @@ private extension NSWindow { return nil } + private static func cmuxOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> CmuxWebView? { + if let webView = cmuxOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = cmuxPointerHitWebView(in: window, event: event) { + cmuxTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return cmuxTrackedOwningWebView(for: textView) + } + private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { if let webView = view as? CmuxWebView { return webView @@ -6909,28 +6954,68 @@ private extension NSWindow { return window.contentView?.hitTest(event.locationInWindow) } + private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) { + if let webView { + objc_setAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey, + CmuxFieldEditorOwningWebViewBox(webView: webView), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static func cmuxTrackedOwningWebView(for fieldEditor: NSTextView) -> CmuxWebView? { + guard let box = objc_getAssociatedObject( + fieldEditor, + &cmuxFieldEditorOwningWebViewAssociationKey + ) as? CmuxFieldEditorOwningWebViewBox else { + return nil + } + guard let webView = box.webView else { + cmuxTrackFieldEditor(fieldEditor, owningWebView: nil) + return nil + } + return webView + } + + private static func cmuxIsPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private static func cmuxPointerHitWebView(in window: NSWindow, event: NSEvent) -> CmuxWebView? { + guard cmuxIsPointerDownEvent(event) else { return nil } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event) else { + return nil + } + return cmuxOwningWebView(for: hitView) + } + private static func cmuxShouldAllowPointerInitiatedWebViewFocus( window: NSWindow, webView: CmuxWebView, event: NSEvent? ) -> Bool { - guard let event else { return false } - switch event.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - break - default: - return false - } - - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return false - } - if let eventWindow = event.window, eventWindow !== window { - return false - } - - guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event), - let hitWebView = cmuxOwningWebView(for: hitView) else { + guard let event, + let hitWebView = cmuxPointerHitWebView(in: window, event: event) else { return false } return hitWebView === webView diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e4714326..ac345a9a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -127,6 +127,25 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class FieldEditorProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + + override var isFieldEditor: Bool { + get { true } + set {} + } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -421,6 +440,66 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { ) } + @MainActor + func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + 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 webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(fieldEditor)) + XCTAssertEqual( + fieldEditor.delegateReadCount, + 0, + "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate" + ) + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu()