Skip to content

Fix orphaned child processes when closing workspace tabs#889

Merged
lawrencecchen merged 3 commits intomanaflow-ai:mainfrom
novarii:fix/orphaned-processes-on-workspace-close
Mar 5, 2026
Merged

Fix orphaned child processes when closing workspace tabs#889
lawrencecchen merged 3 commits intomanaflow-ai:mainfrom
novarii:fix/orphaned-processes-on-workspace-close

Conversation

@novarii
Copy link
Copy Markdown
Contributor

@novarii novarii commented Mar 4, 2026

Summary

  • Closing a workspace tab via the sidebar X button left child processes (zsh, claude, etc.) running as orphans because TabManager.closeWorkspace() relied on ARC deallocation to free Ghostty surfaces, but retained references delayed/prevented deinit
  • Added TerminalSurface.teardownSurface() to eagerly free the Ghostty surface (sends SIGHUP), called from TerminalPanel.close()
  • Added Workspace.teardownAllPanels() which closes all panels and cleans up subscriptions/port scanner registrations
  • TabManager.closeWorkspace() now calls teardownAllPanels() before removing the workspace from the tabs array

Test plan

  • Open multiple workspace tabs, each with active shell/claude sessions
  • Close workspace tabs via the X button in the sidebar
  • Run ps -eo pid,comm | grep -E "zsh|claude" and verify no orphaned processes remain
  • Close individual split panels (Cmd+W) and verify cleanup still works
  • Verify the last remaining workspace tab still functions normally

Summary by cubic

Prevents orphaned shell/Claude processes when closing workspace tabs by eagerly freeing Ghostty surfaces and tearing down all panels. SIGHUP is now delivered deterministically, and closing a non-owned workspace is ignored.

  • Bug Fixes
    • Added TerminalSurface.teardownSurface(): idempotent teardown that frees the runtime surface on the next main-actor turn and releases callbacks.
    • TerminalPanel.close() now calls teardownSurface().
    • Workspace.teardownAllPanels() closes panels, unregisters scanners/subscriptions, and clears panel metadata caches and surface mappings.
    • TabManager.closeWorkspace() verifies ownership, calls teardownAllPanels() before removal, and preserves the focused index.

Written for commit 90fffd4. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved resource cleanup when closing terminal panels and workspaces to prevent resource retention and teardown-related hangs; workspace removal is now more robust against missing or externally owned workspaces.
  • Tests
    • Added tests verifying panel teardown clears panel metadata and that closing a workspace doesn't affect workspaces not owned by the manager.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 4, 2026

@novarii is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

Adds explicit teardown APIs and calls to deterministically release terminal surface resources: a new TerminalSurface.teardownSurface(), TerminalPanel.close() invoking it, Workspace.teardownAllPanels() to bulk-close panels, and TabManager.closeWorkspace() updated to call teardown before removing a workspace.

Changes

Cohort / File(s) Summary
Surface Teardown Implementation
Sources/GhosttyTerminalView.swift
Adds func teardownSurface() to TerminalSurface. Idempotent; clears/releases surface callback context, nulls runtime surface, and frees surface on the main actor to align teardown with deinit.
Panel Teardown Integration
Sources/Panels/TerminalPanel.swift
Calls surface.teardownSurface() during TerminalPanel.close() to ensure panel-level surface cleanup on close.
Workspace & Manager Cleanup
Sources/Workspace.swift, Sources/TabManager.swift
Adds Workspace.teardownAllPanels() which closes each panel, unregisters subscriptions and PortScanner entries, clears panels and surface mappings. TabManager.closeWorkspace() now invokes teardownAllPanels() and guards workspace index before removal. Note: teardownAllPanels() appears duplicated in the workspace extension.
Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Adds tests verifying teardownAllPanels() clears panel metadata caches and that TabManager.closeWorkspace() ignores workspaces not owned by the manager (preserves external workspace state).

Sequence Diagram

sequenceDiagram
    actor User
    participant TabMgr as TabManager
    participant Workspace
    participant TermPanel as TerminalPanel
    participant Surface as TerminalSurface

    User->>TabMgr: closeWorkspace(workspace)
    activate TabMgr

    TabMgr->>Workspace: teardownAllPanels()
    activate Workspace

    loop each panel
        Workspace->>TermPanel: close()
        activate TermPanel

        TermPanel->>Surface: teardownSurface()
        activate Surface
        Surface->>Surface: mark portal lifecycle closed
        Surface->>Surface: clear & release callback context
        Surface->>Surface: null runtime surface
        Surface->>Surface: free surface on main actor
        deactivate Surface

        TermPanel->>Workspace: unregisterSubscriptions() / unregisterFromPortScanner()
        deactivate TermPanel
    end

    Workspace->>Workspace: clear panels & surface mappings
    deactivate Workspace

    TabMgr->>TabMgr: remove workspace by index
    deactivate TabMgr
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped by the code, gave a twitch and a cheer,

Surfaces folded, no callbacks to fear,
Panels tucked in, one by one they unwind,
Workspaces cleared — tidy traces behind,
A carrot for cleanup, and smooth state this year.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly and clearly describes the main objective: fixing orphaned child processes that occur when closing workspace tabs, which aligns with the primary change of adding an explicit teardown chain to eagerly free Ghostty terminal surfaces.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

🧹 Nitpick comments (1)
Sources/Workspace.swift (1)

2155-2163: Clear remaining panel-derived caches during workspace teardown.

Line 2161–Line 2163 only clears panels and surfaceIdToPanelId. In the exact delayed-retention case this PR targets, per-panel metadata can remain on the retained Workspace longer than needed. Consider reusing existing metadata cleanup so teardown is deterministic and consistent with normal close paths.

Proposed diff
 func teardownAllPanels() {
     for (panelId, panel) in panels {
         panel.close()
         panelSubscriptions.removeValue(forKey: panelId)
         PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
     }
     panels.removeAll()
     surfaceIdToPanelId.removeAll()
+    pruneSurfaceMetadata(validSurfaceIds: [])
+    restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
+    terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
+    lastTerminalConfigInheritancePanelId = nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 2155 - 2163, The teardownAllPanels()
loop currently closes panels, removes panelSubscriptions entries, and
unregisters ports but does not clear per-panel metadata caches (so metadata can
linger on retained Workspace); update teardownAllPanels() to invoke the same
metadata cleanup used by the normal panel close path for each panel (e.g., call
the existing per-panel cleanup function used by closePanel(panelId:) or the
panel's metadataClear method) before or after panel.close(), and ensure you also
remove any per-panel entries from panelSubscriptions and surfaceIdToPanelId so
all panel-derived caches are deterministically cleared.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 2155-2163: The teardownAllPanels() loop currently closes panels,
removes panelSubscriptions entries, and unregisters ports but does not clear
per-panel metadata caches (so metadata can linger on retained Workspace); update
teardownAllPanels() to invoke the same metadata cleanup used by the normal panel
close path for each panel (e.g., call the existing per-panel cleanup function
used by closePanel(panelId:) or the panel's metadataClear method) before or
after panel.close(), and ensure you also remove any per-panel entries from
panelSubscriptions and surfaceIdToPanelId so all panel-derived caches are
deterministically cleared.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c00e696f-fae6-49f0-8557-bbd713f70414

📥 Commits

Reviewing files that changed from the base of the PR and between bd6fa9e and 372951c.

📒 Files selected for processing (4)
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR fixes orphaned child processes (zsh, claude, etc.) when closing workspace tabs by introducing eager, deterministic Ghostty surface teardown rather than relying on ARC deallocation. The fix is well-scoped: TerminalSurface.teardownSurface() provides an idempotent @MainActor path that nils the surface pointer before scheduling ghostty_surface_free on the next main-actor drain (matching the existing deinit pattern), Workspace.teardownAllPanels() tears down every panel with correct subscription-cancellation ordering, and TabManager.closeWorkspace() gains an ownership guard and calls teardown before tab removal.

Key changes:

  • teardownSurface() is correctly @MainActor-annotated, idempotent (guarded by portalLifecycleState), and safe to call before deinitdeinit skips the free if the surface is already nil.
  • Cleanup order in teardownAllPanels() cancels the Combine subscription and unregisters the port scanner before panel.close(), preventing late-firing reactive updates from reaching workspace state mid-teardown.
  • pruneSurfaceMetadata(validSurfaceIds: []) correctly clears panelTitles, panelCustomTitles, pinnedPanelIds, and manualUnreadPanelIds via the existing filter-on-empty-set path — the previous review thread concern about panelTitles not being cleared is addressed by this call.
  • bonsplitController tab/pane state is not torn down during teardownAllPanels(). Post-teardown bonsplit delegate callbacks that attempt surfaceIdToPanelId lookups will safely return nil (the map is cleared), but any observer of bonsplitController.tabs(inPane:) on the torn-down workspace will see stale entries until dealloc.
  • The post-loop panelSubscriptions.removeAll() is redundant in normal operation since the loop already removes every entry, though it acts as a safety net for subscriptions without a matching panel.

Confidence Score: 4/5

  • This PR is safe to merge — the core fix is correct, teardown is idempotent, and edge cases are well-handled.
  • The primary logic (teardownSurface + teardownAllPanels + closeWorkspace ownership guard) is sound and well-tested. The one structural gap — bonsplit state not being cleared during teardown — is unlikely to cause real issues since the workspace is removed from tabs immediately after and bonsplit callbacks gracefully handle empty lookups. The redundant post-loop removeAll and the minor test precondition fragility are low-risk. Score reflects high confidence with a small deduction for the bonsplit inconsistency.
  • Sources/Workspace.swift — teardownAllPanels() leaves bonsplitController with stale tab/pane entries that are never explicitly torn down.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds teardownSurface() — an idempotent, @MainActor-annotated method that nils out surface/surfaceCallbackContext eagerly and defers ghostty_surface_free via a Task to the next main-actor turn (matching the existing deinit pattern). Correctly guarded by the portalLifecycleState idempotency check.
Sources/Panels/TerminalPanel.swift Adds surface.teardownSurface() call at the end of close(). Since teardownSurface() is idempotent, double-close scenarios (regular Cmd+W path and workspace teardown path) are safe. The ordering after beginPortalCloseLifecycle and TerminalWindowPortalRegistry.detach is correct.
Sources/TabManager.swift Refactors closeWorkspace to add an ownership guard (guard let index), call teardownAllPanels() before tab removal, and move the focused-index update outside the now-removed if let block. Logic is correct; lastFocusedPanelByTab cleanup on close was already absent pre-PR.
Sources/Workspace.swift Adds teardownAllPanels(): cancels subscriptions and unregisters port scanners before calling panel.close() (correct order), then clears panels, surfaceIdToPanelId, and calls pruneSurfaceMetadata(validSurfaceIds: []) to clear title/pin/unread caches. bonsplitController tab state is not torn down, leaving stale entries; post-loop panelSubscriptions.removeAll() is redundant.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Adds two new test classes: WorkspaceTeardownTests verifies panel metadata caches are cleared by teardownAllPanels(), and TabManagerWorkspaceOwnershipTests verifies that closing a non-owned workspace is a no-op. Pre-condition assertion for panelTitles relies on synchronous title population at panel creation time.

Sequence Diagram

sequenceDiagram
    participant User
    participant TabManager
    participant Workspace
    participant TerminalPanel
    participant TerminalSurface
    participant Ghostty

    User->>TabManager: closeWorkspace(workspace)
    TabManager->>TabManager: guard tabs.count > 1
    TabManager->>TabManager: guard let index (ownership check)
    TabManager->>TabManager: clearNotifications / unwireBrowserTracking
    TabManager->>Workspace: teardownAllPanels()

    loop For each panel
        Workspace->>Workspace: panelSubscriptions.removeValue(panelId)
        Workspace->>Workspace: PortScanner.unregisterPanel(...)
        Workspace->>TerminalPanel: panel.close()
        TerminalPanel->>TerminalSurface: beginPortalCloseLifecycle("panel.close")
        TerminalPanel->>TerminalSurface: teardownSurface()
        TerminalSurface->>TerminalSurface: markPortalLifecycleClosed("teardown")
        TerminalSurface->>TerminalSurface: surface = nil
        TerminalSurface-->>TerminalSurface: Task { @MainActor } scheduled
    end

    Workspace->>Workspace: panels.removeAll()
    Workspace->>Workspace: surfaceIdToPanelId.removeAll()
    Workspace->>Workspace: pruneSurfaceMetadata(validSurfaceIds: [])

    TabManager->>TabManager: tabs.remove(at: index)
    TabManager->>TabManager: update selectedTabId if needed

    Note over TerminalSurface,Ghostty: Next main-actor drain
    TerminalSurface->>Ghostty: ghostty_surface_free(surfaceToFree)
    Ghostty-->>User: SIGHUP → child process exits
Loading

Last reviewed commit: 90fffd4

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.

2 issues found across 4 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/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:2161">
P2: `teardownAllPanels()` performs partial cleanup and leaves multiple panel-indexed state maps populated, which can retain stale workspace state when deallocation is delayed.</violation>
</file>

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

<violation number="1" location="Sources/TabManager.swift:1001">
P2: Only tear down panels after confirming the workspace exists in this manager; otherwise a stale/non-owned workspace can be terminated unexpectedly.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

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

@lawrencecchen
Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@lawrencecchen
Copy link
Copy Markdown
Contributor

@codex review
@coderabbitai review
@cubic-dev-ai review
@greptile-apps review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Mar 5, 2026

@codex review
@coderabbitai review
@cubic-dev-ai review
@greptile-apps review

@lawrencecchen I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 5, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== GhosttyTerminalView.swift ==="
cat Sources/GhosttyTerminalView.swift

echo ""
echo "=== TerminalPanel.swift ==="
cat Sources/Panels/TerminalPanel.swift

echo ""
echo "=== TabManager.swift ==="
cat Sources/TabManager.swift

echo ""
echo "=== Workspace.swift ==="
cat Sources/Workspace.swift

Length of output: 262396


@lawrencecchen: Here's my review of PR #889.


Summary

The approach is sound: rather than relying on ARC deallocation to send SIGHUP via ghostty_surface_free, this PR introduces an explicit teardown chain (teardownSurfaceTerminalPanel.closeWorkspace.teardownAllPanelsTabManager.closeWorkspace) that eagerly frees surfaces before removing the workspace from the tabs array. The overall design is correct and consistent with the existing deinit pattern.


Issues Found

🔴 teardownSurface() is missing @MainActor isolation

teardownSurface() mutates surface and surfaceCallbackContext — the same properties guarded in deinit with comments about async dispatch to avoid re-entrant issues — but lacks @MainActor annotation:

// Current — no thread enforcement
func teardownSurface() {
    markPortalLifecycleClosed(reason: "teardown")
    let callbackContext = surfaceCallbackContext
    surfaceCallbackContext = nil
    let surfaceToFree = surface
    surface = nil
    ...
    Task { `@MainActor` in
        ghostty_surface_free(surfaceToFree)
        callbackContext?.release()
    }
}

Without @MainActor, Swift's concurrency model doesn't enforce that this is called on the main actor. If any caller dispatches this from a background context in the future, you get a race between surface = nil (sync, unguarded) and the Task { @mainactor in ghostty_surface_free(...) }. Since all current callers appear to be on the main thread, this won't reproduce today, but it's fragile. Suggested fix:

`@MainActor`
func teardownSurface() {
    markPortalLifecycleClosed(reason: "teardown")
    let callbackContext = surfaceCallbackContext
    surfaceCallbackContext = nil
    let surfaceToFree = surface
    surface = nil
    guard let surfaceToFree else {
        callbackContext?.release()
        return
    }
    Task { `@MainActor` in
        ghostty_surface_free(surfaceToFree)
        callbackContext?.release()
    }
}

Similarly, teardownAllPanels() in Workspace should also be annotated @MainActor to enforce this at the call site in TabManager.closeWorkspace().


🟡 ghostty_surface_free is still deferred via Task { @mainactor in ... } in teardownSurface()

The deinit uses the async Task pattern specifically because it cannot call MainActor-isolated code synchronously from a non-isolated deinit. But teardownSurface() is called from an already-main-thread context (TabManager.closeWorkspaceWorkspace.teardownAllPanelsTerminalPanel.close), so the extra async hop delays the actual SIGHUP delivery by one runloop turn.

This is not a correctness issue — it still fixes the orphaned process problem — but if teardownSurface() is @MainActor-annotated (as suggested above), the free could be done synchronously for a tighter teardown:

`@MainActor`
func teardownSurface() {
    markPortalLifecycleClosed(reason: "teardown")
    let callbackContext = surfaceCallbackContext
    surfaceCallbackContext = nil
    let surfaceToFree = surface
    surface = nil
    guard let surfaceToFree else {
        callbackContext?.release()
        return
    }
    // Already on MainActor — free immediately, no async hop needed
    ghostty_surface_free(surfaceToFree)
    callbackContext?.release()
}

The deinit path can keep the Task (it has no choice), and teardownSurface() nils surface first, so the deinit guard guard let surfaceToFree still makes this safe against double-free.


🟢 Correctness observations (no action needed)

  • Idempotency: surface = nil before the guard let surfaceToFree check ensures double-calling teardownSurface() (or teardownSurface() followed by deinit) is safe. ✅
  • In-flight callback safety: After teardownAllPanels() clears the panels dict, all in-flight async callbacks that check workspace.panels[surfaceId] != nil will bail out cleanly. ✅
  • Subscription/PortScanner cleanup order: Cleaning up subscriptions and PortScanner registrations inside teardownAllPanels() before clearing the panels map prevents stale callbacks post-close. ✅

🧠 Learnings used
Learnt from: homanp
Repo: manaflow-ai/cmux PR: 883
File: CLI/cmux.swift:1569-1586
Timestamp: 2026-03-04T22:05:04.280Z
Learning: In manaflow-ai/cmux CLI (CLI/cmux.swift), for parity with existing browser commands, markdown.open intentionally normalizes --surface before resolving --workspace/--window, relying on server-side resolution to disambiguate final routing. Avoid one-off reordering in markdown; consider any change only as a coordinated, cross-command refactor.

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:3491-3493
Timestamp: 2026-03-04T14:06:12.296Z
Learning: For manaflow-ai/cmux PR `#819` (Japanese i18n), keep scope limited to localization changes; UX enhancements like preferring workspace.customTitle in workspaceDisplayName() or altering move-target labels should be handled in a separate follow-up issue.
✅ Actions performed

Full review triggered.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

1 issue found across 5 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="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:4026">
P2: The assertion on `manualUnreadPanelIds` is trivially true because no panel is ever marked unread in the test setup. Add `workspace.markPanelUnread(initialPanelId)` (and/or for `splitPanel.id`) before calling `teardownAllPanels()` to actually verify that teardown clears unread state.</violation>
</file>

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

@lawrencecchen
Copy link
Copy Markdown
Contributor

Pushed follow-up fixes for latest thread feedback:

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Mar 5, 2026

Pushed follow-up fixes for latest thread feedback:

@lawrencecchen I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 5, 2026

@lawrencecchen: Thanks for pushing the follow-up fixes! I'll re-review the PR now to verify the @MainActor annotations on teardownSurface() and teardownAllPanels(), and the removal of the unnecessary async Task hop.


🧠 Learnings used
Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-05T00:04:09.862Z
Learning: Applies to **/*TerminalView.swift : Terminal find layering contract: SurfaceSearchOverlay must be mounted from GhosttySurfaceScrollView in Sources/GhosttyTerminalView.swift (AppKit portal layer), not from SwiftUI panel containers

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-05T00:05:51.436Z
Learning: Applies to **/GhosttyTerminalView.swift : `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`

Learnt from: homanp
Repo: manaflow-ai/cmux PR: 883
File: CLI/cmux.swift:1569-1586
Timestamp: 2026-03-04T22:05:07.913Z
Learning: In manaflow-ai/cmux CLI (CLI/cmux.swift), for parity with existing browser commands, markdown.open intentionally normalizes --surface before resolving --workspace/--window, relying on server-side resolution to disambiguate final routing. Avoid one-off reordering in markdown; consider any change only as a coordinated, cross-command refactor.

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:3491-3493
Timestamp: 2026-03-04T14:06:16.241Z
Learning: For manaflow-ai/cmux PR `#819` (Japanese i18n), keep scope limited to localization changes; UX enhancements like preferring workspace.customTitle in workspaceDisplayName() or altering move-target labels should be handled in a separate follow-up issue.
✅ Actions performed

Full review triggered.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 5 files

novarii and others added 3 commits March 4, 2026 18:59
When closing a workspace tab via the sidebar X button, child processes
(login → zsh → claude) survived as orphans because TabManager.closeWorkspace()
only removed the workspace from the tabs array without explicitly freeing
Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views
and Combine publishers held references, delaying or preventing
ghostty_surface_free() (which sends SIGHUP) from ever running.

This adds explicit teardown on the workspace close path:
- TerminalSurface.teardownSurface(): idempotent method to free the Ghostty
  runtime surface eagerly, matching the existing deinit logic
- TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent
- Workspace.teardownAllPanels() iterates all panels and closes them
- TabManager.closeWorkspace() calls teardownAllPanels() before removing
  the workspace from the tabs array
@lawrencecchen lawrencecchen force-pushed the fix/orphaned-processes-on-workspace-close branch from 46e32ba to 90fffd4 Compare March 5, 2026 03:02
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.

🧹 Nitpick comments (1)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

4154-4167: Optional: assert tab titles remain unchanged to lock in ownership no-op semantics.

Line [4163] already proves order stability via IDs; adding a title snapshot would fully guard the “order + titles unchanged” expectation.

♻️ Suggested test hardening
         let initialTabIds = manager.tabs.map(\.id)
+        let initialTabTitles = manager.tabs.map(\.title)
         let initialSelectedTabId = manager.selectedTabId
@@
         XCTAssertEqual(manager.tabs.map(\.id), initialTabIds)
+        XCTAssertEqual(manager.tabs.map(\.title), initialTabTitles)
         XCTAssertEqual(manager.selectedTabId, initialSelectedTabId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 4154 - 4167, Add
an assertion to also verify that tab titles remain unchanged after calling
manager.closeWorkspace(externalWorkspace): capture a snapshot of manager.tabs'
titles (e.g., manager.tabs.map(\.title)) into a variable before the call and
assert it equals manager.tabs.map(\.title) after the call; similarly, assert
that externalWorkspace.panelTitles (already captured as
externalPanelTitlesBefore) matches after the call to lock in the "no-op
ownership" semantics—update near the existing initialTabIds/initialSelectedTabId
captures and following XCTAssertEqual checks that reference manager.tabs,
manager.selectedTabId, and externalWorkspace.panelTitles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 4154-4167: Add an assertion to also verify that tab titles remain
unchanged after calling manager.closeWorkspace(externalWorkspace): capture a
snapshot of manager.tabs' titles (e.g., manager.tabs.map(\.title)) into a
variable before the call and assert it equals manager.tabs.map(\.title) after
the call; similarly, assert that externalWorkspace.panelTitles (already captured
as externalPanelTitlesBefore) matches after the call to lock in the "no-op
ownership" semantics—update near the existing initialTabIds/initialSelectedTabId
captures and following XCTAssertEqual checks that reference manager.tabs,
manager.selectedTabId, and externalWorkspace.panelTitles.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89810db3-4bc6-4477-af97-72489c578159

📥 Commits

Reviewing files that changed from the base of the PR and between 372951c and 90fffd4.

📒 Files selected for processing (5)
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/TabManager.swift

@lawrencecchen
Copy link
Copy Markdown
Contributor

@greptile-apps review\n@cubic-dev-ai review

@lawrencecchen
Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment on lines +2322 to +2338
func teardownAllPanels() {
let panelEntries = Array(panels)
for (panelId, panel) in panelEntries {
panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}

panels.removeAll(keepingCapacity: false)
surfaceIdToPanelId.removeAll(keepingCapacity: false)
panelSubscriptions.removeAll(keepingCapacity: false)
pruneSurfaceMetadata(validSurfaceIds: [])
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
lastTerminalConfigInheritancePanelId = nil
lastTerminalConfigInheritanceFontPoints = nil
}
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.

bonsplitController tabs are not torn down during workspace teardown

teardownAllPanels() clears panels and surfaceIdToPanelId, but the bonsplitController still retains its full tab and pane tree. After teardown, the bonsplit controller will have tabs whose surfaceIdToPanelId lookups all return nil (since the map is cleared). If any deferred bonsplit delegate callback fires after teardown (e.g., from a prior closeTab call that was already in flight), the workspace's delegate will find empty lookups and silently return — so this is unlikely to crash. However, any code that continues to observe bonsplitController.tabs(inPane:) on the torn-down workspace will see stale tab entries.

Consider iterating over all bonsplit pane/tab IDs and calling bonsplitController.reset() (or equivalent) after the panel loop, or at minimum documenting that bonsplit cleanup is intentionally deferred to dealloc. This mirrors the fact that closePanel(_:force:) drives bonsplit closure, but teardownAllPanels() bypasses that path.

Comment on lines +4117 to +4122
XCTFail("Expected focused panel in new workspace")
return
}

workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title")
workspace.setPanelPinned(panelId: initialPanelId, pinned: true)
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.

Pre-condition assertion relies on async title population

The test asserts XCTAssertFalse(workspace.panelTitles.isEmpty) before teardown, expecting that creating a workspace and splitting a panel will populate panelTitles synchronously. This holds today because panelTitles[terminalPanel.id] = terminalPanel.displayTitle is set inline during panel creation (Workspace.swift line ~1155). However, if displayTitle ever returns an empty string on creation and the real title arrives asynchronously (e.g., from a shell prompt update), this precondition assertion would silently pass with an empty dict — meaning the post-teardown XCTAssertTrue(workspace.panelTitles.isEmpty) would also trivially pass without actually validating teardown behavior.

Consider using an explicit XCTAssertGreaterThan(workspace.panelTitles.count, 0, "panelTitles should be populated before testing teardown") with a message to make the dependency visible, or directly seed workspace.panelTitles before the precondition check.

@lawrencecchen lawrencecchen merged commit 2712cab into manaflow-ai:main Mar 5, 2026
3 of 4 checks passed
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.

2 participants