Skip to content

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

Open
anthhub wants to merge 5 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 5 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 consistently cleans up per-surface terminal and browser state, preventing leftover data from affecting later sessions.
    • Closing via detach/move preserves surface state so tabs moved between panes keep their existing session data.
    • Cleanup now runs at the correct teardown points for improved reliability and performance.

@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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added cleanupSurfaceState(surfaceId:) to TerminalController and invoked it from Workspace panel/tab/pane teardown and close flows (teardownAllPanels, splitTabBar(:didCloseTab:fromPane:), splitTabBar(:didClosePane:)), skipping the call 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 v2BrowserElementRefs for the surface, and deletes v2RefByUUID[.surface] / v2UUIDByRef[.surface] mappings.
Workspace tab/pane/panel teardown integration
Sources/Workspace.swift
Workspace teardown/close flows now call TerminalController.shared.cleanupSurfaceState(surfaceId:): teardownAllPanels() invokes it for each panel; splitTabBar(_:didCloseTab:fromPane:) calls it after notification cleanup when !isDetaching; splitTabBar(_:didClosePane:) calls it for each closed panel before removing panel state and unregistering scanners.

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 through closed panels, tidy crumbs and refs away,

I scrub each surface's memory so no phantom sprites may stay,
A whisker twitch, a careful sweep, the terminal sleeps light,
The warren hums, the codebase purrs — I’ve set the panels right.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
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.
Description check ❓ Inconclusive The description provides detailed problem statement, implementation details, and notes, but lacks explicit testing confirmation, demo video, and incomplete checklist items. Add confirmation of manual testing performed, provide test results from Instruments, and complete the checklist items to demonstrate the fix has been validated.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: cleaning up per-surface state on tab close to prevent memory growth, which directly matches the core objectives of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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

Comment thread Sources/TerminalController.swift Outdated
@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

Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/TerminalController.swift Outdated
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.

Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/Workspace.swift Outdated
@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
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

Comment thread Sources/Workspace.swift
@anthhub
Copy link
Copy Markdown
Contributor Author

anthhub commented Mar 29, 2026

Gentle ping on this one — it fixes a memory leak where per-surface state grows unbounded as tabs are closed. Pretty small change if anyone has a few minutes to look.

@anthhub
Copy link
Copy Markdown
Contributor Author

anthhub commented Mar 31, 2026

Hi @austinywang @lawrencecchen 👋 Gentle ping on this one — it fixes unbounded memory growth from per-surface state not being cleaned up on tab close. Would appreciate a review when you get a chance!

@anthhub
Copy link
Copy Markdown
Contributor Author

anthhub commented Apr 27, 2026

Quick triage request on this PR.

This one still seems valuable to me because it targets the per-surface cleanup / memory-growth issue, but it is now conflict-stale. If maintainers still want this direction, I can rebase it and refresh the branch; if not, I am happy to close it rather than leave it hanging.

A short yes/no on whether this is still worth reviving would be enough.

anthhub and others added 5 commits April 27, 2026 15:11
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>
…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>
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>
@anthhub anthhub force-pushed the fix/per-surface-dict-leak branch from 1685946 to 6e0b0bf Compare April 27, 2026 07:23
@anthhub
Copy link
Copy Markdown
Contributor Author

anthhub commented Apr 27, 2026

Rebased this onto current main and resolved the Workspace.swift conflicts. I kept the later pending-input / restored-agent cleanup from main and preserved the original !isDetaching guard. I couldn’t do a full local build in this checkout because GhosttyKit.xcframework isn’t present here, but the branch should be back in reviewable shape.

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

204-211: Doc comment understates the caller set.

The comment names only Workspace.splitTabBar(_:didCloseTab:fromPane:), but per the PR summary this method is also invoked from teardownAllPanels, splitTabBar(_:didClosePane:), and the closePanel flow (see Sources/Workspace.swift:12213). Worth broadening to avoid future readers assuming a single call site.

📝 Proposed doc tweak
     /// Remove all per-surface state for a destroyed surface.
-    /// Called from Workspace.splitTabBar(_:didCloseTab:fromPane:) to prevent unbounded dictionary growth.
+    /// Called from Workspace teardown/close flows (e.g. closePanel, splitTabBar(_:didCloseTab:fromPane:)
+    /// when not detaching, splitTabBar(_:didClosePane:), teardownAllPanels) to prevent unbounded
+    /// dictionary growth. Must not be called for detach/move closes — the surface lives on elsewhere.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 204 - 211, The doc comment for
cleanupSurfaceState(surfaceId:) currently only lists
Workspace.splitTabBar(_:didCloseTab:fromPane:) as a caller but misses other call
sites; update the comment to enumerate all known callers (e.g.,
teardownAllPanels, splitTabBar(_:didClosePane:), the closePanel flow referenced
in Workspace.swift) or state that it’s called from multiple teardown/close flows
so readers don’t assume a single call site, keeping the existing bullet list of
cleaned-up state intact and mentioning why removal is necessary for each caller
category.
🤖 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/TerminalController.swift`:
- Around line 204-211: The doc comment for cleanupSurfaceState(surfaceId:)
currently only lists Workspace.splitTabBar(_:didCloseTab:fromPane:) as a caller
but misses other call sites; update the comment to enumerate all known callers
(e.g., teardownAllPanels, splitTabBar(_:didClosePane:), the closePanel flow
referenced in Workspace.swift) or state that it’s called from multiple
teardown/close flows so readers don’t assume a single call site, keeping the
existing bullet list of cleaned-up state intact and mentioning why removal is
necessary for each caller category.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a6bfe89-3bd0-4be9-a89f-5d620f110c88

📥 Commits

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

📒 Files selected for processing (2)
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
✅ Files skipped from review due to trivial changes (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