From cfa0ca710659175d3ae21b2c136dcf1024fd5d91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 4 Aug 2026 06:29:15 -0700 Subject: [PATCH] macos: defer transparent titlebar KVO rebinding Fixes #13386 Defer transparent-titlebar KVO rebinding to the next main-queue turn. Track the observed tab group so unchanged bindings are preserved. Previously, a tab-group callback could invalidate and recreate its own observation before returning, leaving closed terminal windows registered with AppKit after the undo timeout. These windows accumulated titlebar and layer state, increasing memory use and WindowServer CPU with tab churn. Validated with an AppDelegate change that sat and created/closed tabs in a loop, then counted weak controllers/windows/nsapp window. Co-authored-by: Mustafa J --- .../TransparentTitlebarTerminalWindow.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index c0e506c34..0f5858cf3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -9,6 +9,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? /// KVO observation for tab group window changes. + private weak var observedTabGroup: NSWindowTabGroup? private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? @@ -129,9 +130,27 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // MARK: Tab Group Observation private func setupKVO() { - // See the docs for the respective setup functions for why. - setupTabGroupObservation() - setupTabBarVisibleObservation() + // This can run from one of the observation callbacks below. Replacing + // an observation before its callback returns leaves the window retained + // by AppKit, so always rebind on the next main-queue turn. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + // Recheck because the tab group and observation state may have changed + // while this work was waiting on the main queue. + let currentTabGroup = self.tabGroup + let observationsValid = currentTabGroup == nil || ( + self.tabGroupWindowsObservation != nil && + self.tabBarVisibleObservation != nil + ) + + // Keep the existing observations when they already match. + guard self.observedTabGroup !== currentTabGroup || !observationsValid else { return } + + self.observedTabGroup = currentTabGroup + self.setupTabGroupObservation() + self.setupTabBarVisibleObservation() + } } /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change.