From 3e13b53b1c206a7ae4b48c018659b4941b5e223e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:41:33 -0700 Subject: [PATCH 1/3] Add regression test: live-resize tick must glue hosted terminal frames in-tick During a live window resize, the portal's didResize handling schedules its geometry pass through the main queue, so every tick commits with the previous tick's hosted frames and the terminal trails the window edge for the whole drag. Ghostty hosts surfaces directly in the view hierarchy and has no such gap. Test-only commit so CI shows red, the fix follows. Co-Authored-By: Claude Fable 5 --- ...dowPortalLifecycleHiddenRefreshTests.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 From eb82aca7591bde0144fda90b982fa8af53ff6735 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:44:16 -0700 Subject: [PATCH 2/3] Glue terminal frames to live window resize ticks During a live window resize the portal observed NSWindow.didResize on the main OperationQueue and then coalesced its geometry pass through DispatchQueue.main.async, so hosted terminal frames were written at least one runloop turn after each resize tick committed. Every tick painted the window's new size with the previous tick's terminal frames, and the terminal visibly trailed the window edge for the whole drag. Ghostty hosts surfaces directly in the view hierarchy, so its frames land in the same layout pass; cmux's portal indirection is why cmux felt slower. Observe didResize with queue nil (delivered synchronously inside setFrame, while the tick's transaction is still open) and, while a live resize is active, run the full geometry pass synchronously instead of scheduling it. The pass forces subtree layout first, so it reads final anchor frames (bonsplit re-imposes divider fractions in the same layout pass), writes hosted frames in the same commit as the window's new size, and pushes grid-changing surface resizes in-tick. All synchronous-redraw paths are already gated off during live resize, so the pass cannot reach the open-transaction Metal displayIfNeeded wedge; re-entrant echoes terminate in currentlySynchronizingPortalId and the geometry signature check. Non-live-resize ticks keep the scheduled, coalesced path. Co-Authored-By: Claude Fable 5 --- Sources/TerminalWindowPortal.swift | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index c84c33e9829e..8939ec0708c0 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( From 98b35d38d5a04db873b7a2ad09b345187806ad27 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:52:04 -0700 Subject: [PATCH 3/3] Defer the reveal redraw when the sync pass runs inside a live-resize tick The synchronous in-tick pass runs inside the resize tick's still-open transaction, where the reveal branch's refreshSurfaceNow reaches ghostty's Metal drawFrame via displayIfNeeded and wedges on a present only that transaction can commit. A reveal cannot skip its redraw outright (the surface would sit blank until later churn), so route it through the existing one-turn deferred refresh instead. Co-Authored-By: Claude Fable 5 --- Sources/TerminalWindowPortal.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 8939ec0708c0..057ae11f68a4 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -2230,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")