diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index c84c33e9829e..057ae11f68a4 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -780,8 +780,17 @@ final class WindowTerminalPortal: NSObject { geometryObservers.append(center.addObserver( forName: NSWindow.didResizeNotification, object: window, - queue: .main + queue: nil ) { [weak self] notification in + // queue nil on purpose: NSWindow posts didResize synchronously + // from inside setFrame on the main thread, so this handler runs + // while the resize tick's transaction is still open — the only + // point where hosted frames can land in the SAME commit as the + // window's new size. A .main operation queue defers the handler + // by a runloop turn, and during a live resize that turn is the + // visible difference between the terminal tracking the window + // edge and trailing it by a frame for the whole drag. + guard Thread.isMainThread else { return } MainActor.assumeIsolated { #if DEBUG // Standing tripwire for PROGRAMMATIC window growth — the @@ -805,7 +814,21 @@ final class WindowTerminalPortal: NSObject { } #endif guard let self, self.selfFrameWriteDepth == 0 else { return } - self.scheduleExternalGeometrySynchronize() + if self.isWindowLiveResizeActive { + // Live resize: run the pass INSIDE this tick so hosted + // frames commit together with the window's new size. The + // pass forces subtree layout first (fresh anchor frames) + // and every synchronous-redraw path is already gated off + // while live resize is active (see synchronizeHostedView + // and reconcileVisibleHostedViewsAfterGeometrySync), so + // it cannot reach the open-transaction Metal wedge that + // displayIfNeeded would risk here. Re-entrant echoes die + // in currentlySynchronizingPortalId and the geometry + // signature check. + self.synchronizeAllEntriesFromExternalGeometryChange() + } else { + self.scheduleExternalGeometrySynchronize() + } } }) geometryObservers.append(center.addObserver( @@ -2207,7 +2230,14 @@ final class WindowTerminalPortal: NSObject { // normal frame-change refresh path won't run. Nudge geometry + redraw so newly // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. hostedView.reconcileGeometryNow() - if syncLayout { + // Mid window live-resize the pass runs synchronously inside the + // resize tick's still-open transaction (see the didResize + // observer), where refreshSurfaceNow's displayIfNeeded reaches + // ghostty's Metal drawFrame and wedges on a present only that + // transaction can commit. Unlike the frame-change branch above, + // a reveal cannot skip its redraw outright — the surface would + // sit blank until later churn — so defer it one main-queue turn. + if syncLayout, !isWindowLiveResizeActive { hostedView.refreshSurfaceNow(reason: "portal.reveal") } else { deferSurfaceRefresh(forHostedId: hostedId, reason: "portal.reveal.deferred") diff --git a/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift b/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift index bbfc75c539e0..392a6aa0173c 100644 --- a/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift +++ b/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift @@ -158,6 +158,65 @@ extension TerminalWindowPortalLifecycleTests { withExtendedLifetime((leftSurface, rightSurface)) {} } + /// Regression: during a live window resize, each `didResize` tick must + /// synchronize hosted terminal frames INSIDE the tick — in the same + /// transaction that commits the window's new size. The portal's queued + /// sync (one main-queue hop) paints every tick with the PREVIOUS tick's + /// hosted frames, so the terminal visibly trails the window edge during + /// the whole drag (Ghostty hosts surfaces directly in the hierarchy and + /// has no such gap). + @MainActor + func testLiveResizeTickSynchronizesHostedFrameWithinTheSameTick() throws { + let window = makeTestWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), + styleMask: [.titled, .closable, .resizable] + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let portal = makeTrackedPortal(window: window) + let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) + anchor.autoresizingMask = [.width, .height] + contentView.addSubview(anchor) + + let surface = makeTrackedTerminalSurface() + portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) + portal.synchronizeHostedViewForAnchor(anchor) + drainMainQueue() + realizeWindowLayout(window) + XCTAssertEqual( + surface.hostedView.frame.size, + NSSize(width: 240, height: 160), + "Precondition: the hosted view tracks the anchor at rest" + ) + + portal.isWindowLiveResizeActiveOverrideForTesting = true + + // One live-resize tick: setFrame posts didResize synchronously and + // the anchor grows with the content view through its autoresizing + // mask before the notification fires. + var frame = window.frame + frame.size.width += 100 + frame.size.height += 60 + window.setFrame(frame, display: false) + + // No queue drain on purpose: the assertion runs before any queued + // portal pass can fire, exactly like the tick's own CA commit does. + XCTAssertEqual( + surface.hostedView.frame.size, + NSSize(width: 340, height: 220), + "A live-resize tick must glue hosted frames within the same tick, not a runloop turn later" + ) + withExtendedLifetime(surface) {} + } + /// Regression: switching a pane's tab from a terminal to a browser hides /// the terminal only through its registry entry — the SwiftUI update that /// carries visible=false is dropped by the portal-host ownership gate