Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Sources/BrowserWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2281,6 +2281,28 @@ final class WindowBrowserPortal: NSObject {
return
}
guard anchorView.window === window else {
let isOffWindowReparent =
entry.visibleInUI &&
anchorView.window == nil &&
anchorView.superview != nil
if isOffWindowReparent {
let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "anchorWindowMismatch"
)
#if DEBUG
if didScheduleTransientRecovery && !containerView.isHidden {
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
)
}
#endif
Comment on lines +2295 to +2302

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading debug log name

The log key browser.portal.hidden.deferKeep is emitted inside the guard !containerView.isHidden, meaning the container is currently visible and is being kept visible — it is not being hidden. The name hidden in the key will confuse log-analysis tooling or anyone grepping for hide events.

A name like browser.portal.visible.deferKeep or browser.portal.sync.deferKeep would better reflect what is actually happening at this point.

Suggested change
#if DEBUG
if didScheduleTransientRecovery && !containerView.isHidden {
dlog(
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
)
}
#endif
dlog(
"browser.portal.visible.deferKeep web=\(browserPortalDebugToken(webView)) " +
"reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
)

containerView.setDropZoneOverlay(zone: nil)
return
Comment on lines +2288 to +2304

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zombie portal when retry budget is exhausted

When scheduleTransientRecoveryRetryIfNeeded returns false (budget exhausted — i.e. the anchor has been off-window for more than transientRecoveryRetryBudget sync cycles), the code still returns early without hiding the container. No further deferred syncs are scheduled at that point, so the portal remains unconditionally visible with no remaining recovery path.

Compare this to the existing code immediately below: when the non-off-window anchor-window-mismatch path exhausts its budget, scheduleTransientDetachRecovery returns false and the code falls through to unconditionally hide and clean up the container.

In the off-window path the only escape from this state is an external call to synchronizeWebViewForAnchor (e.g. when the anchor is re-added to the window hierarchy). If that call never arrives — or arrives only after a long delay — the portal will remain visible indefinitely in a zombie state, showing stale content with no frame updates and only the drop-zone overlay cleared.

Consider hiding the container (and clearing the remaining overlays) when the budget is exhausted, analogous to the fallthrough behaviour in the non-off-window path:

if isOffWindowReparent {
    let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded(
        forWebViewId: webViewId,
        entry: &entry,
        webView: webView,
        reason: "anchorWindowMismatch"
    )
    if didScheduleTransientRecovery {
#if DEBUG
        if !containerView.isHidden {
            dlog(
                "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
                "reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))"
            )
        }
#endif
        containerView.setDropZoneOverlay(zone: nil)
        return
    }
    // Budget exhausted — fall through to hide/cleanup below
}

Comment on lines +2303 to +2304

@cubic-dev-ai cubic-dev-ai Bot Mar 7, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the transient recovery budget is exhausted (scheduleTransientRecoveryRetryIfNeeded returns false), this block still returns early, leaving the container visible at a stale frame with no recovery scheduled. Gate the early return on the scheduling result so it falls through to the existing hide logic when retries are exhausted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/BrowserWindowPortal.swift, line 2303:

<comment>When the transient recovery budget is exhausted (`scheduleTransientRecoveryRetryIfNeeded` returns `false`), this block still returns early, leaving the container visible at a stale frame with no recovery scheduled. Gate the early return on the scheduling result so it falls through to the existing hide logic when retries are exhausted.</comment>

<file context>
@@ -2281,6 +2281,28 @@ final class WindowBrowserPortal: NSObject {
+                    )
+                }
+#endif
+                containerView.setDropZoneOverlay(zone: nil)
+                return
+            }
</file context>
Suggested change
containerView.setDropZoneOverlay(zone: nil)
return
containerView.setDropZoneOverlay(zone: nil)
if didScheduleTransientRecovery {
return
}
Fix with Cubic

}
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
Expand Down
55 changes: 55 additions & 0 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10745,6 +10745,61 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
)
}

func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)

guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}

let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
let anchor = NSView(frame: anchorFrame)
contentView.addSubview(anchor)

let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(anchor)
advanceAnimations()

guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}

let offWindowContainer = NSView(frame: anchorFrame)
anchor.removeFromSuperview()
offWindowContainer.addSubview(anchor)
portal.synchronizeWebViewForAnchor(anchor)
advanceAnimations()

XCTAssertTrue(
webView.superview === slot,
"Off-window anchor reparent should preserve the hosted browser slot during drag churn"
)
XCTAssertFalse(
slot.isHidden,
"Off-window anchor reparent should keep the visible browser portal alive until the anchor returns"
)
XCTAssertEqual(portal.debugEntryCount(), 1)

contentView.addSubview(anchor)
portal.synchronizeWebViewForAnchor(anchor)
advanceAnimations()

XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot")
XCTAssertFalse(slot.isHidden)
XCTAssertEqual(portal.debugEntryCount(), 1)
}

func testRegistryDetachRemovesPortalHostedWebView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
Expand Down