From eaea51da19898d2b0a941817429585102476de3a Mon Sep 17 00:00:00 2001 From: cmux Date: Mon, 28 Sep 2026 13:55:34 -0700 Subject: [PATCH 1/2] test: reproduce deferred tab hint lookup during window teardown --- .../TabBarHostWindowLifetimeTests.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Tests/BonsplitTests/TabBarHostWindowLifetimeTests.swift diff --git a/Tests/BonsplitTests/TabBarHostWindowLifetimeTests.swift b/Tests/BonsplitTests/TabBarHostWindowLifetimeTests.swift new file mode 100644 index 00000000..630086eb --- /dev/null +++ b/Tests/BonsplitTests/TabBarHostWindowLifetimeTests.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI +import Testing +@testable import Bonsplit + +@Suite("Tab bar host window lifetime") +@MainActor +struct TabBarHostWindowLifetimeTests { + private final class ReentrantTeardownWindow: NSWindow { + deinit { + // Model AppKit draining pending main-queue work while the window + // is deallocating but its content view is still attached. + CFRunLoopRunInMode(.defaultMode, 0.01, false) + } + } + + @Test("Deferred tab hint lookup tolerates window deallocation") + func deferredLookupDuringWindowDeallocation() async { + _ = NSApplication.shared + let released = await withCheckedContinuation { continuation in + // A run-loop callback permits reentrant dispatch during teardown; + // dispatching this block itself would keep the main queue occupied. + CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) { + MainActor.assumeIsolated { + continuation.resume(returning: Self.releaseHostedWindow()) + } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) + } + #expect(released) + } + + private static func releaseHostedWindow() -> Bool { + weak var releasedWindow: NSWindow? + autoreleasepool { + let controller = BonsplitController() + controller.createTab(title: "Lifetime probe") + let window = ReentrantTeardownWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + releasedWindow = window + window.contentView = NSHostingView(rootView: + BonsplitView(controller: controller) { _, _ in Color.clear } + ) + window.contentView?.layoutSubtreeIfNeeded() + } + return releasedWindow == nil + } +} From de8e3d2d48128e406ac421e4a5b5cf2250b9eb06 Mon Sep 17 00:00:00 2001 From: cmux Date: Mon, 28 Sep 2026 13:57:38 -0700 Subject: [PATCH 2/2] fix: resolve deferred tab hints through a live window reference --- .../Bonsplit/Internal/Views/TabBarView.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 63c2ff36..f5f135b7 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -3159,17 +3159,29 @@ enum TabControlShortcutHintPolicy { private struct TabBarHostWindowReader: NSViewRepresentable { let onResolve: (NSWindow?) -> Void - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { [weak view] in - onResolve(view?.window) + func makeNSView(context: Context) -> WindowTrackingView { + let view = WindowTrackingView() + Task { @MainActor [weak view] in + onResolve(view?.trackedWindow) } return view } - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { [weak nsView] in - onResolve(nsView?.window) + func updateNSView(_ nsView: WindowTrackingView, context: Context) { + Task { @MainActor [weak nsView] in + onResolve(nsView?.trackedWindow) + } + } + + final class WindowTrackingView: NSView { + // NSView.window can still return a deallocating window. Capture it + // while AppKit attaches the view so deferred lookups safely read nil + // during teardown, before the shortcut monitor forms another weak ref. + private(set) weak var trackedWindow: NSWindow? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + trackedWindow = window } } }