Repository navigation
Close only the workspaces a tab manager actually owns - #8753
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthrough
ChangesWorkspace closure
Estimated code review effort: 2 (Simple) | ~5 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 25✅ Passed checks (25 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
c96f845 to
7752f62
Compare
7752f62 to
0ff66c4
Compare
0ff66c4 to
1bc4fe4
Compare
Greptile SummaryThis PR restores a membership guard to
Confidence Score: 5/5Safe to merge. The change is a single early-return guard that prevents destructive teardown from running on a workspace this manager does not own. The guard closes a narrow, well-documented re-regression: without it, closeWorkspace runs teardownAllPanels, teardownRemoteConnection, and a spurious close event against a workspace it has no business touching. The new guard uses the same UUID-identity comparison that the rest of the function already performs, is ordered correctly relative to the count guard, and has no interaction with the main-actor execution model that would introduce a TOCTOU window. The one deliberate behavior change (the global-fallback path now returns early) is correctly reasoned through in the PR description and is the intended outcome. Files Needing Attention: No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant TabManagerA as TabManager (Window A)
participant WorkspaceB as Workspace (Window B)
Note over Caller,WorkspaceB: Before this PR — foreign workspace destroyed
Caller->>TabManagerA: closeWorkspace(workspaceB)
TabManagerA->>TabManagerA: "guard tabs.count > 1 ✓"
TabManagerA->>WorkspaceB: teardownAllPanels() 💀
TabManagerA->>WorkspaceB: teardownRemoteConnection()
TabManagerA->>WorkspaceB: "owningTabManager = nil"
TabManagerA->>TabManagerA: publishCmuxWorkspaceClosed(workspaceB) 📣
Note over Caller,WorkspaceB: After this PR — foreign workspace untouched
Caller->>TabManagerA: closeWorkspace(workspaceB)
TabManagerA->>TabManagerA: "guard tabs.count > 1 ✓"
TabManagerA->>TabManagerA: "guard tabs.contains(id == workspaceB.id) ✗"
TabManagerA-->>Caller: return (no-op)
Reviews (3): Last reviewed commit: "Close only the workspaces a tab manager ..." | Re-trigger Greptile |
1bc4fe4 to
89acd5f
Compare
closeWorkspace checks only that more than one tab is open, then runs its whole teardown. It frees every panel's Ghostty surface, which SIGHUPs the child processes, empties the workspace's panels and titles, clears owningTabManager, and publishes a workspace-closed event. Membership in `tabs` is only enforced at the very end, when the array element is removed; the recordHistory block does look the index up earlier, but only to decide where in the history to record. So handing a manager a workspace from another window kills that workspace's terminals, strips its panels, and announces a close for a workspace that is still open on screen. manaflow-ai#889 added this teardown and, directly above it, a `tabs.firstIndex(where:)` guard, along with the test that covers this. A later "Reapply" merge kept the teardown and dropped the guard, so the destructive half outlived its precondition. Two call sites already make this check themselves rather than relying on closeWorkspace: AppDelegate re-checks `sourceManager.tabs.contains` before closing a source workspace, and TerminalController records `existedBefore` and skips candidates that fail it. Both predate manaflow-ai#889, so they are not compensating for the lost guard — they are evidence that callers have always needed this precondition and have been paying for it individually. One path does change. Workspace.swift resolves a manager as `owningTabManager ?? tabManagerFor(tabId:) ?? AppDelegate.shared?.tabManager`, and that last fallback is reached precisely when no manager owns the workspace. Previously such a call tore the workspace down through an unrelated manager; now it returns early, which is the intent of the guard. testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager covers this and has been failing: it hands the manager a foreign workspace and checks that the workspace keeps its panel, which is the terminal that would otherwise be killed.
89acd5f to
cde7421
Compare
|
Fresh current-main verification passed: https://github.com/manaflow-ai/cmux/actions/runs/30895315961 checked out |
closeWorkspacetears a workspace down before it checks that the workspace belongs to this manager, sohanding it a workspace from another window destroys that workspace while leaving it on screen.
What the user sees
Two windows open. Something asks window A's tab manager to close a workspace that lives in window B.
Window B's workspace keeps its tab and stays visible, but its terminals are gone: the panels were torn
down, the Ghostty surfaces freed, and freeing a surface SIGHUPs the child processes. A workspace-closed
event is published for a workspace that is still open, so anything listening downstream is told about a
close that did not happen, and
owningTabManageris left nil.Why it happens
That is the only precondition. Everything destructive then runs unconditionally:
workspace.teardownAllPanels(),workspace.teardownRemoteConnection(),owningTabManager = nil, andpublishCmuxWorkspaceClosed(workspace).teardownAllPanelsrunsdiscardClosedPanelLifecycleStateper panel, which calls
panel?.close()and removes the entry frompanelsandpanelTitles.Membership in
tabsis only enforced at the very end, when the array element is removed. TherecordHistoryblock does look the index up earlier, but only to decide where in the history torecord, and it does not stop the teardown.
The guard was there, and a merge dropped it
#889 ("Fix orphaned child processes when closing workspace tabs") added
teardownAllPanels()and,directly above it, a
tabs.firstIndex(where:)guard — the destructive step and its precondition landedin the same commit, along with the test that covers this.
git log -Lon those lines shows the guardremoved, restored by a revert, then removed again by a "Reapply" merge commit that kept the teardown.
Two call sites already make this check themselves rather than relying on
closeWorkspace:AppDelegatere-checks
sourceManager.tabs.containsbefore closing a source workspace, andTerminalControllerrecords
existedBeforeand skips candidates that fail it. Both predate #889, so they are notcompensating for the lost guard — they are evidence that callers have always needed this precondition
and have been paying for it individually.
One path does change behavior.
Workspace.swiftresolves a manager asowningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId:) ?? AppDelegate.shared?.tabManager, andthat last fallback is reached precisely when no manager owns the workspace. Previously such a call tore
the workspace down through an unrelated manager; now it returns early. That is the intent of the guard,
but it is a real change rather than a no-op, so it is worth a reviewer's attention.
Test plan
Measured on
4253cc2884, one GUI test host at a time, same checkout and DerivedData for both arms:mainunchangedtestCloseWorkspaceIgnoresWorkspaceNotOwnedByManageris the test that flips: it hands the manager aforeign workspace and checks that the workspace keeps its panel, which is the terminal that would
otherwise be killed. On
mainit fails twice,XCTAssertEqual failed: ("0") is not equal to ("1")forthe panel count and
("[:]")for the panel titles, which is the foreign workspace being emptied.No host restarts in either arm, and
CLICodexHookTimeoutRegressionTestsreportsTest run with 8 tests in 1 suite passedin both.Pull-request CI on this repo runs review bots and security scanners, not the test suite, so the arms
above are the only test evidence this carries.
The eight tests still red on this branch are test-side failures in the same suites: two git-index
fixtures describing a state git cannot produce, a scoped socket report sent to an unresolvable manager,
a stub watching a subprocess the product no longer spawns, and four waits that resolve to a blocking
helper.
Summary by CodeRabbit