Fix portal lifecycle retention and add regression tests

This commit is contained in:
Lawrence Chen 2026-02-18 20:17:44 -08:00
parent edadda6d8c
commit 22aa4d48da
2 changed files with 143 additions and 8 deletions

View file

@ -1787,3 +1787,57 @@ final class WindowTerminalHostViewTests: XCTestCase {
XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100)))
}
}
@MainActor
final class TerminalWindowPortalLifecycleTests: XCTestCase {
func testRegistryPrunesPortalWhenWindowCloses() {
let baseline = TerminalWindowPortalRegistry.debugPortalCount()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
_ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
}
func testPruneDeadEntriesDetachesAnchorlessHostedView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let hosted1 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
contentView.addSubview(anchor1!)
portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
anchor1?.removeFromSuperview()
anchor1 = nil
let hosted2 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
contentView.addSubview(anchor2)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
}
}

View file

@ -2,6 +2,7 @@ import AppKit
import ObjectiveC
private var cmuxWindowTerminalPortalKey: UInt8 = 0
private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0
final class WindowTerminalHostView: NSView {
override var isOpaque: Bool { false }
@ -147,6 +148,7 @@ final class WindowTerminalPortal: NSObject {
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return }
synchronizeHostedView(withId: hostedId)
}
@ -194,21 +196,18 @@ final class WindowTerminalPortal: NSObject {
}
private func pruneDeadEntries() {
let currentWindow = window
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
if entry.hostedView == nil {
if let anchor = entry.anchorView {
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
guard entry.hostedView != nil else { return hostedId }
guard let anchor = entry.anchorView else { return hostedId }
if anchor.window !== currentWindow || anchor.superview == nil {
return hostedId
}
if entry.anchorView == nil {
entry.hostedView?.isHidden = true
}
return nil
}
for hostedId in deadHostedIds {
entriesByHostedId.removeValue(forKey: hostedId)
detachHostedView(withId: hostedId)
}
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
@ -217,6 +216,31 @@ final class WindowTerminalPortal: NSObject {
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func hostedIds() -> Set<ObjectIdentifier> {
Set(entriesByHostedId.keys)
}
func tearDown() {
for hostedId in Array(entriesByHostedId.keys) {
detachHostedView(withId: hostedId)
}
NSLayoutConstraint.deactivate(installConstraints)
installConstraints.removeAll()
hostView.removeFromSuperview()
installedContainerView = nil
installedReferenceView = nil
}
#if DEBUG
func debugEntryCount() -> Int {
entriesByHostedId.count
}
func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif
func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
@ -252,15 +276,65 @@ enum TerminalWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak window] _ in
MainActor.assumeIsolated {
if let window {
removePortal(for: window)
} else {
removePortal(windowId: windowId, window: nil)
}
}
}
objc_setAssociatedObject(
window,
&cmuxWindowTerminalPortalCloseObserverKey,
observer,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private static func removePortal(for window: NSWindow) {
removePortal(windowId: ObjectIdentifier(window), window: window)
}
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
portal.tearDown()
}
hostedToWindowId = hostedToWindowId.filter { $0.value != windowId }
guard let window else { return }
if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) {
NotificationCenter.default.removeObserver(observer)
}
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set<ObjectIdentifier>) {
hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in
mappedWindowId != windowId || validHostedIds.contains(hostedId)
}
}
private static func portal(for window: NSWindow) -> WindowTerminalPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
let portal = WindowTerminalPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
installWindowCloseObserverIfNeeded(for: window)
return portal
}
@ -278,6 +352,7 @@ enum TerminalWindowPortalRegistry {
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI)
hostedToWindowId[hostedId] = windowId
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
}
static func synchronizeForAnchor(_ anchorView: NSView) {
@ -295,4 +370,10 @@ enum TerminalWindowPortalRegistry {
let portal = portal(for: window)
return portal.terminalViewAtWindowPoint(windowPoint)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
#endif
}