Skip to content

Fix Cmd+N crash: retain snapshot workspaces through creation - #2183

Merged
austinywang merged 3 commits into
mainfrom
issue-2180-cmd-n-retain-crash
Mar 26, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-2180-cmd-n-retain-crash

Conversation

@austinywang

@austinywang austinywang commented Mar 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Wraps the workspace creation path in withExtendedLifetime to keep the pre-creation tabs array alive for the full Cmd+N flow
  • Fixes a use-after-free crash (swift_retain) where Release ARC optimizations could drop intermediate retains on workspaces before re-reading tabs for insertion
  • Passes captured tabs/selectedTabId into workspaceCreationSnapshot() instead of re-reading @Published properties

Test plan

  • Regression test testAddWorkspaceKeepsCapturedWorkspaceAliveUntilCreationFinishes verifies the workspace stays alive through creation
  • Verified locally with universal Release build — Cmd+N works correctly
  • CI unit tests pass

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced workspace creation reliability by ensuring consistent state capture and proper object lifecycle management during the workspace setup process.
  • Tests

    • Added test coverage for workspace lifecycle behavior during creation to prevent regressions.

@vercel

vercel Bot commented Mar 26, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 26, 2026 9:12pm

@coderabbitai

coderabbitai Bot commented Mar 26, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR modifies the workspace creation flow in TabManager to capture workspace state (tabs and selectedTabId) into local constants and use withExtendedLifetime to ensure the pre-creation snapshot array remains retained throughout the entire creation process. A new workspaceCreationSnapshot overload is introduced that accepts captured values rather than reading directly from instance properties, and a regression test validates this behavior.

Changes

Cohort / File(s) Summary
Workspace Creation State Capture
Sources/TabManager.swift
Refactored addWorkspace(...) to capture tabs and selectedTabId into local constants, wrapped entire workspace-creation flow in withExtendedLifetime(capturedTabs) to retain pre-creation state consistently. Added new private workspaceCreationSnapshot(currentTabs:currentSelectedTabId:) overload and reintroduced parameterless wrapper for backward compatibility.
Regression Test
cmuxTests/WorkspaceUnitTests.swift
Added testAddWorkspaceKeepsCapturedWorkspaceAliveUntilCreationFinishes to validate that a captured workspace remains alive during the creation process even if the captured workspace is closed before insertion completes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • PR #2181: Makes identical code-level changes to TabManager.addWorkspace (state capture via withExtendedLifetime, snapshot overload addition) and adds the same regression test.
  • PR #2099: Similarly modifies TabManager.addWorkspace workspace-creation flow to compute insertion placement from a pre-creation snapshot and avoid reading mutable state mid-creation.
  • PR #2023: Addresses comparable workspace-creation/snapshot handling to prevent dereferencing potentially-deallocated Workspace instances by extracting and using captured snapshot values.

Poem

🐰 A rabbit hops through captured time,
Where lifetimes stretch and states align,
With snapshots held in gentle care,
The workspace blooms beyond repair,
No races won, just peace divine! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a Cmd+N crash by retaining workspaces through creation, which directly matches the PR's core intent and the file changes.
Description check ✅ Passed The description covers the main change and rationale well, includes a clear test plan with specific test names, and notes local verification. However, it deviates from the template by omitting optional sections (Demo Video, Review Trigger, Checklist) and using a different format, though non-critical sections are acceptable to omit.

✏️ 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-2180-cmd-n-retain-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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 2 files

@greptile-apps

greptile-apps Bot commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a Release-only use-after-free crash (swift_retain) that occurred during Cmd+N workspace creation. When Release ARC optimizations were active, the compiler could eagerly drop intermediate retains on the pre-creation tabs array before makeWorkspaceForCreation was called, crashing on any workspace closed between snapshot capture and insertion. The fix is well-targeted: it captures tabs/selectedTabId explicitly, passes them into workspaceCreationSnapshot() (avoiding repeated @Published reads), and wraps the entire creation body in withExtendedLifetime(capturedTabs) to pin the array — and therefore all Workspace references inside it — for the full Cmd+N path.\n\n- The withExtendedLifetime approach is the idiomatic Swift mechanism for this class of ARC lifetime bug and is applied correctly here.\n- The parameterized workspaceCreationSnapshot(currentTabs:currentSelectedTabId:) overload is a clean separation of concerns; the no-arg backward-compatible wrapper is preserved for newTabInsertIndex(placementOverride:) and any other callers.\n- The regression test uses SnapshotMutatingTabManager's hooks to inject a mid-flight close and assert via weak var that the workspace survives to makeWorkspaceForCreation. The test is logically correct but may not catch the bug in Debug builds due to less aggressive ARC optimization.\n- The commit history deviates from the two-commit regression test policy (CLAUDE.md): a third "repair test" commit follows the test+fix pair, meaning the test in its final form wasn't the one CI confirmed as failing on the pre-fix code. CI unit tests are also still marked unchecked in the PR description.

Confidence Score: 4/5

Safe to merge — the fix is correct and targeted; only non-blocking process and test-coverage observations remain.

The withExtendedLifetime fix is idiomatic and addresses the root cause of the Release-only crash. The code logic is sound: capturedTabs is correctly passed by value into withExtendedLifetime, pinning all enclosed Workspace references through the entire creation closure. The no-arg overload backward compatibility is preserved. The two remaining concerns are P2 only: the three-commit structure deviating from the CLAUDE.md regression test policy, and the test's inability to catch the issue under Debug ARC. Neither affects correctness or production reliability. Score kept at 4 rather than 5 because CI unit tests are still unchecked in the PR description.

cmuxTests/WorkspaceUnitTests.swift — verify the repaired test (commit 80857190) genuinely fails against the pre-fix addWorkspace in a Release test run.

Important Files Changed

Filename Overview
Sources/TabManager.swift Wraps the entire addWorkspace body in withExtendedLifetime(capturedTabs) to prevent Release ARC from dropping workspace retains mid-creation; refactors workspaceCreationSnapshot to accept explicit parameters while preserving a no-arg overload for other callers.
cmuxTests/WorkspaceUnitTests.swift Adds testAddWorkspaceKeepsCapturedWorkspaceAliveUntilCreationFinishes to verify the pre-creation workspace array is kept alive through makeWorkspaceForCreation; test hooks into afterCaptureWorkspaceCreationSnapshot to close a workspace mid-flight and asserts weak var is non-nil in beforeCreateWorkspace.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant addWorkspace
    participant withExtendedLifetime
    participant workspaceCreationSnapshot
    participant makeWorkspaceForCreation
    participant tabs

    Caller->>addWorkspace: addWorkspace(placementOverride:)
    addWorkspace->>tabs: capturedTabs = tabs
    addWorkspace->>tabs: capturedSelectedTabId = selectedTabId
    addWorkspace->>withExtendedLifetime: withExtendedLifetime(capturedTabs) { ... }
    Note over withExtendedLifetime: capturedTabs pinned alive
    withExtendedLifetime->>workspaceCreationSnapshot: snapshot(currentTabs: capturedTabs, currentSelectedTabId:)
    workspaceCreationSnapshot-->>withExtendedLifetime: WorkspaceCreationSnapshot
    Note over withExtendedLifetime: didCaptureWorkspaceCreationSnapshot()
    Note over withExtendedLifetime: mid-creation close may remove workspace from tabs here
    withExtendedLifetime->>makeWorkspaceForCreation: makeWorkspaceForCreation(...)
    makeWorkspaceForCreation-->>withExtendedLifetime: newWorkspace
    withExtendedLifetime->>tabs: updatedTabs = tabs (live array)
    withExtendedLifetime->>tabs: tabs = updatedTabs.inserting(newWorkspace, at: insertIndex)
    Note over withExtendedLifetime: capturedTabs released here (after closure ends)
    withExtendedLifetime-->>addWorkspace: newWorkspace
    addWorkspace-->>Caller: newWorkspace
Loading

Reviews (1): Last reviewed commit: "fix: repair workspace lifetime regressio..." | Re-trigger Greptile

XCTAssertEqual(manager.selectedTabId, inserted.id)
}

func testAddWorkspaceKeepsCapturedWorkspaceAliveUntilCreationFinishes() {

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 Three-commit structure deviates from regression test policy

CLAUDE.md requires a strict two-commit structure for regression tests:

  1. Commit 1 — failing test only (CI goes red)
  2. Commit 2 — fix (CI goes green)

This PR has three commits for this change:

  • 9cd142a4 test: reproduce Cmd+N snapshot workspace lifetime race
  • 09872b62 fix: retain snapshot workspaces through Cmd+N creation
  • 80857190 fix: repair workspace lifetime regression test

The third "repair" commit indicates the test needed correction after the fix was already added. This breaks the guarantee that CI history shows the test was provably red on the pre-fix code. The final test (as it exists in HEAD) should have been the test in commit 1 — CI would then clearly confirm it caught the bug before the fix.

Context Used: CLAUDE.md (source)

Comment on lines +480 to +486
var didReachBeforeCreateWorkspace = false
manager.beforeCreateWorkspace = {
didReachBeforeCreateWorkspace = true
XCTAssertNotNil(
weakClosingWorkspace,
"Expected the workspace captured before Cmd+N to stay alive until creation finishes"
)

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 weak var lifetime check may not fire in Debug unit test runs

The core assertion XCTAssertNotNil(weakClosingWorkspace, ...) is intended to catch a Release ARC optimization that eagerly drops capturedTabs before makeWorkspaceForCreation is reached. Because the optimizer is disabled in Debug builds (the default for xcodebuild -scheme cmux-unit), the old code without withExtendedLifetime would likely also keep the workspace alive here, so the test may pass even against the unfixed implementation.

This is a known limitation of ARC lifetime tests. Consider adding a comment documenting that this assertion exercises Release-only behavior, so future readers don't assume the test provides Debug-mode protection.

@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: 2

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

Inline comments:
In `@cmuxTests/WorkspaceUnitTests.swift`:
- Around line 462-470: The strong local capturedClosingWorkspace keeps the
workspace alive until the function scope ends, making the weak-deallocation
assertion non-deterministic; wrap the strong capture in a narrow scope (e.g. a
do { ... } block) so you only extract closingWorkspaceId and create
weakClosingWorkspace inside that block (use capturedClosingWorkspace.id to set
closingWorkspaceId and weak var weakClosingWorkspace = capturedClosingWorkspace
there), then exit the block, set closingWorkspace = nil, and assert
weakClosingWorkspace is nil — this removes the long-lived strong reference from
capturedClosingWorkspace and ensures deterministic deallocation while preserving
the existing assertions on manager.tabs, first.id, and third.id.

In `@Sources/TabManager.swift`:
- Line 1240: Replace the bare literal "Terminal \(nextTabCount)" with a
localized string using the repo pattern — e.g. use String(localized:
"terminal.title.default", defaultValue: "Terminal %d").formatted(nextTabCount)
(or equivalent String(format:) if you prefer) in the TabManager code where the
title is created (the call that currently uses nextTabCount), and add the key
"terminal.title.default" with value "Terminal %d" to
Resources/Localizable.xcstrings so the default workspace title is localized.
🪄 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: c1429e55-35d1-42e0-9d89-f2e78116a67c

📥 Commits

Reviewing files that changed from the base of the PR and between 61e6a0e and 8085719.

📒 Files selected for processing (2)
  • Sources/TabManager.swift
  • cmuxTests/WorkspaceUnitTests.swift

Comment on lines +462 to +470
guard let capturedClosingWorkspace = closingWorkspace else {
XCTFail("Expected secondary workspace")
return
}

let closingWorkspaceId = capturedClosingWorkspace.id
weak var weakClosingWorkspace = capturedClosingWorkspace
XCTAssertEqual(manager.tabs.map(\.id), [first.id, closingWorkspaceId, third.id])
closingWorkspace = 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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "WorkspaceUnitTests.swift" | head -5

Repository: manaflow-ai/cmux

Length of output: 96


🏁 Script executed:

wc -l ./cmuxTests/WorkspaceUnitTests.swift

Repository: manaflow-ai/cmux

Length of output: 101


🏁 Script executed:

sed -n '451,497p' ./cmuxTests/WorkspaceUnitTests.swift

Repository: manaflow-ai/cmux

Length of output: 1973


🏁 Script executed:

# Check if there are other strong references to the Workspace in the closure captures
sed -n '475,495p' ./cmuxTests/WorkspaceUnitTests.swift

Repository: manaflow-ai/cmux

Length of output: 893


🏁 Script executed:

# Verify the proposed diff syntax by examining the context more carefully
# Check if there are any other uses of capturedClosingWorkspace after line 468
sed -n '462,496p' ./cmuxTests/WorkspaceUnitTests.swift | grep -n "capturedClosingWorkspace"

Repository: manaflow-ai/cmux

Length of output: 260


🏁 Script executed:

# Find the Workspace class/struct definition to confirm it's a reference type
rg -t swift 'class Workspace|struct Workspace' --max-count 5

Repository: manaflow-ai/cmux

Length of output: 2742


🏁 Script executed:

# Search for the actual Workspace class/struct definition
rg -t swift '^(class|struct) Workspace[^a-zA-Z]' --max-count 10

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

fd -t f "Workspace.swift" | head -5

Repository: manaflow-ai/cmux

Length of output: 83


🏁 Script executed:

head -100 Sources/Workspace.swift | grep -A 3 "^class Workspace\|^struct Workspace"

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -t swift "class Workspace [:{]|struct Workspace [:{]" Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

sed -n '1,200p' Sources/Workspace.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 3122


🏁 Script executed:

rg -n "^(final )?class Workspace\b" Sources/Workspace.swift

Repository: manaflow-ai/cmux

Length of output: 120


🏁 Script executed:

# Verify the syntax of the proposed refactor by examining similar patterns
rg -A 5 "do \{" cmuxTests/WorkspaceUnitTests.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Verify the proposed syntax more directly by checking if similar do-block patterns exist
rg -B 2 -A 5 "do \{" Sources/Workspace.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 1082


Remove the extra strong local to make the weak-deallocation assertion deterministic.

Line 462–468 introduce capturedClosingWorkspace as a strong local that remains in function scope until line 497. At line 496, the test expects weakClosingWorkspace to be nil, but the strong local still being in scope keeps the workspace alive. In Debug builds, the strong reference prevents deallocation; in Release builds with optimizations, the compiler may elide the unused local, causing non-deterministic behavior.

Scope the strong capture to a narrow block. Extract only closingWorkspaceId and the weak reference outside the block so the workspace can be deallocated before the final assertion.

Proposed refactor
-        guard let capturedClosingWorkspace = closingWorkspace else {
-            XCTFail("Expected secondary workspace")
-            return
-        }
-
-        let closingWorkspaceId = capturedClosingWorkspace.id
-        weak var weakClosingWorkspace = capturedClosingWorkspace
+        let closingWorkspaceId: UUID
+        weak var weakClosingWorkspace: Workspace?
+        do {
+            guard let capturedClosingWorkspace = closingWorkspace else {
+                XCTFail("Expected secondary workspace")
+                return
+            }
+            closingWorkspaceId = capturedClosingWorkspace.id
+            weakClosingWorkspace = capturedClosingWorkspace
+        }
         XCTAssertEqual(manager.tabs.map(\.id), [first.id, closingWorkspaceId, third.id])
         closingWorkspace = nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/WorkspaceUnitTests.swift` around lines 462 - 470, The strong local
capturedClosingWorkspace keeps the workspace alive until the function scope
ends, making the weak-deallocation assertion non-deterministic; wrap the strong
capture in a narrow scope (e.g. a do { ... } block) so you only extract
closingWorkspaceId and create weakClosingWorkspace inside that block (use
capturedClosingWorkspace.id to set closingWorkspaceId and weak var
weakClosingWorkspace = capturedClosingWorkspace there), then exit the block, set
closingWorkspace = nil, and assert weakClosingWorkspace is nil — this removes
the long-lived strong reference from capturedClosingWorkspace and ensures
deterministic deallocation while preserving the existing assertions on
manager.tabs, first.id, and third.id.

Comment thread Sources/TabManager.swift
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = makeWorkspaceForCreation(
title: "Terminal \(nextTabCount)",

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 | 🟡 Minor

Localize the default workspace title.

"Terminal \(nextTabCount)" is user-visible and will surface in the workspace list/window title, so it should go through the repo’s localization path instead of staying a bare literal.

🌐 Proposed fix
-                title: "Terminal \(nextTabCount)",
+                title: String(
+                    localized: "workspace.title.default",
+                    defaultValue: "Terminal \(nextTabCount)"
+                ),

Please also add the key to Resources/Localizable.xcstrings.

As per coding guidelines, "**/*.swift: All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

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

In `@Sources/TabManager.swift` at line 1240, Replace the bare literal "Terminal
\(nextTabCount)" with a localized string using the repo pattern — e.g. use
String(localized: "terminal.title.default", defaultValue: "Terminal
%d").formatted(nextTabCount) (or equivalent String(format:) if you prefer) in
the TabManager code where the title is created (the call that currently uses
nextTabCount), and add the key "terminal.title.default" with value "Terminal %d"
to Resources/Localizable.xcstrings so the default workspace title is localized.

@austinywang
austinywang merged commit 8a37815 into main Mar 26, 2026
31 checks passed
Jesssullivan added a commit to Jesssullivan/cmux that referenced this pull request Mar 26, 2026
Ingests all upstream fixes since 2026-03-22 including:
- Fix Cmd+N crash: retain snapshot workspaces (manaflow-ai#2183, manaflow-ai#2181, manaflow-ai#2178, manaflow-ai#2173)
- Fix browser pane restore after reopen (manaflow-ai#2141)
- Fix Ghostty resize_split keybind (manaflow-ai#1899)
- Reduce shell integration prompt latency (manaflow-ai#2109)
- Fix command palette focus after terminal find (manaflow-ai#2089)
- Add Codex CLI hooks (manaflow-ai#2103)
- Add cmux.json custom commands (manaflow-ai#2011)
- Fix window position restore on relaunch (manaflow-ai#2129)

Conflict resolution:
- BrowserPanel.swift: accepted upstream configureWebViewConfiguration()
  refactor (already includes our forMainFrameOnly:true CAPTCHA fix from PR manaflow-ai#1877)

Fork-specific files preserved:
- Sources/Panels/WebAuthn{Coordinator,BridgeJavaScript}.swift
- Sources/FIDO2/module.modulemap
- vendor/ctap2 submodule
- cmux.entitlements (with camera/audio-input removed)
- cmux.embedded.entitlements
- .github/workflows/fork-{ci,release}.yml
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…w-ai#2183)

* test: reproduce Cmd+N snapshot workspace lifetime race

* fix: retain snapshot workspaces through Cmd+N creation

* fix: repair workspace lifetime regression test

This branch was successfully deployed

1 active deployment
Preview — 80857190 Deployed Mar 26, 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.

1 participant