Skip to content

Fix 100% CPU from ContentView publisher feedback loop (fixes #3020) - #3028

Merged
austinywang merged 4 commits into
mainfrom
issue-3020-content-view-cpu-loop
Apr 20, 2026
Merged

austinywang merged 4 commits into
mainfrom
issue-3020-content-view-cpu-loop

Conversation

@austinywang

@austinywang austinywang commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3020.

Summary

  • Guard SessionIndexStore.currentDirectory writes behind setCurrentDirectoryIfChanged(_:) so equal values do not fire @Published from syncFileExplorerDirectory().
  • Move the selected-workspace/current-directory Combine chain out of ContentView.body into a stable @StateObject observer, so body re-evaluation no longer recreates the subscription and resets removeDuplicates().
  • Add an XCTest/Combine regression test for equal-value current-directory setter publishes.

Verification

  • Pushed branch to origin (https://github.com/manaflow-ai/cmux.git).
  • Built and launched with ./scripts/reload.sh --tag issue-3020-content-view-cpu-loop --launch.
  • Opened Activity Monitor.
  • Idle tagged cmux DEV issue-3020-content-view-cpu-loop process sampled at 14.4% CPU, then 11.8% CPU five seconds later, down from the reported 100% loop.

Tests were not run locally per repo policy.


Note

Medium Risk
Refactors SwiftUI/Combine state propagation for workspace directory changes; behavior changes around when currentDirectory publishes could affect sidebar/session filtering if edge cases are missed.

Overview
Fixes a runaway SwiftUI/Combine update loop that could peg CPU when syncing the file explorer/session index scope to the selected workspace directory.

Moves the selected-workspace/currentDirectory Combine chain out of ContentView.body into a stable @StateObject (SelectedWorkspaceDirectoryObserver) and triggers syncFileExplorerDirectory() via a generation counter to avoid re-subscribing on every body re-evaluation.

Adds SessionIndexStore.setCurrentDirectoryIfChanged(_:) and switches call sites (including RightSidebarPanelView) to avoid equal-value @Published emissions, with a new Combine regression test ensuring duplicate sets don’t publish.

Reviewed by Cursor Bugbot for commit c346693. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes a 100% CPU spin caused by a Combine feedback loop when syncing the file explorer directory to the selected workspace (fixes #3020). Keeps the subscription stable and blocks equal-value publishes to stop cascading updates.

  • Bug Fixes
    • Introduced SelectedWorkspaceDirectoryObserver as a @StateObject and switched to onChange of a generation counter to keep the subscription stable.
    • Guarded SessionIndexStore.currentDirectory with setCurrentDirectoryIfChanged(_:) and applied it in ContentView and RightSidebarPanelView.
    • Added a regression test to ensure equal-value sets do not publish, and fixed the SessionIndexView test harness section initializer to include entries.

Written for commit c346693. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor

    • Stabilized workspace-directory observation and file-explorer synchronization to keep subscriptions stable across view updates and avoid redundant directory writes, reducing unnecessary UI refreshes.
  • Tests

    • Added coverage verifying directory-change notifications are only emitted when the directory actually changes.

@vercel

vercel Bot commented Apr 20, 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 Apr 20, 2026 4:31am

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a MainActor SelectedWorkspaceDirectoryObserver to stabilize workspace directory subscriptions in ContentView, introduces SessionIndexStore.setCurrentDirectoryIfChanged(_:), and switches callers to use the conditional setter to avoid redundant @Published emissions and rebuilds during SwiftUI body re-evaluations.

Changes

Cohort / File(s) Summary
Content view & observer
Sources/ContentView.swift
Add @MainActor SelectedWorkspaceDirectoryObserver as a @StateObject; move subscription wiring to wire(tabManager:) onAppear; replace inline Combine subscription with onChange(of: directoryChangeGeneration) calling syncFileExplorerDirectory().
Session index store
Sources/SessionIndexStore.swift
Add func setCurrentDirectoryIfChanged(_ next: String?) that only updates currentDirectory when the new value differs (avoids redundant @Published emissions).
Right sidebar
Sources/RightSidebarPanelView.swift
Replace direct sessionIndexStore.currentDirectory = ... writes with setCurrentDirectoryIfChanged(sessionIndexDirectory) and add sessionIndexDirectory: String? computed mapping.
Tests
cmuxTests/SessionIndexViewTests.swift
Add import Combine, update IndexSection construction, and add testCurrentDirectorySetterDoesNotPublishEqualValue to assert setCurrentDirectoryIfChanged does not emit duplicate values.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as "ContentView / UI"
    participant TabMgr as "TabManager"
    participant Observer as "SelectedWorkspaceDirectoryObserver"
    participant Workspace as "Workspace (currentDirectory)"
    participant SessionStore as "SessionIndexStore"

    rect rgba(200,200,255,0.5)
    UI->>TabMgr: wire subscription onAppear (selectedTabId)
    TabMgr->>Observer: publish selectedTabId
    end

    rect rgba(200,255,200,0.5)
    Observer->>Workspace: resolve selected workspace
    Workspace-->>Observer: stream currentDirectory
    Observer->>UI: increment directoryChangeGeneration
    end

    rect rgba(255,200,200,0.5)
    UI->>Observer: onChange(directoryChangeGeneration)
    UI->>SessionStore: call syncFileExplorerDirectory()
    SessionStore->>SessionStore: setCurrentDirectoryIfChanged(next)
    SessionStore-->>UI: publish only if value changed
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I watched the tabs in careful rows,
I counted hops where wild publish goes.
A gentle guard, a steady beat,
Now directories change when changes meet.
🥕✨

🚥 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 accurately summarizes the main change: fixing a 100% CPU issue caused by a ContentView publisher feedback loop, with reference to the issue number.
Linked Issues check ✅ Passed The pull request fully addresses issue #3020: implements equal-value write guards via setCurrentDirectoryIfChanged, moves the Combine chain to stable @StateObject storage, and adds regression tests.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to fixing the CPU feedback loop: adding the observer, guarding writes, updating call sites, and adding tests. No extraneous changes detected.
Description check ✅ Passed PR description is comprehensive and well-structured, covering summary, verification steps, and test implementation details as required by the template.

✏️ 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-3020-content-view-cpu-loop

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.

@greptile-apps

greptile-apps Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a 100% CPU feedback loop (issue #3020) caused by two compounding problems: ContentView.body was recreating a Combine subscription on every render (resetting removeDuplicates()), and syncFileExplorerDirectory() was unconditionally writing to SessionIndexStore.currentDirectory even when the value hadn't changed, re-triggering renders. The fix introduces a stable @StateObject observer (SelectedWorkspaceDirectoryObserver) with proper removeDuplicates guards, and a setCurrentDirectoryIfChanged(_:) helper that short-circuits equal-value writes.

  • The regression test in SessionIndexViewTests.swift has a compile error: IndexSection is initialized without the required entries: argument (line 102), which will break the test target build.

Confidence Score: 4/5

The core logic fix is sound, but the test file has a compile error that must be resolved before merging.

One P1 finding: the test target will not build due to a missing entries: argument in the IndexSection initializer. The fix itself — the @StateObject observer and the setCurrentDirectoryIfChanged guard — is correct and addresses the root cause cleanly.

cmuxTests/SessionIndexViewTests.swift — compile error in makeHarness; must add entries: [] to the IndexSection call.

Important Files Changed

Filename Overview
cmuxTests/SessionIndexViewTests.swift Adds regression tests for the CPU loop fix; makeHarness has a compile error — IndexSection is constructed without the required entries: argument, breaking the test target build.
Sources/SessionIndexStore.swift Adds setCurrentDirectoryIfChanged(_:) guard to prevent @Published from firing for equal currentDirectory writes; the didSet guard is a clean secondary safety layer for direct property access.
Sources/ContentView.swift Adds SelectedWorkspaceDirectoryObserver as a stable @StateObject with a removeDuplicates-guarded Combine chain, ending the body-recreation feedback loop; the Task { @MainActor } inside sink is a minor unnecessary async hop.

Sequence Diagram

sequenceDiagram
    participant TM as TabManager
    participant Obs as SelectedWorkspaceDirectoryObserver (@StateObject)
    participant CV as ContentView.body
    participant SIS as SessionIndexStore

    Note over CV: body re-evaluation (stable @StateObject, no subscription teardown)
    CV->>Obs: wire(tabManager:) [idempotent guard]

    TM-->>Obs: $selectedTabId publishes
    Obs->>Obs: removeDuplicates(by workspace id)
    Obs->>Obs: switchToLatest to workspace.$currentDirectory
    Obs->>Obs: removeDuplicates(by workspace id + dir)
    Obs->>Obs: directoryChangeGeneration += 1

    CV->>CV: onChange(directoryChangeGeneration) fires
    CV->>SIS: syncFileExplorerDirectory()
    SIS->>SIS: setCurrentDirectoryIfChanged(dir)
    SIS-->>SIS: currentDirectory published only if changed
Loading

Comments Outside Diff (1)

  1. cmuxTests/SessionIndexViewTests.swift, line 102-106 (link)

    P1 Missing entries: in IndexSection initializer

    IndexSection has four stored properties (key, title, icon, entries) and no custom initializer, so the auto-generated memberwise initializer requires all four. Omitting entries: here is a compile error that will prevent the test target from building. Add entries: [] as the fourth argument to the IndexSection constructor call on line 102.

Reviews (1): Last reviewed commit: "Fix ContentView directory sync feedback ..." | Re-trigger Greptile

Comment thread Sources/ContentView.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

🧹 Nitpick comments (2)
Sources/SessionIndexStore.swift (1)

294-297: Route direct writes through setCurrentDirectoryIfChanged(_:) for consistency.

RightSidebarPanelView.swift bypasses the guard at two sites: line 50 (inside the mode-switch conditional in modeBar) and line 76 (in .onAppear of contentForMode). Both write sessionIndexStore.currentDirectory = … directly, allowing equal-value assignments to fire unnecessary @Published updates. ContentView.swift already adopts this pattern uniformly across four call sites; migrating these two writes would align RightSidebarPanelView with the established approach and prevent regressions from similar feedback loops.

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

In `@Sources/SessionIndexStore.swift` around lines 294 - 297,
RightSidebarPanelView currently writes sessionIndexStore.currentDirectory
directly in two places (inside the mode-switch conditional in modeBar and in the
.onAppear of contentForMode); change those direct assignments to call
sessionIndexStore.setCurrentDirectoryIfChanged(_:) instead so the guard-based
check prevents redundant `@Published` updates—replace both occurrences of
sessionIndexStore.currentDirectory = … with a call to
setCurrentDirectoryIfChanged(next) and remove the direct writes to keep behavior
consistent with ContentView's existing pattern.
Sources/ContentView.swift (1)

1905-1910: Redundant Task { @mainactor … } inside a @MainActor-isolated sink.

The observer is @MainActor, and .receive(on: DispatchQueue.main) already delivers the sink on the main queue, so wrapping directoryChangeGeneration &+= 1 in Task { @mainactor [weak self] in … } just adds an extra async hop that can reorder increments relative to the tab-switch side effects in TabManager.selectedTabId.didSet (which itself uses DispatchQueue.main.async). A direct assignment keeps the update tightly ordered with the source event and is still safe w.r.t. the "no body mutation" rule because this closure runs from a Combine dispatch, not from body.

♻️ Proposed simplification
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] _ in
-                Task { `@MainActor` [weak self] in
-                    self?.directoryChangeGeneration &+= 1
-                }
-            }
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.directoryChangeGeneration &+= 1
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1905 - 1910, The sink closure
currently wraps self?.directoryChangeGeneration &+= 1 inside an extra Task {
`@MainActor` … } which is redundant and can reorder updates; remove the Task
wrapper so the Combine subscriber delivered via .receive(on: DispatchQueue.main)
directly performs self?.directoryChangeGeneration &+= 1. Locate the Combine
chain using .receive(on: DispatchQueue.main) and the .sink that references
directoryChangeGeneration and replace the async Task block with a direct
increment, ensuring ordering with TabManager.selectedTabId.didSet (which uses
DispatchQueue.main.async) remains consistent.
🤖 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/ContentView.swift`:
- Around line 1890-1911: The current compactMap drops nil selectedTabId values
so the pipeline never emits a "no-selection" event; change the chain so
tabManager.$selectedTabId is mapped (not compactMapped) into an inner
AnyPublisher that emits a sentinel/no-selection tuple when tabId is nil (instead
of returning nil) so switchToLatest gets detached and the downstream
removeDuplicates/sink sees the deselection; update the intermediate publisher
types and the removeDuplicates comparison to handle the optional/no-selection
tuple and keep incrementing directoryChangeGeneration in the existing sink
branch (symbols to change: tabManager.$selectedTabId mapping, the closure that
currently returns nil, the inner publisher returned for
workspace.$currentDirectory, switchToLatest usage, and the removeDuplicates
comparison that currently compares previous.0/next.0 and previous.1/next.1).

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 1905-1910: The sink closure currently wraps
self?.directoryChangeGeneration &+= 1 inside an extra Task { `@MainActor` … }
which is redundant and can reorder updates; remove the Task wrapper so the
Combine subscriber delivered via .receive(on: DispatchQueue.main) directly
performs self?.directoryChangeGeneration &+= 1. Locate the Combine chain using
.receive(on: DispatchQueue.main) and the .sink that references
directoryChangeGeneration and replace the async Task block with a direct
increment, ensuring ordering with TabManager.selectedTabId.didSet (which uses
DispatchQueue.main.async) remains consistent.

In `@Sources/SessionIndexStore.swift`:
- Around line 294-297: RightSidebarPanelView currently writes
sessionIndexStore.currentDirectory directly in two places (inside the
mode-switch conditional in modeBar and in the .onAppear of contentForMode);
change those direct assignments to call
sessionIndexStore.setCurrentDirectoryIfChanged(_:) instead so the guard-based
check prevents redundant `@Published` updates—replace both occurrences of
sessionIndexStore.currentDirectory = … with a call to
setCurrentDirectoryIfChanged(next) and remove the direct writes to keep behavior
consistent with ContentView's existing pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ed888be-8a3e-4afc-89c2-5c7db09512fb

📥 Commits

Reviewing files that changed from the base of the PR and between e976893 and 156d116.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/SessionIndexStore.swift
  • cmuxTests/SessionIndexViewTests.swift

Comment thread Sources/ContentView.swift

@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.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:1891">
P2: `compactMap` drops `nil` `selectedTabId` values, so tab deselection never triggers `syncFileExplorerDirectory()`. The nil-clearing branch at line 3108 (`setCurrentDirectoryIfChanged(nil)`) becomes unreachable through this path, leaving `sessionIndexStore.currentDirectory` stale when no tab is selected. Use `.map` instead of `.compactMap` and handle the `nil` workspace case by emitting a sentinel value (e.g., `Just((nil, nil))`) so `switchToLatest` delivers a deselection event.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/ContentView.swift Outdated

Copy link
Copy Markdown
Contributor

Addressed the remaining Greptile note in c346693 by adding entries: [] to the IndexSection initializer in SessionIndexViewTests.makeHarness. I did not run local tests per repo policy.

@austinywang
austinywang merged commit 3af1173 into main Apr 20, 2026
17 checks passed
rodchristiansen pushed a commit to rodchristiansen/cmux that referenced this pull request Sep 2, 2026
…-ai#3020) (manaflow-ai#3028)

* Add regression test for session directory publishing

* Fix ContentView directory sync feedback loop

* Address directory observer review comments

* Fix SessionIndexView test harness section initializer

This branch was successfully deployed

1 active deployment
Preview — c3466936 Deployed Apr 20, 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.

Nightly: 100% CPU issue not fixed by #3013, cause found

2 participants