Skip to content

Fix stale surface-to-panel rebinding - #6581

Merged
austinywang merged 13 commits into
mainfrom
issue-6536-tab-session-crosswire
Jun 22, 2026
Merged

austinywang merged 13 commits into
mainfrom
issue-6536-tab-session-crosswire

Conversation

@austinywang

@austinywang austinywang commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #6536.

Root cause by code analysis: the workspace pane model allowed the same cmux panel/PTY id to be reachable from more than one Bonsplit tab id. Forward lookup was keyed by surface id, while reverse lookup scanned a dictionary that could contain stale duplicate panel mappings. If lifecycle churn rebound a live panel to a new surface while an old surface mapping survived, a tab could resolve to another tab's live terminal panel until restart rebuilt clean state.

This PR makes the invalid state unrepresentable:

  • PaneTreeModel.surfaceIdToPanelId is now read-only outside the model.
  • All writes go through bindSurface, removeSurfaceMapping, and removeSurfaceMappings.
  • A private reverse index keeps panel-to-surface lookup coherent and O(1).
  • Workspace panel creation, split, attach, respawn, custom sidebar, and lifecycle cleanup all use the shared mutation path.
  • Panel cleanup removes by panel id through the model, so stale close callbacks cannot drop a rebound live surface mapping.

Testing

  • Added the failing regression test first in commit 9d602f494 for stale surface rebinding.
  • Added follow-up regression coverage for both rebind directions and rebound-surface cleanup.
  • Not run locally per task instruction: no local tests, no local build, no bare xcodebuild.
  • No interactive repro video: the issue has no reliable trigger, so this was handled by code analysis plus regression coverage.
  • CI is the verification gate for package/app tests.

Localization

No user-facing strings changed. Localization audit: changed Swift production code only for internal bookkeeping APIs and comments, plus tests; no new UI text, settings text, alerts, menus, schema text, docs copy, or web messages were introduced by this PR.

@vercel

vercel Bot commented Jun 22, 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 Jun 22, 2026 10:48am
cmux-staging Building Building Preview, Comment Jun 22, 2026 10:48am

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

PaneTreeModel.surfaceIdToPanelId becomes read-restricted (public private(set)) with an exclusivity invariant, and a new reverse index (panelIdToSurfaceId) is added. bindSurface(_:toPanelId:) now removes stale entries for the target panel before installing the new mapping, ensuring each panel maps to at most one active surface. Workspace introduces three helper methods (bindSurface, removeSurfaceMapping, removeSurfaceMappings) that delegate to paneTree. All panel creation, split, attachment, and lifecycle cleanup code throughout the codebase is systematically updated to use these helpers instead of directly mutating surfaceIdToPanelId.

Changes

Surface-to-panel mapping API refactor

Layer / File(s) Summary
PaneTreeModel: read-restricted mapping and stale-removal binding
Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift
surfaceIdToPanelId becomes public private(set) with an exclusivity invariant documented. New private reverse index panelIdToSurfaceId supports direct panel→surface lookups. bindSurface(_:toPanelId:) removes stale entries for the target panelId before installing the new mapping, enforcing that each panel maps to exactly one active surface. Removal helpers maintain both indices in sync. surfaceId(forPanelId:) changes from a dictionary scan to a direct reverse-index lookup.
PaneTreeModel tests: validate binding API
Packages/macOS/CmuxPanes/Tests/CmuxPanesTests/PaneTreeModelTests.swift
Tests now use bindSurface API instead of direct dictionary mutation. New regression tests rebindingPanelToNewSurfaceInvalidatesOldSurfaceMapping and rebindingSurfaceToNewPanelInvalidatesOldPanelMapping verify that rebinding removes stale mappings in both directions while preserving the new association. closedPanelCleanupRemovesClosedSurfaceMapping and closedPanelCleanupKeepsReboundSurfaceMapping validate cleanup behavior during and after rebinding.
Workspace: surface mapping helper methods
Sources/Workspace.swift
surfaceIdToPanelId becomes read-only, forwarding to paneTree. Three new methods encapsulate mapping management: bindSurface(_:toPanelId:) registers a surface-to-panel association, removeSurfaceMapping(forSurfaceId:) removes a single surface entry, and removeSurfaceMappings(forPanelId:) removes all surfaces for a panel.
Workspace initialization
Sources/Workspace.swift
Initial browser and terminal panel creation now call bindSurface instead of directly assigning to surfaceIdToPanelId.
Terminal panel operations
Sources/Workspace.swift
Terminal split pre-creation, new terminal surface creation, remote-tmux display addition, terminal respawn, splitPaneWithNewTerminal, split-tab-bar placeholder repair, and split-tab-bar auto-create all register mappings via bindSurface and clean up via removeSurfaceMapping on split-pane creation failure.
Browser panel operations
Sources/Workspace.swift
Browser split, surface creation, and extension browser tab use bindSurface for registration and removeSurfaceMapping for split-pane creation failure cleanup.
Markdown panel operations
Sources/Workspace.swift
Markdown split, surface creation, and splitPaneWithMarkdown use bindSurface for registration and removeSurfaceMapping for split-pane creation failure cleanup.
Other panel types: project, file-preview, sidebar, agent-session
Sources/Workspace.swift
Project, file-preview, right-sidebar, and agent-session surface creation, plus file-preview split, all use bindSurface and removeSurfaceMapping.
Custom sidebar pane
Sources/Workspace+CustomSidebarPane.swift
newCustomSidebarSurface and splitPaneWithCustomSidebar register mappings via bindSurface and use removeSurfaceMapping for split-pane creation failure cleanup.
Panel lifecycle cleanup and surface attachment
Sources/Workspace+PanelLifecycle.swift, Sources/Workspace.swift
discardClosedPanelLifecycleState delegates mapping removal to removeSurfaceMappings(forPanelId:). Detached-surface attachment and replacement-terminal-panel creation use bindSurface.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • manaflow-ai/cmux#6416: Both PRs target the same surfaceId↔panelId resolution path—this PR centralizes bindSurface/removeSurfaceMapping(s) for maintaining dual-direction mapping bookkeeping in PaneTreeModel and Workspace, while the retrieved PR updates TabManager to normalize notification targets from bonsplit surface IDs to panel IDs using that mapping.

Poem

🐇 One surface per panel, that's the way!
No duplicate mappings shall stay.
Bind it, remove it, keep it clean,
The clearest API you've ever seen.
With helpers guarding each mutation's door,
Cross-wiring troubles are no more! ✨

🚥 Pre-merge checks | ✅ 22 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (22 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix stale surface-to-panel rebinding' clearly and specifically describes the main fix being implemented in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed All production Swift changes maintain proper Swift 6 actor isolation: PaneTreeModel and Workspace both marked @MainActor; surfaceIdToPanelId made private(set); new helper methods (bindSurface, remo...
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking synchronization (semaphores, locks, sleeps, waits) in production code. Changes consist of dictionary operations, helper method delegation, and test scaffolding only.
Cmux Expensive Synchronous Load ✅ Passed PR adds only dictionary mutations (bindSurface, removeSurfaceMapping, removeSurfaceMappings) with O(1) complexity. No expensive synchronous loads (RestorableAgentSessionIndex.load, JSON parsing, Fi...
Cmux Cache Substitution Correctness ✅ Passed The PR does not introduce a cache substitution issue. The private panelIdToSurfaceId reverse index is always synchronously updated alongside surfaceIdToPanelId, never persisted, and not used in per...
Cmux No Hacky Sleeps ✅ Passed Check is not applicable: all modified files are Swift (.swift), and the rule explicitly covers only TypeScript, JavaScript, shell, and non-Swift build/runtime scripts. Swift is explicitly out of sc...
Cmux Algorithmic Complexity ✅ Passed All operations in the PR use O(1) dictionary lookups/removals. The collections (surfaceIdToPanelId and panelIdToSurfaceId) scale with surfaces per workspace (~10-100), not with 1000+ workspaces. Th...
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns. New methods (bindSurface, removeSurfaceMapping, removeSurfaceMappings) are synchronous dictionary operations. Uses @MainActor and @Observable, avoiding legac...
Cmux Swift @Concurrent ✅ Passed All new/modified Swift methods in the PR are synchronous dictionary operations in @MainActor classes. No async functions, @concurrent annotations, or nonisolated async work introduced. No rule viol...
Cmux Swift File And Package Boundaries ✅ Passed PR satisfies all swift-file-package-boundaries rules: adds only 47 lines to PaneTreeModel (160 total, coherent state-ownership responsibility, in SwiftPM package), adds only 44 lines to oversized W...
Cmux Swiftpm Lockfiles ✅ Passed PR contains only Swift source code changes (model, tests, workspace logic) with no SwiftPM package, Xcode project, .gitignore, or dependency changes, so the lockfile rule is not applicable.
Cmux Swift Logging ✅ Passed No logging violations found. Changes add only mapping helpers and reverse index—no print/debugPrint/dump/NSLog statements, no ad hoc logging, and no Logger declarations added in production code.
Cmux User-Facing Error Privacy ✅ Passed PR contains no user-facing errors, alerts, or strings violating privacy rules. All changes are internal bookkeeping for panel-to-surface mapping with only developer-facing documentation comments.
Cmux Full Internationalization ✅ Passed PR contains only internal refactoring for surface-panel mapping bookkeeping with no new user-facing text, localization keys, string catalog changes, or web UI modifications.
Cmux Swiftui State Layout ✅ Passed PR does not introduce new ObservableObject, @Published, or SwiftUI state violations. Changes are to data model/logic files only (PaneTreeModel, Workspace extensions, tests). PaneTreeModel uses @Obs...
Cmux Architecture Rethink ✅ Passed This PR is a small local correctness fix enforcing the clear invariant "each panel binds to at most one surface" via a reverse index and targeted cleanup logic, with no timing patches, duplicate st...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR makes no changes to window creation or management; it only refactors internal panel-to-surface ID mapping logic. No new or materially changed NSWindow, NSPanel, NSWindowController, or SwiftUI Wi...
Cmux Source Artifacts ✅ Passed PR changes are all intentional source files: hand-written Swift source code fixes, tests, docs, and symlinks to knowledge files. No build artifacts, caches, or generated files detected.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PR adds production helper methods (bindSurface, removeSurfaceMapping, removeSurfaceMappings) with multiple production callers; no test-naming patterns, no new #if DEBUG test seams, and access is na...
Description check ✅ Passed The PR description provides a clear summary of the root cause, the fix implemented, testing approach, and localization review. However, it lacks specific details in the Testing section about what was verified and how.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6536-tab-session-crosswire

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 Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale surface-to-panel rebinding (#6536) by making the invalid state unrepresentable: PaneTreeModel.surfaceIdToPanelId is now read-only outside the model, and all mutations go through three new exclusive-binding methods that keep a private O(1) reverse index coherent.

  • bindSurface(_:toPanelId:) enforces the one-panel-one-surface invariant at write time, removing both the stale forward entry for a rebound panel and the stale reverse entry for a rebound surface before installing the new mapping.
  • removeSurfaceMappings(forPanelId:) uses the reverse index so a stale close callback can never drop a rebound live surface mapping.
  • All 14+ write sites in Workspace.swift, Workspace+PanelLifecycle.swift, and Workspace+CustomSidebarPane.swift are migrated to the new API; four regression tests cover both rebind directions and rebound-surface cleanup.

Confidence Score: 5/5

The change is safe to merge — it is a targeted refactor of a single data structure that was the confirmed root cause of a focus/input routing bug.

All write paths to the surface-to-panel map have been migrated to the new exclusive-binding API. The bindSurface logic correctly handles all rebind combinations (panel moves to new surface, surface moves to new panel, cross-rebind), and removeSurfaceMappings correctly uses the reverse index so a stale close callback cannot drop a live mapping. The four regression tests exercise all the cases called out in the PR description. No actor isolation, blocking runtime, test-only seam, or algorithmic complexity concerns were found.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift Adds exclusive-binding invariant via private reverse index and new mutating API; logic in bindSurface is correct for all rebind combinations, and both remove helpers maintain index coherence
Packages/macOS/CmuxPanes/Tests/CmuxPanesTests/PaneTreeModelTests.swift Adds four regression tests covering rebind-panel-to-new-surface, rebind-surface-to-new-panel, closed-panel cleanup, and rebound-surface cleanup; test callers updated from direct subscript to bindSurface API
Sources/Workspace+PanelLifecycle.swift Close-cleanup path replaced with removeSurfaceMappings(forPanelId:), fixing the previously-flagged stale close-callback issue where a rebound live surface could be dropped
Sources/Workspace+CustomSidebarPane.swift Custom sidebar panel creation and rollback paths migrated from direct dictionary subscript writes to bindSurface/removeSurfaceMapping; no logic changes
Sources/Workspace.swift surfaceIdToPanelId getter is now read-only; all 14+ bind and remove sites across panel creation, split, attach, and respawn paths migrated to the new model API; forwarding wrappers kept for extension-scoped callers
.github/swift-file-length-budget.tsv Budget updated to reflect Workspace.swift growth (+14 lines) from this PR; also captures unrelated size reductions already in the branch

Reviews (6): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift Outdated
Comment thread Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift Outdated
Comment thread Sources/Workspace+PanelLifecycle.swift Outdated

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift (1)

68-73: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Avoid full-map rebuild on each surface bind.

Line 69 currently rescans and rebuilds the entire mapping for every bind. In production batch/rebind paths, this creates avoidable O(n²) behavior and extra allocations. Prefer maintaining a reverse index (panelId -> surfaceId) so rebinding can remove the prior surface in O(1) and update both maps directly.

As per coding guidelines, “for production code over scalable user data, flag nested full-collection scans [and] per-target rescans for batch actions.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift` around
lines 68 - 73, The bindSurface method currently filters and rebuilds the entire
surfaceIdToPanelId dictionary on every call, creating O(n²) behavior during
batch operations. To fix this, introduce a reverse mapping data structure that
maintains panelId to surfaceId associations. In the bindSurface method, instead
of using the filter operation to rebuild the entire map, check if the panelId
already has an existing surfaceId mapped to it using the reverse index, remove
that old mapping from surfaceIdToPanelId, and then directly insert the new
surfaceId to panelId binding. Update both the forward and reverse maps
simultaneously to keep them in sync, enabling O(1) rebinding operations without
full dictionary rescans.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/macOS/CmuxPanes/Tests/CmuxPanesTests/PaneTreeModelTests.swift`:
- Around line 121-138: The test closedPanelCleanupKeepsReboundSurfaceMapping
currently uses two different surface IDs (closedPanelTabId and reboundTabId)
which does not accurately test the bug scenario of a surface being rebound
before cleanup runs. Refactor the test to use a single surface ID that is first
bound to closedPanelId, then rebound to livePanelId before calling
removeSurfaceMappings for the closed panel. After the cleanup, assert that the
surface mapping still points to livePanelId and the stale alias to closedPanelId
has been removed, directly covering the actual stale-rebind scenario described
in the bug.

---

Outside diff comments:
In `@Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift`:
- Around line 68-73: The bindSurface method currently filters and rebuilds the
entire surfaceIdToPanelId dictionary on every call, creating O(n²) behavior
during batch operations. To fix this, introduce a reverse mapping data structure
that maintains panelId to surfaceId associations. In the bindSurface method,
instead of using the filter operation to rebuild the entire map, check if the
panelId already has an existing surfaceId mapped to it using the reverse index,
remove that old mapping from surfaceIdToPanelId, and then directly insert the
new surfaceId to panelId binding. Update both the forward and reverse maps
simultaneously to keep them in sync, enabling O(1) rebinding operations without
full dictionary rescans.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 83e398be-2d43-4616-948e-9c544f1398e8

📥 Commits

Reviewing files that changed from the base of the PR and between 1f37a84 and 82307cc.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (4)
  • Packages/macOS/CmuxPanes/Sources/CmuxPanes/Model/PaneTreeModel.swift
  • Packages/macOS/CmuxPanes/Tests/CmuxPanesTests/PaneTreeModelTests.swift
  • Sources/Workspace+PanelLifecycle.swift
  • Sources/Workspace.swift

@austinywang
austinywang merged commit f9b66da into main Jun 22, 2026
33 of 35 checks passed
@austinywang
austinywang deleted the issue-6536-tab-session-crosswire branch June 22, 2026 10:12
azooz2003-bit added a commit that referenced this pull request Jul 3, 2026
…ace-map mutations through paneTree.bindSurface/removeSurfaceMapping, get-only bridge; thread allowTextBoxFocusDefault to newTerminalSurfaceLocal; add DetachedSurfaceTransfer.directoryDisplayLabel (round 50-fix)

This branch was successfully deployed

1 active deployment
Preview – cmux — deae39ad Deployed Jun 22, 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.

Active tab's shell gets cross-wired to a different workspace's session at runtime (prompt swaps; restart fixes it)

1 participant