Skip to content

Fix #1983: avoid workspace creation crash after restore#1985

Merged
lawrencecchen merged 2 commits intomainfrom
issue-1983-newTabInsertIndex-crash
Mar 25, 2026
Merged

Fix #1983: avoid workspace creation crash after restore#1985
lawrencecchen merged 2 commits intomainfrom
issue-1983-newTabInsertIndex-crash

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Mar 23, 2026

Closes #1983.

Summary

  • hop port-scanner workspace updates onto the main actor instead of assuming a background callback is already main-isolated
  • tear down bootstrap workspaces that session restore replaces so stale panels, TTY registrations, and callbacks cannot outlive the restore swap
  • prune restore-adjacent workspace state after the new workspace list is installed

Verification

  • ./scripts/reload.sh --tag issue-1983
  • confirmed the tagged app stayed alive past the original ~7 second crash window
  • cmux-dev new-workspace
  • cmux-dev new-workspace

Summary by cubic

Fixes #1983 by preventing the post-restore crash when creating a new workspace. Port updates now run on the main actor, and replaced workspaces are fully torn down so stale callbacks can’t touch them.

  • Bug Fixes
    • Port scanning: mark PortScanner.onPortsUpdated as @MainActor and deliver results via a main-actor Task; simplify the TerminalController handler.
    • Session restore: atomically swap in restored tabs, then release replaced workspaces (clear notifications, tear down panels and remote connections, drop ownership), prune background loads, and keep sidebar selection to existing IDs.

Written for commit 61e25a5. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Improved session restoration to better clean up and replace restored workspaces and tabs
    • Changed port update delivery to ensure callbacks run on the main UI execution context
    • Simplified terminal/port handling callbacks to reduce unsafe main-thread assumptions and improve stability

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 23, 2026

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 25, 2026 3:16am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

Updated PortScanner callback to be @MainActor-delivered and switched delivery to Task { @MainActor in ... }. Added TabManager helper to fully release workspaces replaced during session restore and pruned state (pending loads, sidebar selections) when swapping tabs.

Changes

Cohort / File(s) Summary
Port callback delivery
Sources/PortScanner.swift, Sources/TerminalController.swift
Made onPortsUpdated an @MainActor closure; replaced DispatchQueue.main.async with Task { @mainactor in ... } in delivery; removed MainActor.assumeIsolated use in the TerminalController callback.
Session-restore workspace cleanup
Sources/TabManager.swift
Added private releaseRestoredAwayWorkspace(_:) to clear notifications, tear down panels, disconnect remote connections, and nil out owningTabManager. Updated restoreSessionSnapshot(_:) to snapshot previous tabs, assign new tabs, prune pending/selected workspace IDs, and release old workspaces before wiring new ones.

Sequence Diagram(s)

sequenceDiagram
    participant TabMgr as TabManager
    participant OldWS as Workspace (old)
    participant NewWS as Workspace (new)
    participant Remote as RemoteConnection
    participant Port as PortScanner

    rect rgba(255,127,0,0.5)
    Note over TabMgr: restoreSessionSnapshot() begins
    TabMgr->>TabMgr: capture previousTabs
    TabMgr->>TabMgr: assign new tabs & selectedTabId
    TabMgr->>TabMgr: prune pendingBackgroundWorkspaceLoadIds / sidebarSelectedWorkspaceIds
    end

    rect rgba(255,99,71,0.5)
    Note over TabMgr,OldWS: releaseRestoredAwayWorkspace() for each old workspace
    TabMgr->>OldWS: clear notifications
    TabMgr->>OldWS: tear down panels
    TabMgr->>Remote: disconnect remote connection
    TabMgr->>OldWS: set owningTabManager = nil
    end

    rect rgba(70,130,180,0.5)
    Note over Port,NewWS: port update delivery on main actor
    Port->>Port: Task { `@MainActor` in onPortsUpdated(...) }
    Port->>NewWS: update surfaceListeningPorts
    Port->>NewWS: recomputeListeningPorts()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hop and tidy tabs with zest,
Old workspaces find their rest,
Callbacks race to the main-actor rug,
No more ghosts to give a shrug,
Hooray — everything's snug! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the issue being fixed and the primary change (avoiding workspace creation crash after restore), aligning with the main objective of the PR.
Description check ✅ Passed The description covers the key changes and verification steps, though it omits the 'Testing' section checklist details and demo video URL that are in the template.
Linked Issues check ✅ Passed All coding objectives from issue #1983 are addressed: main-actor isolation for PortScanner updates, workspace teardown during session restore, and pruning stale state.
Out of Scope Changes check ✅ Passed All changes in PortScanner.swift, TabManager.swift, and TerminalController.swift are directly scoped to fixing issue #1983's root causes with no extraneous modifications.

✏️ 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-1983-newTabInsertIndex-crash

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
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 23, 2026

Greptile Summary

This PR fixes the post-restore new-workspace crash (#1983) via two targeted changes: promoting PortScanner's result delivery from DispatchQueue.main.async + runtime assumeIsolated to a proper @MainActor-typed callback dispatched with Task { @MainActor in }, and ensuring the bootstrap workspaces that session-restore replaces are fully torn down (panels, TTY registrations, remote connections, notification store) rather than silently abandoned.

  • PortScanner.swift: onPortsUpdated is now typed (@MainActor ...), giving a compile-time main-actor guarantee; deliverResults uses Task { @MainActor in }.
  • TerminalController.swift: MainActor.assumeIsolated wrapper removed — no longer needed since the closure type enforces isolation.
  • TabManager.swift: restoreSessionSnapshot captures previousTabs before the loop, performs the atomic tabs/selectedTabId swap, then calls the new releaseRestoredAwayWorkspace helper on every replaced workspace; also prunes pendingBackgroundWorkspaceLoadIds, debugPinnedWorkspaceLoadIds, and sidebarSelectedWorkspaceIds to the current ID set.
  • The teardown in releaseRestoredAwayWorkspace correctly mirrors the existing close-workspace path (clearNotificationsteardownAllPanels which includes PortScanner.unregisterPanelteardownRemoteConnection → nil owning pointer); unwireClosedBrowserTracking is already handled at the top of restoreSessionSnapshot before the swap.

Confidence Score: 5/5

  • This PR is safe to merge; all three changes are tightly scoped, well-motivated, and the new teardown path correctly mirrors the existing close-workspace flow.
  • The @MainActor promotion is a strict upgrade (compile-time vs. runtime assertion). The releaseRestoredAwayWorkspace teardown reuses battle-tested helpers (teardownAllPanels, teardownRemoteConnection) and the sequencing — swap first, tear down second — prevents any use-after-free window. No new concurrency primitives or data structures are introduced, and the fix was validated against the original crash window.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/PortScanner.swift Upgrades onPortsUpdated callback from a plain closure with a comment to a @MainActor-annotated type, and replaces DispatchQueue.main.async with Task { @MainActor in } — giving a compile-time guarantee of main-actor delivery instead of a runtime assumeIsolated assertion.
Sources/TerminalController.swift Removes the MainActor.assumeIsolated wrapper now that the callback type itself is @MainActor; the body is identical, now properly enforced at compile time rather than asserted at runtime.
Sources/TabManager.swift Adds releaseRestoredAwayWorkspace which mirrors the normal close-workspace teardown (notifications, panels + PortScanner unregistration, remote connection, owning pointer) and calls it for all replaced bootstrap workspaces after the atomic tabs swap; also prunes pendingBackgroundWorkspaceLoadIds, debugPinnedWorkspaceLoadIds, and sidebarSelectedWorkspaceIds to the new ID set.

Sequence Diagram

sequenceDiagram
    participant PS as PortScanner (bg queue)
    participant MA as Main Actor
    participant TC as TerminalController
    participant TM as TabManager
    participant WS as Workspace

    Note over PS: Burst scan completes
    PS->>PS: deliverResults()
    PS->>MA: Task { @MainActor in callback(...) }
    MA->>TC: onPortsUpdated(workspaceId, panelId, ports)
    TC->>TM: tabs.first(where: id == workspaceId)
    alt workspace found & panel valid
        TC->>WS: surfaceListeningPorts[panelId] = ports
        TC->>WS: recomputeListeningPorts()
    else workspace not found (released after restore)
        TC-->>TC: guard returns early
    end

    Note over TM: Session restore begins
    TM->>TM: previousTabs = tabs
    TM->>TM: unwireClosedBrowserTracking (previousTabs)
    TM->>TM: build newTabs from snapshot
    TM->>TM: tabs = newTabs (atomic swap)
    TM->>TM: pruneBackgroundWorkspaceLoads
    TM->>TM: sidebarSelectedWorkspaceIds.formIntersection
    loop each old workspace
        TM->>WS: clearNotifications(forTabId)
        TM->>WS: teardownAllPanels() → PortScanner.unregisterPanel(...)
        TM->>WS: teardownRemoteConnection()
        TM->>WS: owningTabManager = nil
    end
Loading

Reviews (1): Last reviewed commit: "Fix workspace creation crash after resto..." | Re-trigger Greptile

Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

No issues found across 3 files

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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)
Sources/PortScanner.swift (1)

174-178: ⚠️ Potential issue | 🟡 Minor

Preserve burst result ordering in high-frequency port updates.

The burst scanner (runBurst) calls deliverResults() multiple times in rapid succession, each wrapping results in separate Task { @mainactor in ... } submissions. Swift does not guarantee FIFO ordering for independent actor task submissions—later scans can complete before earlier ones, causing stale port state to temporarily overwrite fresh results.

Add sequence numbers to gate deliveries:

Ordering pattern to preserve scan sequence
+    private var nextDeliverySequence: UInt64 = 0
+    `@MainActor` private var lastAppliedDeliverySequence: UInt64 = 0
+
     private func deliverResults(_ results: [(PanelKey, [Int])]) {
         guard let callback = onPortsUpdated else { return }
+        let sequence = nextDeliverySequence
+        nextDeliverySequence &+= 1
         Task { `@MainActor` in
+            guard sequence >= lastAppliedDeliverySequence else { return }
+            lastAppliedDeliverySequence = sequence
             for (key, ports) in results {
                 callback(key.workspaceId, key.panelId, ports)
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/PortScanner.swift` around lines 174 - 178, The Burst scanner's
deliverResults submissions can be reordered because each Task { `@MainActor` in
... } is independent; modify deliverResults() (called by runBurst) to attach an
incrementing sequence number (e.g., scanSeq) to each result batch, stash the
latest delivered sequence on the MainActor (or a dedicated `@MainActor` property)
and have the MainActor task compare sequence numbers before invoking
callback(workspaceId:panelId:ports) so only newer-or-equal sequences apply;
update runBurst/deliverResults to increment and pass the sequence and update the
stored last-applied sequence when a task actually runs to prevent stale
deliveries from overwriting newer state.
🤖 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/TerminalController.swift`:
- Around line 1022-1024: The closure assigned to
PortScanner.shared.onPortsUpdated currently resolves the workspace via
self.tabManager (active-window only) which drops updates for workspaces in other
windows; change the resolution to use AppDelegate.shared?.workspaceFor(tabId:
workspaceId) (as used by sendPickedElementToTerminal(workspaceId:summary:)) so
the lookup searches across all mainWindowContexts, and keep the existing weak
self capture and early-return behavior if workspace lookup fails; update any
references to tabManager.tabs.first(where:) to use the AppDelegate lookup to
ensure surfaceListeningPorts/listeningPorts are updated for workspaces in other
windows.

---

Outside diff comments:
In `@Sources/PortScanner.swift`:
- Around line 174-178: The Burst scanner's deliverResults submissions can be
reordered because each Task { `@MainActor` in ... } is independent; modify
deliverResults() (called by runBurst) to attach an incrementing sequence number
(e.g., scanSeq) to each result batch, stash the latest delivered sequence on the
MainActor (or a dedicated `@MainActor` property) and have the MainActor task
compare sequence numbers before invoking callback(workspaceId:panelId:ports) so
only newer-or-equal sequences apply; update runBurst/deliverResults to increment
and pass the sequence and update the stored last-applied sequence when a task
actually runs to prevent stale deliveries from overwriting newer state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bc20f0e0-9e8f-4565-95b4-9c5bf4042426

📥 Commits

Reviewing files that changed from the base of the PR and between 88751b2 and b431b66.

📒 Files selected for processing (3)
  • Sources/PortScanner.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift

Comment on lines 1022 to +1024
PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in
MainActor.assumeIsolated {
guard let self, let tabManager = self.tabManager else { return }
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return }
let validSurfaceIds = Set(workspace.panels.keys)
guard validSurfaceIds.contains(panelId) else { return }
workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports
workspace.recomputeListeningPorts()
}
guard let self, let tabManager = self.tabManager else { return }
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resolve port-scan updates by workspaceId, not the active-window tabManager.

Line 1023/Line 1024 still route through self.tabManager, which only tracks the active window. Port updates for workspaces in other windows will be dropped here, so their surfaceListeningPorts/listeningPorts can silently go stale.

Suggested fix
-        PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in
-            guard let self, let tabManager = self.tabManager else { return }
-            guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return }
+        PortScanner.shared.onPortsUpdated = { workspaceId, panelId, ports in
+            guard let workspace = AppDelegate.shared?.workspaceFor(tabId: workspaceId) else { return }
             let validSurfaceIds = Set(workspace.panels.keys)
             guard validSurfaceIds.contains(panelId) else { return }
             workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports
             workspace.recomputeListeningPorts()
         }

Based on learnings: sendPickedElementToTerminal(workspaceId:summary:) resolves the target workspace via AppDelegate.shared?.workspaceFor(tabId: workspaceId), which searches across all mainWindowContexts (not self.tabManager, which is active-window only).

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

In `@Sources/TerminalController.swift` around lines 1022 - 1024, The closure
assigned to PortScanner.shared.onPortsUpdated currently resolves the workspace
via self.tabManager (active-window only) which drops updates for workspaces in
other windows; change the resolution to use
AppDelegate.shared?.workspaceFor(tabId: workspaceId) (as used by
sendPickedElementToTerminal(workspaceId:summary:)) so the lookup searches across
all mainWindowContexts, and keep the existing weak self capture and early-return
behavior if workspace lookup fails; update any references to
tabManager.tabs.first(where:) to use the AppDelegate lookup to ensure
surfaceListeningPorts/listeningPorts are updated for workspaces in other
windows.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@Sources/TabManager.swift`:
- Around line 5166-5173: In releaseRestoredAwayWorkspace, clear the workspace's
owningTabManager before tearing down panels or the remote connection to prevent
callbacks (e.g., Workspace.closeWorkspaceWithConfirmation called during
teardownAllPanels) from re-entering TabManager against the restored tab list;
specifically, set workspace.owningTabManager = nil prior to calling
workspace.teardownAllPanels() and workspace.teardownRemoteConnection() in the
releaseRestoredAwayWorkspace helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e82b608d-d202-4cc6-9539-b89191708424

📥 Commits

Reviewing files that changed from the base of the PR and between b431b66 and 61e25a5.

📒 Files selected for processing (1)
  • Sources/TabManager.swift

Comment on lines +5166 to +5173
private func releaseRestoredAwayWorkspace(_ workspace: Workspace) {
// Session restore replaces the bootstrap workspace objects with freshly
// restored ones. Tear the old graph down after the atomic swap so late
// panel/socket callbacks cannot keep mutating hidden pre-restore state.
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownAllPanels()
workspace.teardownRemoteConnection()
workspace.owningTabManager = nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Detach the old workspace before tearing its panels down.

Unlike closeWorkspace(_:), this helper runs after the old workspace has already been removed from tabs. Workspace can still call back into owningTabManager?.closeWorkspaceWithConfirmation(self) during panel teardown (see Sources/Workspace.swift, Lines 9747-9750), so leaving owningTabManager set through teardownAllPanels() lets restore cleanup re-enter TabManager against the restored tab list. That can prompt or close the wrong workspace/window. Nil the manager before teardown.

🛠️ Proposed fix
 private func releaseRestoredAwayWorkspace(_ workspace: Workspace) {
     // Session restore replaces the bootstrap workspace objects with freshly
     // restored ones. Tear the old graph down after the atomic swap so late
     // panel/socket callbacks cannot keep mutating hidden pre-restore state.
     AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
+    workspace.owningTabManager = nil
     workspace.teardownAllPanels()
     workspace.teardownRemoteConnection()
-    workspace.owningTabManager = nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5166 - 5173, In
releaseRestoredAwayWorkspace, clear the workspace's owningTabManager before
tearing down panels or the remote connection to prevent callbacks (e.g.,
Workspace.closeWorkspaceWithConfirmation called during teardownAllPanels) from
re-entering TabManager against the restored tab list; specifically, set
workspace.owningTabManager = nil prior to calling workspace.teardownAllPanels()
and workspace.teardownRemoteConnection() in the releaseRestoredAwayWorkspace
helper.

@lawrencecchen lawrencecchen merged commit 3952c25 into main Mar 25, 2026
14 checks passed
@lawrencecchen lawrencecchen deleted the issue-1983-newTabInsertIndex-crash branch March 25, 2026 07:04
zzdif pushed a commit to zzdif/cmux that referenced this pull request Mar 26, 2026
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.

Crash when opening a new file

2 participants