Skip to content

fix: clean up per-surface state on tab close to prevent unbounded memory growth#2192

Open
anthhub wants to merge 4 commits intomanaflow-ai:mainfrom
anthhub:fix/per-surface-dict-leak
Open

fix: clean up per-surface state on tab close to prevent unbounded memory growth#2192
anthhub wants to merge 4 commits intomanaflow-ai:mainfrom
anthhub:fix/per-surface-dict-leak

Conversation

@anthhub
Copy link
Copy Markdown
Contributor

@anthhub anthhub commented Mar 26, 2026

Problem

TerminalController accumulates per-surface state that is never cleaned up when a surface is destroyed:

  1. 6 browser panel dictionaries (v2BrowserFrameSelectorBySurface, v2BrowserInitScriptsBySurface, v2BrowserInitStylesBySurface, v2BrowserDialogQueueBySurface, v2BrowserDownloadEventsBySurface, v2BrowserUnsupportedNetworkRequestsBySurface) — written during browser/socket interactions, never removed
  2. v2BrowserElementRefs — element references keyed by surface ID, never pruned after surface close (stale refs can never be resolved anyway per the guard at L6899)
  3. v2RefByUUID[.surface] / v2UUIDByRef[.surface] — handle mappings written on creation, only added-to by v2RefreshKnownRefs() but never removed

Over long sessions with many workspaces created and closed, these dictionaries grow without bound.

Fix

Sources/TerminalController.swift: Add cleanupSurfaceState(surfaceId:) that clears all 8 categories of per-surface data in one call.

Sources/Workspace.swift: Call cleanupSurfaceState from splitTabBar(_:didCloseTab:fromPane:) alongside the existing panel dictionary cleanup.

Note on Leak 1 from #2078

The reported observer accumulation in GhosttyApp does not exist — appObservers.append runs only inside initializeGhostty(), which is called once from private init(). Since GhosttyApp is static let shared, the observer count is always exactly 2.

Test plan

  • Run cmux for an extended session, repeatedly creating and closing workspaces with browser panels
  • Verify via Instruments (Allocations) that the cleaned dictionaries do not grow after tab close
  • Confirm normal browser panel functionality (navigation, script injection, dialogs, downloads) is unaffected

Partially addresses #2078

Summary by CodeRabbit

  • Bug Fixes
    • Tab and panel closing now fully cleans up per-surface terminal and browser-related state, preventing residual data from affecting subsequent sessions.
    • Closing via detach/move preserves state so tabs moved between panes keep their existing session data.
    • Improved reliability and performance by ensuring cleanup runs at the correct teardown points.

Six per-surface dictionaries in TerminalController
(v2BrowserFrameSelectorBySurface, v2BrowserInitScriptsBySurface,
v2BrowserInitStylesBySurface, v2BrowserDialogQueueBySurface,
v2BrowserDownloadEventsBySurface, v2BrowserUnsupportedNetworkRequestsBySurface)
are populated when browser panels interact with socket commands but never
cleaned up when the surface is destroyed. Over long sessions with many
workspaces created and closed, these dictionaries grow without bound.

Add TerminalController.cleanupBrowserSurfaceState(surfaceId:) and call it
from Workspace.splitTabBar(_:didCloseTab:fromPane:) alongside the existing
panel dictionary cleanup.

Partially addresses manaflow-ai#2078

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 26, 2026

@anthhub 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 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: af1d1c74-ca85-4339-87e8-418795061035

📥 Commits

Reviewing files that changed from the base of the PR and between a67f4f3 and 1685946.

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

📝 Walkthrough

Walkthrough

Added @MainActor func cleanupSurfaceState(surfaceId:) to TerminalController to clear stored per-surface browser-related state. Workspace teardown/close flows now invoke this method for panels/tabs/panes (teardownAllPanels, splitTabBar(:didCloseTab:fromPane:), splitTabBar(:didClosePane:)) with conditional skipping for detach/move closes.

Changes

Cohort / File(s) Summary
TerminalController surface cleanup
Sources/TerminalController.swift
Added @MainActor func cleanupSurfaceState(surfaceId: UUID) that removes per-surface entries from internal dictionaries (frame selector, init scripts/styles, dialog queue, download events, unsupported network requests), filters out v2BrowserElementRefs for the surface, and removes v2RefByUUID[.surface] / v2UUIDByRef[.surface] mappings.
Workspace tab/pane/panel teardown integration
Sources/Workspace.swift
Workspace teardown and close flows now call TerminalController.shared.cleanupSurfaceState(surfaceId:): teardownAllPanels() calls it for each panel; splitTabBar(_:didCloseTab:fromPane:) calls it after notification cleanup only when !isDetaching; splitTabBar(_:didClosePane:) cleans up each closed panelId before closing/removing panes.

Sequence Diagram(s)

sequenceDiagram
  participant Workspace
  participant NotificationStore
  participant TerminalController

  Workspace->>NotificationStore: clearNotifications(panelId)
  NotificationStore-->>Workspace: cleared
  alt close is not detaching
    Workspace->>TerminalController: cleanupSurfaceState(surfaceId)
    TerminalController-->>TerminalController: remove per-surface maps & refs
    TerminalController-->>Workspace: cleanupComplete
  else detaching/move
    Workspace-->>TerminalController: (skip cleanup for detach/move)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇 I hop from tab to tidy tab, I sweep each surface bright,

I chase away the lingering refs and tuck the frames at night,
No queues remain, no phantom calls, the warren’s neat and shorn,
A bunny’s job is done again — new day, a cleaner morn.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete against the template. While it provides detailed Problem and Fix sections, it lacks Testing details, Demo Video, Review Trigger block, and Checklist completion. Add Testing section with specific verification steps, include Demo Video URL if applicable, include Review Trigger block with bot mentions, and complete the Checklist with checkmarks for completed items.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing unbounded memory growth by cleaning up per-surface state on tab close.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

5784-5785: ⚠️ Potential issue | 🟠 Major

Gate browser-state cleanup to true destruction paths and cover pane-close closures

cleanupBrowserSurfaceState is unconditional at Line 10301, but detached tabs (isDetaching at Line 10223) are transferred, not destroyed (see transfer path at Line 10244). That can drop browser command state during moves.
Also, pane-close flow explicitly bypasses per-tab callbacks (Line 5784), and didClosePane currently does not invoke browser-surface cleanup, so leak coverage is incomplete.

💡 Proposed fix
@@
-        AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
-        TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
+        AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
+        if !isDetaching {
+            TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
+        }
@@
     func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
@@
         if !closedPanelIds.isEmpty {
             for panelId in closedPanelIds {
                 panels[panelId]?.close()
+                TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
                 panels.removeValue(forKey: panelId)
                 untrackRemoteTerminalSurface(panelId)

Also applies to: 10223-10301, 10423-10466

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

In `@Sources/Workspace.swift` around lines 5784 - 5785, cleanupBrowserSurfaceState
is being invoked unconditionally which drops browser command state during tab
transfers (when isDetaching is true) and pane-close flow bypasses per-tab
didClose callbacks; modify the logic so cleanupBrowserSurfaceState is only
called on true destruction paths (guard against isDetaching/transfer paths in
the code around cleanupBrowserSurfaceState), ensure didClosePane triggers
browser-surface cleanup for genuinely closed tabs, and wire the
pendingPaneClosePanelIds map into didClosePane to emit per-tab didClose
callbacks before removing state so pane-close closures are covered; update
references: cleanupBrowserSurfaceState, isDetaching, didClosePane, and
pendingPaneClosePanelIds.
🤖 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 201-211: cleanupBrowserSurfaceState(surfaceId:) currently removes
many per-surface stores but misses v2BrowserElementRefs, leaving stale element
references; update the function (cleanupBrowserSurfaceState) to also remove the
entry for the given surfaceId from v2BrowserElementRefs (i.e., call
removeValue(forKey: surfaceId) on v2BrowserElementRefs) so all per-surface state
is purged when a surface is destroyed.

---

Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 5784-5785: cleanupBrowserSurfaceState is being invoked
unconditionally which drops browser command state during tab transfers (when
isDetaching is true) and pane-close flow bypasses per-tab didClose callbacks;
modify the logic so cleanupBrowserSurfaceState is only called on true
destruction paths (guard against isDetaching/transfer paths in the code around
cleanupBrowserSurfaceState), ensure didClosePane triggers browser-surface
cleanup for genuinely closed tabs, and wire the pendingPaneClosePanelIds map
into didClosePane to emit per-tab didClose callbacks before removing state so
pane-close closures are covered; update references: cleanupBrowserSurfaceState,
isDetaching, didClosePane, and pendingPaneClosePanelIds.
🪄 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: a889ccc5-0c42-4124-ba71-7b1d36384b91

📥 Commits

Reviewing files that changed from the base of the PR and between 8a37815 and f4c6614.

📒 Files selected for processing (2)
  • Sources/TerminalController.swift
  • Sources/Workspace.swift

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR fixes a memory leak (issue #2078 Leak 3) by adding cleanupBrowserSurfaceState(surfaceId:) to TerminalController and calling it when a tab is closed in Workspace.splitTabBar(_:didCloseTab:fromPane:). The fix is well-targeted for the tab-close path, but the call site is missing an !isDetaching guard, which means browser init scripts, styles, frame selectors, and in-flight dialog/download queues are also wiped from the global TerminalController singleton when a browser panel is detached (dragged to a new window) — causing those scripts/styles to stop applying on future navigations in the new workspace.\n\n- P1 — isDetaching guard missing: TerminalController.shared is a singleton; its dictionaries must survive a detach since the panel continues to exist. All other cleanups in the same block are workspace-scoped and are correct to run unconditionally; the browser state dictionaries are different.\n- P2 — v2BrowserElementRefs not included: This per-surface dictionary was omitted from the cleanup and will still grow without bound.\n- P2 — Redundant @MainActor: The method annotation is redundant because the class is already @MainActor.

Confidence Score: 3/5

Not safe to merge as-is; the missing !isDetaching guard causes a functional regression for browser panels that are moved between windows.

The core memory-leak fix is correct and well-structured, but calling cleanupBrowserSurfaceState unconditionally silently breaks browser init scripts/styles for any tab-detach scenario. This is a real behavioral regression on a code path that was working before the PR, justifying a 3.

Sources/Workspace.swift — the unconditional call to cleanupBrowserSurfaceState needs an !isDetaching guard.

Important Files Changed

Filename Overview
Sources/TerminalController.swift Adds cleanupBrowserSurfaceState(surfaceId:) to remove 6 per-surface browser dictionaries on close; v2BrowserElementRefs (also per-surface) was not included and will still grow; @MainActor on the method is redundant given the class-level attribute.
Sources/Workspace.swift Calls cleanupBrowserSurfaceState in splitTabBar(_:didCloseTab:fromPane:) without an !isDetaching guard, causing browser init-scripts/styles and pending queues to be wiped from the global TerminalController singleton when a browser panel is dragged to a new window.

Sequence Diagram

sequenceDiagram
    participant User
    participant Bonsplit
    participant Workspace
    participant TerminalController

    User->>Bonsplit: Close tab (normal close)
    Bonsplit->>Workspace: splitTabBar(_:didCloseTab:fromPane:)
    Note over Workspace: isDetaching = false
    Workspace->>Workspace: panels.removeValue(panelId)
    Workspace->>Workspace: clearNotifications(forTabId:surfaceId:)
    Workspace->>TerminalController: cleanupBrowserSurfaceState(surfaceId:) ✅
    TerminalController->>TerminalController: removeValue x6 dictionaries

    User->>Bonsplit: Detach tab (drag to new window)
    Bonsplit->>Workspace: splitTabBar(_:didCloseTab:fromPane:)
    Note over Workspace: isDetaching = true
    Workspace->>Workspace: pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer
    Workspace->>Workspace: panels.removeValue(panelId) [workspace-local, OK]
    Workspace->>Workspace: clearNotifications(forTabId:surfaceId:)
    Workspace->>TerminalController: cleanupBrowserSurfaceState(surfaceId:) ⚠️ BUG
    Note over TerminalController: Init scripts, styles, frame selector wiped from singleton
    Bonsplit->>Workspace: attachDetachedSurface (new workspace)
    Note over Workspace: Browser state gone — init scripts won't replay
Loading

Reviews (1): Last reviewed commit: "fix: clean up per-surface browser dictio..." | Re-trigger Greptile

Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration)
}
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
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.

P1 Browser state wiped on tab detach/move

cleanupBrowserSurfaceState is called unconditionally, so it also fires when isDetaching is true (i.e., when the user drags a browser panel to a different window or workspace).

TerminalController.shared is a singleton, so v2BrowserInitScriptsBySurface, v2BrowserInitStylesBySurface, v2BrowserFrameSelectorBySurface, and the pending queues are shared state keyed by panel UUID. After the detach, the same panel UUID continues to live in the new workspace. Because the cleanup removed the init-script / init-style entries, any subsequent in-page navigation in the new workspace will not re-inject those scripts/styles (they're gone from the singleton). Dialogs and download events in-flight at detach time are silently dropped as well.

The other cleanups in this block (e.g. panelDirectories, panelGitBranches, panelTitles) are all workspace-level dictionaries — it is correct to clear them on detach because the new workspace will rebuild its own entries. The TerminalController dictionaries are global state and must persist as long as the panel exists.

clearNotifications — the call you followed — is notification UI state, where the intent is presumably to reset unread badges when a tab moves windows; browser script state has different semantics and should not be cleared on detach.

Suggested change
TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
if !isDetaching {
TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId)
}

Comment on lines +204 to +211
func cleanupBrowserSurfaceState(surfaceId: UUID) {
v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId)
v2BrowserInitScriptsBySurface.removeValue(forKey: surfaceId)
v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId)
v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId)
v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId)
v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId)
}
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 v2BrowserElementRefs not cleaned up

v2BrowserElementRefs: [String: V2BrowserElementRefEntry] also stores per-surface data — each V2BrowserElementRefEntry carries a surfaceId. When a surface is closed these entries are never removed, so the dictionary grows unboundedly in the same long-running-session scenario described in #2078.

Because the keys are String element-ref handles rather than UUIDs, cleanup requires a linear scan, but this could be added to the same method:

v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId }

Worth addressing here or in a follow-up to keep the fix complete.


/// Remove all per-surface browser state for a destroyed surface.
/// Called from Workspace.splitTabBar(_:didCloseTab:fromPane:) to prevent unbounded dictionary growth.
@MainActor
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 Redundant @MainActor annotation

TerminalController is already declared @MainActor at the class level (line 17), so annotating this individual method with @MainActor is redundant. It compiles fine but may be misleading (it implies the annotation is load-bearing).

Suggested change
@MainActor
func cleanupBrowserSurfaceState(surfaceId: UUID) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 2 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/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:204">
P2: cleanupBrowserSurfaceState doesn’t clear v2BrowserElementRefs entries for the closed surface, so element refs allocated by snapshot/find APIs remain indefinitely and can still grow unbounded across tab closes.</violation>
</file>

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

<violation number="1" location="Sources/Workspace.swift:10301">
P2: Browser per-surface runtime state is cleaned during detach/move close path, causing transferred browser panels to lose state before reattach.</violation>
</file>

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

…UID maps

扩展 cleanupBrowserSurfaceState → cleanupSurfaceState,追加两类额外清理:

1. v2BrowserElementRefs:过滤掉 surfaceId 匹配的条目。surface 关闭后这些
   refs 永远无法被解析(entry.surfaceId == surfaceId guard 必然失败),
   继续保留只会造成无界增长。

2. v2RefByUUID[.surface] / v2UUIDByRef[.surface]:surface 创建时写入双向
   映射,但关闭时从未清理;v2RefreshKnownRefs() 只添加不删除。先查出 ref
   string 再双向移除,不影响其他 kind(.workspace/.pane/.window)。

同步将方法改名为 cleanupSurfaceState,因为清理范围已不限于 browser 状态,
并更新调用方 Workspace.splitTabBar(_:didCloseTab:fromPane:)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@anthhub anthhub changed the title fix: clean up per-surface browser dictionaries on tab close fix: clean up per-surface state on tab close to prevent unbounded memory growth Mar 26, 2026
Surface state must not be cleaned up when a tab is being detached
(dragged to a new window), as the surface continues to live in the
new window. Only clean up on genuine tab close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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/Workspace.swift`:
- Around line 10301-10303: The current cleanup call
TerminalController.shared.cleanupSurfaceState(surfaceId: panelId) is only
invoked in the didCloseTab path when !isDetaching, leaving per-surface
dictionaries unchanged for pane-close and workspace teardown flows; update the
pane-close and workspace-teardown code paths (and any panel deinit/teardown
handlers) to call TerminalController.shared.cleanupSurfaceState(surfaceId:
panelId) unconditionally when a panel is destroyed (or ensure the existing
didCloseTab branch runs for those flows), so per-surface state is removed
regardless of isDetaching.
🪄 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: d72a77fd-5e56-4cad-8ac3-1e660afcf152

📥 Commits

Reviewing files that changed from the base of the PR and between 66a4d97 and a67f4f3.

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

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