Skip to content

Fix workspace layout follow-up spin loop - #1633

Merged
austinywang merged 1 commit into
mainfrom
issue-1628-layout-followup-spin-loop
Mar 18, 2026
Merged

austinywang merged 1 commit into
mainfrom
issue-1628-layout-followup-spin-loop

Conversation

@austinywang

@austinywang austinywang commented Mar 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • dedupe Workspace layout follow-up scheduling so observers do not enqueue an unbounded number of main-queue passes
  • add stalled-pass backoff and only continue follow-up when one of the tracked pending conditions actually makes progress
  • avoid redundant browser and terminal portal visibility/refresh work so a stuck browser pane does not keep force-refreshing every iteration

Testing

  • ./scripts/reload.sh --tag fix-1628-layout-followup

Fixes #1628.


Summary by cubic

Fixes a layout follow-up spin loop by deduping attempts, adding backoff when no progress is made, and avoiding redundant portal refreshes. Stabilizes focus and portal updates to stop unbounded main-queue passes. Fixes #1628.

  • Bug Fixes
    • Deduplicated layout follow-up scheduling with a single queued attempt and a small exponential backoff when stalled.
    • Detects “real” progress by comparing pending conditions before/after; only reschedules on progress.
    • Reduces redundant portal work: only toggles terminal/browser visibility and active state when values change; synchronizes/refreshes browser portals only when anchors are ready or containers are hidden.
    • Uses debug snapshots to prevent repeated refresh/hide loops; adds a debugPortalActive getter to avoid unnecessary setActive calls.

Written for commit 3c2ad9f. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved layout stability and responsiveness by implementing exponential backoff and scheduling for layout follow-up retries
    • Enhanced visibility reconciliation for terminal and browser portal components with better progress tracking and stall recovery
    • Optimized layout adjustment mechanisms to prevent excessive recalculations during portal visibility updates

@vercel

vercel Bot commented Mar 18, 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 18, 2026 0:01am

@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 18, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes introduce exponential backoff scheduling and stall detection to prevent infinite re-enqueueing loops in layout follow-up observers. A debug property exposing portal active state is added to GhosttyTerminalView.

Changes

Cohort / File(s) Summary
Terminal View Debug Property
Sources/GhosttyTerminalView.swift
Added read-only computed property debugPortalActive exposing scroll view's active state for debugging.
Workspace Layout Follow-Up Resilience
Sources/Workspace.swift
Added exponential backoff, stall tracking, and scheduling to prevent non-converging layout follow-up loops. Introduced layoutFollowUpStalledAttemptCount and layoutFollowUpAttemptScheduled state variables. New methods: scheduleLayoutFollowUpAttempt(), layoutFollowUpBackoffDelay(), terminalFocusNeedsFollowUp(), browserPanelNeedsFollowUp(). Modified reconciliation methods to return Bool with @discardableResult for change detection. Updated call sites to use scheduled enqueueing instead of immediate dispatch.

Possibly Related PRs

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A spin loop spins round and round,
CPU screams with nary a sound!
With backoff and scheduling so wise,
The rabbit brings stalls to demise.
Now layout converges with grace—
Sanity restored to this place! ✨

🚥 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 'Fix workspace layout follow-up spin loop' directly and concisely describes the main issue being addressed in the changeset.
Description check ✅ Passed The PR description covers what changed and why, includes testing instructions, and provides clear context. However, it lacks a demo video section and incomplete checklist items.
Linked Issues check ✅ Passed All three suggested fixes from issue #1628 are implemented: deduplication of enqueued work, exponential backoff when stalled, and dirty-flag checks to only reschedule on progress.
Out of Scope Changes check ✅ Passed All changes directly address the spin loop issue: deduplication logic, backoff scheduling, progress detection, and the debugPortalActive property to reduce redundant portal refreshes.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-1628-layout-followup-spin-loop
📝 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 2 files

@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 (2)
Sources/Workspace.swift (2)

8152-8157: Consider using reconcile change flags in didMakeProgress.

You now return Bool from both reconcile methods, but didMakeProgress only checks pending-state transitions. Including these change flags would avoid classifying intermediate reconciliation as a stall when pending predicates remain true.

♻️ Proposed refinement
-        reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
+        let terminalPortalDidChange = reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
         let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp()
@@
-        reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
+        let browserPortalDidChange = reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
         let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp()
@@
         let didMakeProgress =
             (geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) ||
             (terminalPortalPendingBefore && !terminalPortalPending) ||
             (browserVisibilityPendingBefore && !browserVisibilityPending) ||
             (terminalFocusPendingBefore && !terminalFocusPending) ||
             (browserPanelPendingBefore && !browserPanelPending) ||
-            (browserExitPendingBefore && !browserExitPending)
+            (browserExitPendingBefore && !browserExitPending) ||
+            terminalPortalDidChange ||
+            browserPortalDidChange

Also applies to: 8211-8217, 8297-8321, 8343-8402

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8152 - 8157, didMakeProgress currently
only checks pending-state predicates (terminalPortalVisibilityNeedsFollowUp,
browserPortalVisibilityNeedsFollowUp) and therefore treats intermediate
reconciliation as a stall even though the reconcile methods now return Bool
change flags; update didMakeProgress to also consider the Bool results from
reconcileTerminalPortalVisibilityForCurrentRenderedLayout() and
reconcileBrowserPortalVisibilityForCurrentRenderedLayout() (and analogous
reconcile calls at the other sites mentioned) so that any returned true from
these methods counts as progress even if the "needsFollowUp" predicates remain
true—call the reconcile methods, capture their Bool return values (e.g.,
terminalChanged, browserChanged), and include those flags in the progress
determination along with the existing pending-state checks.

8035-8049: Make scheduled follow-up attempts cancellable across follow-up generations.

DispatchQueue.main.asyncAfter at Line 8057 is not tied to a cancellable work item today, so a stale callback can fire after clearLayoutFollowUp() and bleed into a new cycle. It’s safer to store/cancel a DispatchWorkItem.

♻️ Proposed refactor
@@
     private var layoutFollowUpAttemptScheduled = false
     private var layoutFollowUpStalledAttemptCount = 0
+    private var layoutFollowUpAttemptWorkItem: DispatchWorkItem?
@@
     private func clearLayoutFollowUp() {
@@
+        layoutFollowUpAttemptWorkItem?.cancel()
+        layoutFollowUpAttemptWorkItem = nil
         layoutFollowUpAttemptScheduled = false
         layoutFollowUpStalledAttemptCount = 0
     }
@@
     private func scheduleLayoutFollowUpAttempt() {
         guard layoutFollowUpTimeoutWorkItem != nil else { return }
         guard !layoutFollowUpAttemptScheduled else { return }
 
         layoutFollowUpAttemptScheduled = true
         let delay = layoutFollowUpBackoffDelay()
-        DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+        layoutFollowUpAttemptWorkItem?.cancel()
+        let workItem = DispatchWorkItem { [weak self] in
             guard let self else { return }
             self.layoutFollowUpAttemptScheduled = false
+            self.layoutFollowUpAttemptWorkItem = nil
             self.attemptEventDrivenLayoutFollowUp()
         }
+        layoutFollowUpAttemptWorkItem = workItem
+        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
     }

Also applies to: 8051-8061

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8035 - 8049, The scheduled follow-up
callbacks use DispatchQueue.main.asyncAfter without a cancellable work item, so
create and use a DispatchWorkItem for these timeouts: allocate a
DispatchWorkItem, assign it to the existing layoutFollowUpTimeoutWorkItem (or a
new clearly named property if another asyncAfter is used), cancel any previous
work item before assigning, schedule it with
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem), and
ensure clearLayoutFollowUp continues to cancel and nil out
layoutFollowUpTimeoutWorkItem; apply the same pattern to the other asyncAfter
usages referenced (the follow-up attempt scheduling range) so all scheduled
callbacks are cancellable across follow-up generations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 8152-8157: didMakeProgress currently only checks pending-state
predicates (terminalPortalVisibilityNeedsFollowUp,
browserPortalVisibilityNeedsFollowUp) and therefore treats intermediate
reconciliation as a stall even though the reconcile methods now return Bool
change flags; update didMakeProgress to also consider the Bool results from
reconcileTerminalPortalVisibilityForCurrentRenderedLayout() and
reconcileBrowserPortalVisibilityForCurrentRenderedLayout() (and analogous
reconcile calls at the other sites mentioned) so that any returned true from
these methods counts as progress even if the "needsFollowUp" predicates remain
true—call the reconcile methods, capture their Bool return values (e.g.,
terminalChanged, browserChanged), and include those flags in the progress
determination along with the existing pending-state checks.
- Around line 8035-8049: The scheduled follow-up callbacks use
DispatchQueue.main.asyncAfter without a cancellable work item, so create and use
a DispatchWorkItem for these timeouts: allocate a DispatchWorkItem, assign it to
the existing layoutFollowUpTimeoutWorkItem (or a new clearly named property if
another asyncAfter is used), cancel any previous work item before assigning,
schedule it with DispatchQueue.main.asyncAfter(deadline: .now() + delay,
execute: workItem), and ensure clearLayoutFollowUp continues to cancel and nil
out layoutFollowUpTimeoutWorkItem; apply the same pattern to the other
asyncAfter usages referenced (the follow-up attempt scheduling range) so all
scheduled callbacks are cancellable across follow-up generations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 94dea376-a212-4c8b-b72a-fd790b5c4d92

📥 Commits

Reviewing files that changed from the base of the PR and between 43d1fd4 and 3c2ad9f.

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

This branch was successfully deployed

1 active deployment
Preview — 3c2ad9ff Deployed Mar 18, 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.

installLayoutFollowUpObservers spin loop: 110% CPU, +740MB heap from non-converging browser portal reconciliation

1 participant