From e41f39bd75e9e0faa33939b39cc438c3ecfaf863 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Mar 2026 19:18:53 -0700 Subject: [PATCH 1/3] test: cover stale terminal portal after restore-like rebind --- cmuxTests/TerminalAndGhosttyTests.swift | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 24ec48a6..e87bfdf1 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2596,6 +2596,70 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") } + func testDeferredSyncHidesVisibleHostedViewAfterAnchorDisappears() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + var retiredAnchor: NSView? = NSView(frame: NSRect(x: 24, y: 28, width: 96, height: 180)) + contentView.addSubview(retiredAnchor!) + + let retiredTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 96, height: 180)) + let retiredHosted = GhosttySurfaceScrollView(surfaceView: retiredTerminal) + portal.bind(hostedView: retiredHosted, to: retiredAnchor!, visibleInUI: true) + portal.synchronizeHostedViewForAnchor(retiredAnchor!) + + let retiredWindowPoint = retiredAnchor!.convert( + NSPoint(x: retiredAnchor!.bounds.midX, y: retiredAnchor!.bounds.midY), + to: nil + ) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(retiredWindowPoint) === retiredTerminal, + "Initial hit-testing should resolve the first hosted terminal at its anchor" + ) + + retiredAnchor?.removeFromSuperview() + retiredAnchor = nil + + let activeAnchor = NSView(frame: NSRect(x: 184, y: 28, width: 280, height: 180)) + contentView.addSubview(activeAnchor) + + let activeTerminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 280, height: 180)) + let activeHosted = GhosttySurfaceScrollView(surfaceView: activeTerminal) + portal.bind(hostedView: activeHosted, to: activeAnchor, visibleInUI: true) + portal.synchronizeHostedViewForAnchor(activeAnchor) + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let activeWindowPoint = activeAnchor.convert( + NSPoint(x: activeAnchor.bounds.midX, y: activeAnchor.bounds.midY), + to: nil + ) + XCTAssertTrue( + retiredHosted.isHidden, + "A visible hosted terminal whose anchor vanished should hide on the deferred full sync" + ) + XCTAssertNil( + portal.terminalViewAtWindowPoint(retiredWindowPoint), + "Restore-like rebinds should clear stale portal hit regions after the old anchor disappears" + ) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(activeWindowPoint) === activeTerminal, + "The active terminal should remain visible after the stale hosted view is hidden" + ) + } + func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() { let window = ContentViewCountingWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), From 836360db360af00b072995f8ab94d6331f5b4cb4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Mar 2026 19:19:05 -0700 Subject: [PATCH 2/3] fix: hide stale terminal portal after restore churn --- Sources/TerminalWindowPortal.swift | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 4c4588a8..4d5d843d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1211,18 +1211,33 @@ final class WindowTerminalPortal: NSObject { return } guard let anchorView = entry.anchorView, let window else { - // Only hide if the entry is not marked visibleInUI. When a workspace is - // remounting, updateNSView sets visibleInUI=true before the deferred bind - // provides an anchor — hiding here would race with that and cause a flash. - if !entry.visibleInUI { + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !hostedView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forHostedId: hostedId, + entry: &entry, + hostedView: hostedView, + reason: "missingAnchorOrWindow" + ) + if shouldPreserveVisibleOnTransient { #if DEBUG - if !hostedView.isHidden { - dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") - } + dlog( + "portal.hidden.deferKeep hosted=\(portalDebugToken(hostedView)) " + + "reason=missingAnchorOrWindow frame=\(portalDebugFrame(hostedView.frame))" + ) #endif - hostedView.isHidden = true - resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + return + } } else { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) + } +#if DEBUG + if !hostedView.isHidden { + dlog("portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 reason=missingAnchorOrWindow") + } +#endif + hostedView.isHidden = true + if entry.visibleInUI { _ = scheduleTransientRecoveryRetryIfNeeded( forHostedId: hostedId, entry: &entry, From 8c0aee3155f601b751b03513d0031bd8ea12981d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 24 Mar 2026 00:04:30 -0700 Subject: [PATCH 3/3] test: clarify stale portal rebind sync points --- cmuxTests/TerminalAndGhosttyTests.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index e87bfdf1..7bbdddc6 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2640,19 +2640,20 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { portal.bind(hostedView: activeHosted, to: activeAnchor, visibleInUI: true) portal.synchronizeHostedViewForAnchor(activeAnchor) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + retiredHosted.isHidden, + "A visible hosted terminal whose anchor vanished should hide as soon as the replacement anchor sync runs" + ) + // Drain the queued full-sync turn so the portal clears any stale hit-test region left by the rebind. + drainMainQueue() let activeWindowPoint = activeAnchor.convert( NSPoint(x: activeAnchor.bounds.midX, y: activeAnchor.bounds.midY), to: nil ) - XCTAssertTrue( - retiredHosted.isHidden, - "A visible hosted terminal whose anchor vanished should hide on the deferred full sync" - ) XCTAssertNil( portal.terminalViewAtWindowPoint(retiredWindowPoint), - "Restore-like rebinds should clear stale portal hit regions after the old anchor disappears" + "Restore-like rebinds should clear stale portal hit regions on the queued portal resync" ) XCTAssertTrue( portal.terminalViewAtWindowPoint(activeWindowPoint) === activeTerminal,