Skip to content

Fix spinner hang after display resolution changes - #1549

Merged
lawrencecchen merged 3 commits into
mainfrom
issue-1541-resolution-spinner
Mar 17, 2026
Merged

lawrencecchen merged 3 commits into
mainfrom
issue-1541-resolution-spinner

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a regression test that reproduces stale terminal portal geometry after a queued layout shift
  • defer terminal portal geometry synchronization until the current AppKit layout churn finishes, avoiding re-entrant sync during display changes

Testing

  • xcodebuild -quiet -project /Users/lawrence/fun/cmuxterm-hq/worktrees/issue-1541-resolution-spinner/GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-issue-1541-red-final -only-testing:cmuxTests/TerminalWindowPortalLifecycleTests/testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift -only-testing:cmuxTests/TerminalWindowPortalLifecycleTests/testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift test (fails on 94f7529a, new regression test fails)
  • xcodebuild -quiet -project /Users/lawrence/fun/cmuxterm-hq/worktrees/issue-1541-resolution-spinner/GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-issue-1541-green3 -only-testing:cmuxTests/TerminalWindowPortalLifecycleTests/testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift -only-testing:cmuxTests/TerminalWindowPortalLifecycleTests/testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift test (passes)
  • ./scripts/reload.sh --tag issue-1541-resolution-spinner (build succeeds)

Issues


Summary by cubic

Fixes a spinner hang after display resolution changes by deferring terminal portal geometry sync until AppKit finishes the current layout pass while keeping syncs immediate during live resize; closes #1541. Adds a regression test that verifies the sync waits for queued layout shifts and updates the portal position correctly.

  • Bug Fixes
    • Defer external geometry sync with double main-queue dispatch when not in live resize; keep immediate sync during live resize to avoid lag.
    • Replace immediate syncs in GhosttyTerminalView with a revision-keyed scheduler to dedupe updates.
    • Add regression test: testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift.

Written for commit 95ef1c8. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor

    • Geometry synchronization now uses deferred, coalesced updates that respect live-resize and layout turns, reducing immediate per-host syncs and redundant work.
  • Behavior

    • Timing of external-geometry synchronization was shifted to occur after an extra run-loop tick when needed, ensuring updates wait for settled layout shifts.
  • Tests

    • Added coverage that verifies synchronization waits for queued layout changes and that portals update to the final anchored positions.

@vercel

vercel Bot commented Mar 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 17, 2026 1:50am

@greptile-apps greptile-apps Bot left a comment

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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Mar 16, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

HostContainerView now defers and coalesces portal-geometry synchronization via a new private synchronization helper, replacing immediate per-host registry calls. TerminalWindowPortal synchronization also gains an extra asynchronous hop. A unit test verifies synchronization waits for queued layout shifts.

Changes

Cohort / File(s) Summary
Host container geometry changes
Sources/GhosttyTerminalView.swift
Added private static synchronizePortalGeometry(for:coordinator:) in HostContainerView; replaced direct TerminalWindowPortalRegistry.synchronizeForAnchor(host) calls with the deferred/coalesced helper.
Portal scheduling adjustments
Sources/TerminalWindowPortal.swift
Introduced additional DispatchQueue.main.async nesting for scheduleExternalGeometrySynchronize and scheduleExternalGeometrySynchronizeForAllWindows, shifting when flags are cleared and when synchronizeAllEntriesFromExternalGeometryChange() runs.
Test coverage
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Added testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift to validate that global external-geometry sync defers until a queued layout shift settles and portals update to the final anchored position.

Sequence Diagram(s)

sequenceDiagram
    participant AppKit as AppKit Layout
    participant HCV as HostContainerView
    participant DQ as DispatchQueue.main
    participant Portal as TerminalWindowPortal

    rect rgba(220, 20, 60, 0.5)
    Note over AppKit,Portal: Previous (Synchronous Re-entry)
    AppKit->>HCV: onGeometryChanged()
    HCV->>Portal: synchronizeForAnchor(host) [immediate]
    Portal->>Portal: reconcileGeometryNow()
    Portal->>Portal: refreshSurfaceNow()
    Note over HCV,Portal: ⚠️ Can re-enter AppKit layout / block
    end

    rect rgba(34, 139, 34, 0.5)
    Note over AppKit,Portal: New (Deferred, Coalesced)
    AppKit->>HCV: onGeometryChanged()
    HCV->>DQ: synchronizePortalGeometry(for:host, coordinator:)
    DQ->>Portal: scheduleExternalGeometrySynchronize() [coalesced]
    DQ->>DQ: nested async hop (delay to next run loop tick)
    DQ->>Portal: synchronizeAllEntriesFromExternalGeometryChange()
    Note over HCV,Portal: ✓ Non-blocking, uses settled layout
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰
A hop, a pause, I wait my turn,
I nudge the frames, let layouts learn.
No frantic sync that blocks the day —
I coalesce, then bounce away.
Hooray for calm geometry play!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main issue being fixed: a spinner hang after display resolution changes, which directly matches the linked issue #1541 and the PR's primary objective.
Description check ✅ Passed The description includes Summary and Testing sections with detailed explanation of changes, test results, and linked issue. All critical information is present despite differing slightly from the template format.
Linked Issues check ✅ Passed The code changes directly address issue #1541 by deferring portal geometry synchronization during layout churn and adding a regression test to verify the fix handles queued layout shifts correctly.
Out of Scope Changes check ✅ Passed All changes are scoped to the synchronization mechanism for terminal portal geometry, the regression test, and related coordination logic, all directly supporting the resolution of #1541.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-1541-resolution-spinner
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 3 files

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d1d4722f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +8582 to +8586
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
// Avoid synchronizing the terminal portal while AppKit is still inside
// the current layout turn. Re-entrant syncs here can wedge window resize
// handling and leave the app spinning on the wait cursor.
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()

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 Badge Avoid scheduling external sync for detached hosts

scheduleDeferredPortalGeometrySynchronize now always queues scheduleExternalGeometrySynchronizeForAllWindows() after a geometry revision bump, but unlike the previous synchronizeForAnchor(host) path it no longer short-circuits when host.window == nil. During SwiftUI reparent/detach churn (viewDidMoveToWindow/viewDidMoveToSuperview), this can trigger unnecessary full-portal syncs across every window, including reconcileGeometryNow()/refreshSurfaceNow() on unrelated terminals, which is a user-visible performance regression under window/workspace churn.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

13124-13125: Use deterministic queue draining instead of fixed sleep.

Line 13124 uses a timed RunLoop wait, which can be flaky for nested DispatchQueue.main.async chains on busy CI machines.

♻️ Suggested stabilization
-        RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+        let firstDrain = expectation(description: "drain main queue (1)")
+        DispatchQueue.main.async { firstDrain.fulfill() }
+        wait(for: [firstDrain], timeout: 1.0)
+
+        let secondDrain = expectation(description: "drain main queue (2)")
+        DispatchQueue.main.async { secondDrain.fulfill() }
+        wait(for: [secondDrain], timeout: 1.0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 13124 - 13125,
The test currently uses a fixed RunLoop.sleep call (RunLoop.current.run(until:
Date().addingTimeInterval(0.05))) which is flaky; replace it with a
deterministic queue drain using an XCTestExpectation: schedule a
DispatchQueue.main.async that fulfills an expectation and then call wait(for:
[expectation], timeout: X) so the test only proceeds after the enqueued
main-queue work completes (this avoids relying on timed sleeps for nested
DispatchQueue.main.async chains). Use the existing RunLoop/current dispatching
point as the location to swap in the expectation-based drain.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 13124-13125: The test currently uses a fixed RunLoop.sleep call
(RunLoop.current.run(until: Date().addingTimeInterval(0.05))) which is flaky;
replace it with a deterministic queue drain using an XCTestExpectation: schedule
a DispatchQueue.main.async that fulfills an expectation and then call wait(for:
[expectation], timeout: X) so the test only proceeds after the enqueued
main-queue work completes (this avoids relying on timed sleeps for nested
DispatchQueue.main.async chains). Use the existing RunLoop/current dispatching
point as the location to swap in the expectation-based drain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b3ff42d1-7177-4c96-8973-0b094ff912e7

📥 Commits

Reviewing files that changed from the base of the PR and between 3b507d3 and 8d1d472.

📒 Files selected for processing (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalWindowPortal.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 8583-8585: The code currently calls
TerminalWindowPortalRegistry.synchronizeForAnchor(host) immediately when
host.inLiveResize or host.window?.inLiveResize == true, which can re-enter
AppKit during live-resize; change this to defer synchronization instead of
calling synchronizeForAnchor synchronously — e.g., skip the immediate call when
host.inLiveResize is true and schedule a deferred synchronization for that host
(using a main-queue async/next-runloop dispatch or a window live-resize end
observer) so TerminalWindowPortalRegistry.synchronizeForAnchor(host) runs after
live-resize finishes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d943118-d77f-414e-8dca-f79eccaf5e70

📥 Commits

Reviewing files that changed from the base of the PR and between 8d1d472 and 95ef1c8.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalWindowPortal.swift

Comment on lines +8583 to +8585
if host.inLiveResize || host.window?.inLiveResize == true {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid immediate portal synchronization during live-resize churn

Line 8584 still calls the immediate synchronizeForAnchor path during live resize. That can re-enter AppKit layout/resize work and reintroduce the spinner-hang path this PR is targeting.

💡 Proposed fix
-        if host.inLiveResize || host.window?.inLiveResize == true {
-            TerminalWindowPortalRegistry.synchronizeForAnchor(host)
-            return
-        }
-        // Avoid synchronizing the terminal portal while AppKit is still inside
-        // the current layout turn. Re-entrant syncs here can wedge window resize
-        // handling and leave the app spinning on the wait cursor.
-        TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+        // Avoid re-entrant portal sync during AppKit layout/live-resize churn.
+        // Keep this deferred so geometry reconciliation happens after the current turn.
+        TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 8583 - 8585, The code
currently calls TerminalWindowPortalRegistry.synchronizeForAnchor(host)
immediately when host.inLiveResize or host.window?.inLiveResize == true, which
can re-enter AppKit during live-resize; change this to defer synchronization
instead of calling synchronizeForAnchor synchronously — e.g., skip the immediate
call when host.inLiveResize is true and schedule a deferred synchronization for
that host (using a main-queue async/next-runloop dispatch or a window
live-resize end observer) so
TerminalWindowPortalRegistry.synchronizeForAnchor(host) runs after live-resize
finishes.

This branch was successfully deployed

1 active deployment
Preview — 95ef1c8c Deployed Mar 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hang with spinning wait cursor after screen resolution changes

1 participant