Skip to content

Fix 100% CPU render loop when an extension sidebar is selected (#5970) - #5972

Closed
kevinsslin wants to merge 1 commit into
manaflow-ai:mainfrom
kevinsslin:fix/extension-sidebar-render-loop
Closed

kevinsslin wants to merge 1 commit into
manaflow-ai:mainfrom
kevinsslin:fix/extension-sidebar-render-loop

Conversation

@kevinsslin

@kevinsslin kevinsslin commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #5970: selecting a bundled extension-sidebar preset (e.g. "Project Worktrees") pinned the main thread at ~100% CPU in an unbounded SwiftUI re-render loop, and because the selection persists in cmuxExtensionSidebar.providerId, the app re-entered the loop on every launch.

Root cause. VerticalTabsSidebar observed workspace state with .onReceive on a publisher built inline on every body pass:

.onReceive(extensionSidebarImmediateObservationPublisher(renderContext: renderContext).receive(on: RunLoop.main)) { _ in
    refreshExtensionSidebarSnapshot()
}

That factory returns a fresh AnyPublisher each render, so SwiftUI re-subscribed every render. Each publisher is a CombineLatest of @Published workspace fields, which replays its current value on every subscription. The replay called refreshExtensionSidebarSnapshot() -> bumped the @State extensionSidebarUpdateToken -> re-invalidated body -> re-subscribed -> replayed again. (The default workspaces sidebar doesn't wire a recreated-per-render publisher into a body-level @State bump, so it was unaffected.)

Fix. Observe via .task(id: renderContext.tabIds), bridging the existing observation publishers through .values. SwiftUI owns the subscription lifecycle: the task restarts only when the observed workspace set changes and is torn down on disappear, so a subscription is established once per genuine change instead of every frame. No new Combine view state, no shadow tab-id tracker. The publishers themselves are unchanged, so their on-subscribe replay (relied on by a late-mounting sidebar row, covered by WorkspaceSidebarObservationTests) still fires when a subscription is genuinely established.

Testing

  • Repro (before): built a Debug app at the affected commit, set cmuxExtensionSidebar.providerId = com.example.cmux.sidebar.project-worktrees with a workspace open -> sustained ~100% CPU. sample shows a continuous GraphHost.flushTransactions -> VerticalTabsSidebar.body -> CmuxExtensionSidebarSelection.descriptor(for:).
  • After: rebuilt with this change, same pref + workspace -> CPU settles to idle (~0.5%); the extension sidebar renders and provider switching works.
  • WorkspaceSidebarObservationTests (the publishers' emit-on-subscribe contract) is unaffected since the publishers are untouched.

Notes

  • Reworked from an earlier @State-publisher-caching approach to .task(id:) per reviewer feedback (cmux-swift-concurrency-modernization / cmux-swift-architectural-rethink): no new Combine @State, and the runtime owns the subscription lifecycle.

Checklist

  • I tested the change locally
  • Existing publisher tests (WorkspaceSidebarObservationTests) remain valid; the change is in view-subscription lifecycle, not the publishers
  • Demo video (can add if useful)

Summary by CodeRabbit

  • Bug Fixes
    • Optimized extension sidebar observation to avoid repeated re-subscriptions, reducing unnecessary refreshes. This improves stability and responsiveness of the sidebar, resulting in smoother interactions when switching views or tabs. The update harmonizes observation behavior across related areas to prevent redundant updates and lessen UI jitter.

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

@kevinsslin is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ContentView replaces two pairs of .onReceive-based extension-sidebar observers with Task(id: renderContext.tabIds) async loops. Each immediate observer now iterates the publisher's .values and calls refreshExtensionSidebarSnapshot() on each emission; each debounced observer applies .debounce(..., scheduler: RunLoop.main) before iterating .values. Tasks are tied to renderContext.tabIds to avoid re-subscription on every body render.

Changes

Task-based extension-sidebar observation

Layer / File(s) Summary
First site: immediate and debounced task loops
Sources/ContentView.swift
Replaces immediate and debounced .onReceive handlers with two .task(id: renderContext.tabIds) loops that iterate the respective Combine publishers via .values; the debounced task applies .debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main). Each emission calls refreshExtensionSidebarSnapshot().
Second site: immediate and debounced task loops
Sources/ContentView.swift
Applies the same replacement at the second location in the file: immediate .values iteration and a debounced .values iteration inside separate task(id: renderContext.tabIds) blocks, both invoking refreshExtensionSidebarSnapshot() per emission.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#5662: Related change to publisher emission behavior that may interact with the new task-based observation and initial emissions for late subscribers.

Poem

🐰 In a loop that once ran wild and fast,
I swapped .onReceive for tasks to last.
Debounced and tidy, emissions now nap—
The sidebar breathes easy, no re-subscribe trap. 🥕

🚥 Pre-merge checks | ✅ 20 | ❌ 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 (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a 100% CPU render loop triggered by selecting an extension sidebar, directly matching the core issue addressed in the PR.
Linked Issues check ✅ Passed The PR successfully addresses all coding objectives from issue #5970: eliminates the 100% CPU render loop by stabilizing publisher subscriptions via task-based observation, preserves on-subscribe replay semantics for existing tests, and restores idle behavior matching the default sidebar.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the extension sidebar render loop in ContentView.swift by replacing onReceive-based subscriptions with task-based observation. No unrelated modifications are present.
Cmux Swift Actor Isolation ✅ Passed ContentView.swift uses SwiftUI .task(id: renderContext.tabIds) loops that call refreshExtensionSidebarSnapshot(), which only bumps @State extensionSidebarUpdateToken; no new detached/background wri...
Cmux Swift Blocking Runtime ✅ Passed PR diff (Sources/ContentView.swift) adds cached AnyPublishers and Combine debounce, but contains no DispatchSemaphore, wait/sync, sleep/asyncAfter, polling loops, or manual locking primitives.
Cmux Expensive Synchronous Load ✅ Passed PASS: PR only changes extension-sidebar observation lifecycle (.task/.debounce) calling refreshExtensionSidebarSnapshot; the changed task blocks contain no RestorableAgentSessionIndex.load or Share...
Cmux Cache Substitution Correctness ✅ Passed Rules allow caches only for transient, non-persisted UI hints. ContentView.swift switches observation refresh to .task(id:) loops that bump an in-memory update token; no persistence/history/undo/sn...
Cmux No Hacky Sleeps ✅ Passed PASS: PR changes Swift Sources/ContentView.swift (uses .task/.debounce); runtime-no-hacky-sleeps rules scope is TypeScript/JS/shell/non-Swift, so no covered hacky sleeps were introduced.
Cmux Algorithmic Complexity ✅ Passed ContentView.swift now uses .task(id: renderContext.tabIds) with for-await .values calling refreshExtensionSidebarSnapshot (just increments a token); publisher construction uses a single linear rend...
Cmux Swift Concurrency ✅ Passed ContentView.swift replaces extension-sidebar observation with SwiftUI .task(id: renderContext.tabIds) + for await ... .values; no DispatchQueue.global/DispatchGroup/completion handlers or unstr...
Cmux Swift @Concurrent ✅ Passed ContentView.swift uses SwiftUI .task(id:renderContext.tabIds) with for-await publisher.values; refreshExtensionSidebarSnapshot is a sync token increment. No @concurrent or nonisolated async appears.
Cmux Swift File And Package Boundaries ✅ Passed Only Sources/ContentView.swift changed (+38/−18) with no new files/packages; updates are confined to view observation lifecycle in an existing UI-heavy file (no added persistence/network/parsing/pr...
Cmux Swift Logging ✅ Passed PR diff only changes extension-sidebar observation lifecycle in Sources/ContentView.swift; no added print/debugPrint/dump/NSLog calls or file-scoped Logger constants found in the changed hunks.
Cmux User-Facing Error Privacy ✅ Passed PR only changes Sources/ContentView.swift sidebar observation/publisher wiring (@State + onReceive). No new SwiftUI Alert/Text error or recovery copy (and no provider-name strings) added.
Cmux Full Internationalization ✅ Passed Diff only updates VerticalTabsSidebar observation lifecycle in Sources/ContentView.swift; no user-facing Swift text, i18n catalogs, or Info.plist/web locale files were changed.
Cmux Swiftui State Layout ✅ Passed ContentView.swift drives sidebar refresh via .task(id: renderContext.tabIds) loops calling refreshExtensionSidebarSnapshot (mutates existing @State). No new ObservableObject/@published, GeometryRea...
Cmux Architecture Rethink ✅ Passed ContentView.swift replaces extension-sidebar onReceive observation with SwiftUI .task(id: renderContext.tabIds) + for-await over publisher .values (debounced via .debounce) and calls refreshExtensi...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR diff affects only Sources/ContentView.swift (extension-sidebar observation lifecycle); no NSWindow/NSPanel/NSWindowController/Window/WindowGroup or cmuxAuxiliaryWindowIdentifiers/close-shortcut...
Cmux Source Artifacts ✅ Passed PR #5972 changes only Sources/ContentView.swift (files view shows 1 Swift file) and contains no added artifact paths like DerivedData/build/logs/temp.
Description check ✅ Passed The PR description comprehensively covers all required sections: summary with root cause analysis, detailed testing methodology, notes on design decisions, and a complete checklist.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@kevinsslin

Copy link
Copy Markdown
Contributor Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Jun 12, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@kevinsslin I can't start this review because your workspace has reached its free monthly review limit. cubic has reviewed 241,260 of the 240,000 allowed lines of code this month. Reviews resume on 1 July 2026 (in 19 days). Paid plans include much higher monthly review limits. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR details and changed files
gh pr view 5972 --repo manaflow-ai/cmux
echo "---FILES---"
gh pr diff 5972 --repo manaflow-ai/cmux

Length of output: 11625



@kevinsslin: The diagnosis and fix are both correct. Here's my review:

Review of PR #5972

Root Cause & Fix – ✅

The analysis is accurate. SwiftUI's .onReceive compares publisher identity; when the publisher reference changes on every body pass (as the old inline factory did), SwiftUI re-subscribes every frame. Each CombineLatest-backed publisher replays on subscription → extensionSidebarUpdateToken bump → body re-invalidated → loop. Caching the instances in @State and rebuilding only on tabIds change is the right solution.

Specific observations

Guard clause (correctness)

guard extensionSidebarObservedTabIds != renderContext.tabIds else { return }

[UUID] equality is O(n) but entirely fine for typical workspace counts. This correctly prevents redundant publisher rebuilds on unrelated body passes.

initial: true on .onChange
Using initial: true ensures the publishers are initialized when the view first appears rather than waiting for the first tabIds mutation. Combined with Empty() as the initial @State value, onReceive has a no-op publisher until the first onChange fires — which is safe and correct.

debounce placement
Moving the .debounce operator from the subscription site into updateExtensionSidebarObservation is semantically equivalent since RunLoop.main is used as the scheduler in both cases. ✅

Duplicate .onReceive subscriptions
Two separate view branches both subscribe to extensionSidebarImmediateObservation and extensionSidebarDebouncedObservation. This is a pre-existing pattern, not introduced by this PR, so it's out of scope here — but worth noting that both fire refreshExtensionSidebarSnapshot() independently.

Summary

The fix is minimal, targeted, and preserves the on-subscribe replay contract that WorkspaceSidebarObservationTests depends on. The CPU loop is broken, and provider switching + late-mounting sidebar rows continue to work as before. LGTM.

[approve]

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 4f4bbf094d

ℹ️ 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".

@greptile-apps

greptile-apps Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the 100% CPU render loop (#5970) by replacing four inline .onReceive(publisher.receive(on:)) subscriptions in VerticalTabsSidebar with .task(id: renderContext.tabIds) + for await _ in publisher.values {}. SwiftUI's task modifier owns the subscription lifecycle — tasks are restarted only when the observed workspace set (tabIds) changes, so the publishers are no longer rebuilt and re-subscribed on every body pass.

  • Removes the per-body-pass publisher reconstruction that replayed each @Published-backed publisher's current value → bumped extensionSidebarUpdateToken → re-invalidated the body → looped indefinitely.
  • Applies the same stabilisation to both the immediate and debounced observation publisher pairs in extensionSidebarScrollArea and the outer workspace scroll area (two call sites, four modifiers total).
  • Publisher factories and the WorkspaceSidebarObservationTests emit-on-subscribe contract are entirely unchanged; only the subscription lifecycle is fixed.

Confidence Score: 5/5

Safe to merge — the change is a well-scoped subscription-lifecycle fix that eliminates the render loop without touching any publisher logic or downstream state contracts.

The four modified subscription sites are straightforwardly converted from inline Combine .onReceive to .task(id: renderContext.tabIds) + .values. Publisher factories are untouched, refreshExtensionSidebarSnapshot() is still called on @MainActor (task inherits view isolation), and the emit-on-subscribe replay contract relied on by WorkspaceSidebarObservationTests is preserved. The earlier PR iterations that introduced @State-stored publishers and a shadow extensionSidebarObservedTabIds variable are gone; the current version is idiomatic SwiftUI. No regressions or new issues were found.

No files require special attention.

Important Files Changed

Filename Overview
Sources/ContentView.swift Four .onReceive subscription sites correctly replaced with .task(id: renderContext.tabIds) + .values bridge; publisher factories are unchanged and both task pairs restart only on workspace-set changes.

Sequence Diagram

sequenceDiagram
    participant SwiftUI as SwiftUI Runtime
    participant Task as .task(id: tabIds)
    participant Pub as Observation Publisher
    participant Refresh as refreshExtensionSidebarSnapshot()

    Note over SwiftUI,Refresh: BEFORE (CPU loop)
    SwiftUI->>Pub: body pass → new publisher created
    Pub-->>SwiftUI: .onReceive subscribes
    Pub->>Refresh: replays current value on subscribe
    Refresh->>SwiftUI: "extensionSidebarUpdateToken &+= 1"
    SwiftUI->>Pub: body re-invalidated → new publisher created
    Pub-->>SwiftUI: .onReceive re-subscribes
    Note right of SwiftUI: loop at ~100% CPU

    Note over SwiftUI,Refresh: AFTER (this PR)
    SwiftUI->>Task: view appears / tabIds changes
    Task->>Pub: publisher(renderContext).values
    Pub->>Refresh: replays current value once on subscribe
    Refresh->>SwiftUI: "extensionSidebarUpdateToken &+= 1"
    Note right of Task: task is NOT restarted (tabIds unchanged)
    SwiftUI->>SwiftUI: body re-evaluates, task id unchanged
    Pub->>Refresh: only fires on real workspace state change
    Note over Task,Pub: tabIds changes → task cancelled and restarted
Loading

Reviews (5): Last reviewed commit: "Fix 100% CPU render loop when an extensi..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift Outdated
Comment on lines +11623 to +11633
private func updateExtensionSidebarObservation(renderContext: WorkspaceListRenderContext) {
guard extensionSidebarObservedTabIds != renderContext.tabIds else { return }
extensionSidebarObservedTabIds = renderContext.tabIds
extensionSidebarImmediateObservation = extensionSidebarImmediateObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
extensionSidebarDebouncedObservation = extensionSidebarDebouncedObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}

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.

P2 Caching publishers in @State is a symptom patch, not the correct SwiftUI fix

Storing AnyPublisher<Void, Never> in @State and guarding rebuilds with a parallel extensionSidebarObservedTabIds shadow is the kind of new flag/cache/side-channel that cmux-swift-architectural-rethink calls out: the bad pattern (onReceive on a per-body-pass Combine publisher) is still present and representable; the guard just breaks the visible feedback loop. The SwiftUI-idiomatic replacement is .task(id: renderContext.tabIds) { for await _ in someAsyncSequence { refreshExtensionSidebarSnapshot() } } or a single .task(id:) that wraps both publishers via AsyncPublisher. A task modifier is torn down and restarted by SwiftUI whenever its id changes, so no shadow state, no guard comparison, and no manual publisher caching are needed — the runtime owns the subscription lifecycle.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread Sources/ContentView.swift Outdated
Comment on lines +10694 to +10696
@State private var extensionSidebarObservedTabIds: [UUID] = []
@State private var extensionSidebarImmediateObservation: AnyPublisher<Void, Never> = Empty().eraseToAnyPublisher()
@State private var extensionSidebarDebouncedObservation: AnyPublisher<Void, Never> = Empty().eraseToAnyPublisher()

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.

P2 New Combine state stored in @State — should be Swift Concurrency

extensionSidebarImmediateObservation and extensionSidebarDebouncedObservation are new AnyPublisher<Void, Never> values held in SwiftUI @State. Per cmux-swift-concurrency-modernization, new Combine app state in cmux-owned Swift should be replaced with Swift concurrency primitives. Exposing the workspace observation sources as AsyncSequence (e.g. wrapping the existing Publishers.MergeMany via values) and driving refresh from a .task(id:) modifier would eliminate the need for these two new @State fields, the shadow extensionSidebarObservedTabIds tracker, and the manual guard in updateExtensionSidebarObservation altogether.

Rule Used: Flag new legacy async patterns in cmux-owned Swift... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a 100% CPU render loop triggered by selecting a bundled extension-sidebar preset. The loop was caused by VerticalTabsSidebar creating fresh AnyPublisher instances inline inside .onReceive, causing SwiftUI to re-subscribe on every render and replay the publisher's current value, which invalidated state and re-triggered the render.

  • The two extension-sidebar observation publishers are now cached in @State and rebuilt only when renderContext.tabIds changes via .onChange(of:initial:), making subscriptions stable across unrelated body passes.
  • A new @State shadow array extensionSidebarObservedTabIds is used as a guard key inside updateExtensionSidebarObservation; this shadow copy is redundant with .onChange's own change semantics.
  • The receive(on: RunLoop.main) and debounce operators are now applied once at publisher-build time instead of on every subscription.

Confidence Score: 4/5

Safe to merge; the fix correctly breaks the infinite re-subscription loop with no functional regressions.

The core logic is sound — caching the publishers in @State and rebuilding only on tabIds changes correctly prevents the per-render resubscription cycle. The only notable issue is that extensionSidebarObservedTabIds is a shadow copy of tabIds whose guard is redundant with .onChange's own change-detection; dropping it would simplify the invariant and reduce the surface of future sync bugs, but it does not break anything today.

Sources/ContentView.swift — specifically the updateExtensionSidebarObservation method and its guard condition.

Important Files Changed

Filename Overview
Sources/ContentView.swift Caches the two extension-sidebar Combine publishers in @State and rebuilds them only when renderContext.tabIds changes via .onChange(of:initial:), correctly breaking the per-render re-subscription loop; introduces a shadow extensionSidebarObservedTabIds @State copy whose guard logic is redundant with .onChange's built-in change semantics.

Sequence Diagram

sequenceDiagram
    participant SW as SwiftUI Runtime
    participant V as VerticalTabsSidebar.body
    participant OC as .onChange(tabIds)
    participant UP as updateExtensionSidebarObservation
    participant OR as .onReceive(publisher)
    participant RS as refreshExtensionSidebarSnapshot()

    Note over SW,RS: Before fix - infinite loop
    SW->>V: body evaluation
    V->>OR: inline publisher creation, new subscription
    OR->>RS: replay on subscribe
    RS->>SW: bump extensionSidebarUpdateToken
    SW->>V: re-evaluate body (loop)

    Note over SW,RS: After fix - stable publishers
    SW->>V: body evaluation (initial)
    V->>OC: .onChange fires (initial: true)
    OC->>UP: updateExtensionSidebarObservation
    UP->>UP: guard tabIds changed, build publishers once
    UP-->>V: "@State publishers set"
    SW->>V: body re-evaluates
    V->>OR: .onReceive subscribes to same stable publisher
    OR->>RS: replay on genuine re-subscribe
    RS->>SW: bump extensionSidebarUpdateToken
    SW->>V: body re-evaluates
    V->>OC: .onChange sees same tabIds, no-op
    Note over OR,RS: no more replays
Loading

Reviews (2): Last reviewed commit: "Fix 100% CPU render loop when an extensi..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift Outdated
Comment on lines +11623 to +11633
private func updateExtensionSidebarObservation(renderContext: WorkspaceListRenderContext) {
guard extensionSidebarObservedTabIds != renderContext.tabIds else { return }
extensionSidebarObservedTabIds = renderContext.tabIds
extensionSidebarImmediateObservation = extensionSidebarImmediateObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
extensionSidebarDebouncedObservation = extensionSidebarDebouncedObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}

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.

P2 Shadow tabIds state doubles the source of truth for workspace membership. extensionSidebarObservedTabIds is a copy of renderContext.tabIds whose only purpose is to gate publisher rebuilds — it's a side channel that must stay in sync with renderContext. If renderContext.tabIds ever changes without going through .onChange on this ZStack (e.g., if the modifier is accidentally removed or the ZStack is conditionally excluded), the publishers silently become stale. Relying on the fact that .onChange fires only on change removes the need for the separate extensionSidebarObservedTabIds @State variable entirely.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a 100% CPU SwiftUI render loop triggered when an extension sidebar preset was selected. The root cause was that extensionSidebarImmediateObservationPublisher and extensionSidebarDebouncedObservationPublisher were built inline inside .onReceive, causing a new publisher to be created on every body pass; each new publisher's on-subscribe replay bumped extensionSidebarUpdateToken, re-invalidating the view in an unbounded cycle.

  • Adds three @State variables to hold the two observation publishers and a mirror of the observed tabIds; rebuilds publishers only via .onChange(of: renderContext.tabIds, initial: true), making them stable across unrelated body passes.
  • Replaces the four inline .onReceive(…publisher factory…) call sites with extensionSidebarImmediateObservation and extensionSidebarDebouncedObservation @State properties, eliminating per-render re-subscription.
  • Two non-blocking concerns: @State-held AnyPublisher instances are Combine-specific view state that run against the repo's @Observable direction, and the renderContext captured in the onChange closure could theoretically be stale when the action fires after a rapid sequence of unrelated body passes.

Confidence Score: 4/5

The change correctly eliminates the render loop and is safe to merge; the two comments are clean-up suggestions that do not affect correctness.

The fix is targeted and mechanically sound: moving publisher construction out of body into a lifecycle handler removes the per-frame re-subscription that caused the loop. The bounded 2-pass cascade on workspace-set changes is acceptable. Both flagged concerns are style/architecture observations rather than functional defects.

Sources/ContentView.swift — specifically the updateExtensionSidebarObservation function and the .onChange closure for the renderContext capture pattern.

Important Files Changed

Filename Overview
Sources/ContentView.swift Caches two extension-sidebar Combine publishers in @State and rebuilds them only on workspace-set changes via .onChange(of: renderContext.tabIds, initial: true); fixes the 100% CPU render loop from per-body-pass .onReceive re-subscription. Two style/architecture concerns: @State-held AnyPublisher is non-idiomatic and the renderContext capture in the onChange closure can theoretically be stale.

Sequence Diagram

sequenceDiagram
    participant SwiftUI as SwiftUI Runtime
    participant Body as VerticalTabsSidebar.body
    participant State as @State publishers
    participant onReceive as .onReceive
    participant Pub as AnyPublisher (Merged @Published)
    participant Token as extensionSidebarUpdateToken

    Note over SwiftUI,Token: BEFORE fix - unbounded loop
    SwiftUI->>Body: evaluate body
    Body->>onReceive: inline factory() new AnyPublisher
    onReceive->>Pub: subscribe (new instance every pass)
    Pub-->>onReceive: replay current value (on-subscribe)
    onReceive->>Token: refreshExtensionSidebarSnapshot() token++
    Token-->>SwiftUI: "@State change - invalidate body"
    SwiftUI->>Body: evaluate body (loop)

    Note over SwiftUI,Token: AFTER fix - bounded, stable
    SwiftUI->>Body: evaluate body
    Body->>State: read cached extensionSidebarImmediateObservation
    State-->>onReceive: same AnyPublisher instance (stable)
    Note over onReceive: no re-subscription - no replay - no token bump

    Note over SwiftUI,State: On tabIds change only
    SwiftUI->>Body: tabIds changed - .onChange fires
    Body->>State: updateExtensionSidebarObservation() new AnyPublisher
    State-->>onReceive: new instance - re-subscribe once
    Pub-->>onReceive: replay current value
    onReceive->>Token: token++ (one body re-render)
    Token-->>SwiftUI: "@State change"
    SwiftUI->>Body: evaluate body
    Body->>State: same publisher instances (guard prevented rebuild)
    Note over onReceive: no re-subscription - loop stops
Loading

Reviews (3): Last reviewed commit: "Fix 100% CPU render loop when an extensi..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift Outdated
Comment on lines +11623 to +11633
private func updateExtensionSidebarObservation(renderContext: WorkspaceListRenderContext) {
guard extensionSidebarObservedTabIds != renderContext.tabIds else { return }
extensionSidebarObservedTabIds = renderContext.tabIds
extensionSidebarImmediateObservation = extensionSidebarImmediateObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
extensionSidebarDebouncedObservation = extensionSidebarDebouncedObservationPublisher(renderContext: renderContext)
.receive(on: RunLoop.main)
.debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}

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.

P2 @State-held AnyPublisher is new Combine view state

The three new @State vars (extensionSidebarObservedTabIds, extensionSidebarImmediateObservation, extensionSidebarDebouncedObservation) introduce explicit Combine state management into the view — the publisher instances are owned and lifetime-managed by the view's @State bag rather than by a dedicated object. While the fix is correct, the pattern is non-idiomatic and runs against the repo's push away from new ObservableObject/Combine-owned view state. A @StateObject (or @Observable-conforming) coordinator that initialises its own merged publisher from tabIds and exposes a single updateToken-style change signal would own the publisher lifetime, avoid the extensionSidebarObservedTabIds mirror variable entirely, and make the re-subscription contract visible at the type level rather than in a body-level onChange closure. Worth a follow-up to bring this pattern into the @Observable shape the rest of the sidebar state uses, even if the immediate fix is safe.

Rule Used: Flag new legacy async patterns in cmux-owned Swift... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread Sources/ContentView.swift Outdated
Comment on lines +11123 to +11125
.onChange(of: renderContext.tabIds, initial: true) { _, _ in
updateExtensionSidebarObservation(renderContext: renderContext)
}

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.

P2 renderContext captured in onChange closure can diverge from emitted tabIds

The closure discards the newValue parameter and calls updateExtensionSidebarObservation(renderContext: renderContext) using a copy of renderContext captured from the surrounding body scope. In the common case this is fine, but if a second body pass (for an unrelated state change) produces a new renderContext before this onChange action fires, the publishers are rebuilt from a snapshot that already matches extensionSidebarObservedTabIds. The guard on line 11624 (extensionSidebarObservedTabIds != renderContext.tabIds) would compare the captured (possibly stale) renderContext.tabIds against the stored one, potentially passing when it should not or skipping when it should. Using the emitted newValue of the onChange parameter (i.e., { _, newTabIds in ... }) for the guard comparison, and ensuring renderContext.tabs is sourced from the live tabManager at call time, would make this immune to the snapshot-staleness risk.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

…low-ai#5970)

VerticalTabsSidebar observed workspace state with `.onReceive` on a Combine
publisher rebuilt inline on every body pass, so SwiftUI re-subscribed on
every render. Each publisher is a CombineLatest of @published fields that
replays its current value on subscription; that replay bumped the @State
extensionSidebarUpdateToken, re-invalidated the body, and re-subscribed
again: an unbounded re-render loop that pinned the main thread at ~100% CPU
whenever a non-default extension sidebar (e.g. the bundled "Project
Worktrees" preset) was selected. The selection persists, so the app
re-entered the loop on every launch.

Observe via `.task(id: renderContext.tabIds)` instead, bridging the existing
observation publishers through `.values`. SwiftUI now owns the subscription
lifecycle: it restarts only when the observed workspace set changes and is
torn down on disappear, so a subscription is established once per genuine
change rather than every frame. No new Combine view state and no shadow
tab-id tracker. The publishers are unchanged, so their on-subscribe replay
(relied on by a late-mounting sidebar row, covered by
WorkspaceSidebarObservationTests) still works.
@kevinsslin
kevinsslin force-pushed the fix/extension-sidebar-render-loop branch from 4f4bbf0 to 73da164 Compare June 12, 2026 10:31
@kevinsslin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Agreed on the cmux-swift-concurrency-modernization / cmux-swift-architectural-rethink point: storing AnyPublisher in @State with a shadow tabIds guard was a symptom patch.

Reworked to the suggested Swift Concurrency shape: two .task(id: renderContext.tabIds) modifiers that bridge the existing observation publishers via .values and call refreshExtensionSidebarSnapshot() on each element. SwiftUI now owns the subscription lifecycle (restarted only when the observed workspace set changes, torn down on disappear), so this removes:

  • the three new @State fields (extensionSidebarImmediateObservation, extensionSidebarDebouncedObservation, extensionSidebarObservedTabIds),
  • the updateExtensionSidebarObservation guard helper, and
  • the .onChange(of:initial:) lifecycle hook.

This also addresses the renderContext-staleness note: the task is keyed on renderContext.tabIds, so the captured renderContext always matches the id that (re)started the task. No new Combine view state and no shadow source of truth.

Verified locally on a from-source build at the affected commit: with cmuxExtensionSidebar.providerId = com.example.cmux.sidebar.project-worktrees and a workspace open, the main thread now settles to idle (was a sustained ~100% before), and the extension sidebar still renders and refreshes.

(The two failing Vercel checks are Authorization required to deploy on fork PRs, not related to this change.)

@kevinsslin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review
@greptile-apps review

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh pr diff 5972 --repo manaflow-ai/cmux

Length of output: 4427



@kevinsslin: The Swift Concurrency rework is a clear improvement over the first iteration — fewer concepts (no shadow state, no onChange guard), and the fix is now structurally enforced by SwiftUI's task lifecycle rather than defended by a manual equality check. Here is a targeted pass over the updated diff:


.task(id: renderContext.tabIds) – lifecycle correctness ✅

SwiftUI cancels and restarts each .task modifier independently when its id changes. Two modifiers with the same id are still independent tasks that restart and cancel together — which is exactly the right behaviour here: both the immediate and debounced subscriptions should reset whenever the observed workspace set changes.

Main-actor safety ✅

SwiftUI's .task modifier inherits the view's @MainActor context, so each for await body runs on the main thread. Removing the .receive(on: RunLoop.main) from the immediate publisher is safe for the same reason. refreshExtensionSidebarSnapshot() is always called on the main thread.

renderContext capture freshness ✅

Because the task closure is recreated on every restart, the captured renderContext always corresponds to the tabIds that triggered the restart. The staleness concern from the first iteration is eliminated.

debounce on extensionSidebarDebouncedObservationPublisher ✅

The second view branch previously had an accidental double .receive(on: RunLoop.main) before the .debounce. The new code drops that redundant operator and keeps only the .debounce(for:scheduler:) — a tidy cleanup.

Publisher factory call site

extensionSidebarImmediateObservationPublisher(renderContext:) and extensionSidebarDebouncedObservationPublisher(renderContext:) are called inside the task closure, so they run exactly once per task lifetime (once per tabIds change). Any cost in those constructors is now amortized correctly, not paid on every body pass.


Summary: The approach is idiomatic, minimal, and correct. The CPU loop is broken at the structural level. LGTM. 🐇

[approve]

@azooz2003-bit

Copy link
Copy Markdown
Collaborator

Reviewed this and reproduced the CPU issue before/after.

The CPU fix is real: with cmuxExtensionSidebar.providerId = com.example.cmux.sidebar.project-worktrees, the merge-base build r5972b sustained high CPU, ps samples were mostly ~130-300%, top showed 173.9% after startup, and sample showed GraphHost.flushTransactions -> VerticalTabsSidebar.body -> CmuxExtensionSidebarSelection.descriptor. The PR build r5972p with the same provider settled to 0-4% in top, and its sample did not show that SwiftUI body loop.

I think there is still one blocker before merge: the new .task(id: renderContext.tabIds) observation fixes the resubscription loop, but it also removes the old incidental refresh path for hosted extension snapshots when only SidebarUnreadModel changes. extensionWorkspaceSnapshot includes sidebarUnread.unreadCount(...) and latestNotificationText, while CMUXInstalledExtensionSidebarHostView only sends sidebarSnapshotDidChange() when snapshotUpdateToken or snapshot sequence changes. Unread-only changes can therefore leave hosted extension sidebars stale until another workspace publisher emits.

Suggested fix: add an explicit bounded refresh trigger for the unread projection consumed by hosted extension snapshots, without reintroducing body-level Combine resubscription.

@azooz2003-bit

Copy link
Copy Markdown
Collaborator

CI follow-up: workflow-guard-tests is failing on the Swift file length budget, not on the CPU behavior.

Failure:

Swift file length budget exceeded.
+12 Sources/ContentView.swift
   actual=19277 budget=19265
Split the file, reduce the new growth, or refresh the budget only when accepting known debt.

Since this PR only changes ContentView.swift, the lowest-risk path is probably to reduce the added comment/code lines while adding the hosted-extension unread refresh fix, rather than updating the budget for this bug fix.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks for this! The 100% CPU extension sidebar render loop (#5970) landed on main in #6341. You opened this first, so you got there first. Closing since main covers it now.

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.

Selecting a bundled extension-sidebar preset pins cmux at ~100% CPU (sustained SwiftUI re-render loop in VerticalTabsSidebar)

3 participants