fix: clean up per-surface state on tab close to prevent unbounded memory growth#2192
fix: clean up per-surface state on tab close to prevent unbounded memory growth#2192anthhub wants to merge 4 commits intomanaflow-ai:mainfrom
Conversation
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>
|
@anthhub is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdded Changes
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
There was a problem hiding this comment.
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 | 🟠 MajorGate browser-state cleanup to true destruction paths and cover pane-close closures
cleanupBrowserSurfaceStateis unconditional at Line 10301, but detached tabs (isDetachingat 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), anddidClosePanecurrently 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
📒 Files selected for processing (2)
Sources/TerminalController.swiftSources/Workspace.swift
Greptile SummaryThis PR fixes a memory leak (issue #2078 Leak 3) by adding Confidence Score: 3/5Not safe to merge as-is; the missing The core memory-leak fix is correct and well-structured, but calling Sources/Workspace.swift — the unconditional call to Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "fix: clean up per-surface browser dictio..." | Re-trigger Greptile |
Sources/Workspace.swift
Outdated
| Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) | ||
| } | ||
| AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) | ||
| TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId) |
There was a problem hiding this comment.
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.
| TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId) | |
| if !isDetaching { | |
| TerminalController.shared.cleanupBrowserSurfaceState(surfaceId: panelId) | |
| } |
Sources/TerminalController.swift
Outdated
| 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) | ||
| } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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).
| @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!
There was a problem hiding this comment.
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>
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>
There was a problem hiding this comment.
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
… and pane-close paths
Problem
TerminalControlleraccumulates per-surface state that is never cleaned up when a surface is destroyed:v2BrowserFrameSelectorBySurface,v2BrowserInitScriptsBySurface,v2BrowserInitStylesBySurface,v2BrowserDialogQueueBySurface,v2BrowserDownloadEventsBySurface,v2BrowserUnsupportedNetworkRequestsBySurface) — written during browser/socket interactions, never removedv2BrowserElementRefs— element references keyed by surface ID, never pruned after surface close (stale refs can never be resolved anyway per the guard at L6899)v2RefByUUID[.surface]/v2UUIDByRef[.surface]— handle mappings written on creation, only added-to byv2RefreshKnownRefs()but never removedOver long sessions with many workspaces created and closed, these dictionaries grow without bound.
Fix
Sources/TerminalController.swift: AddcleanupSurfaceState(surfaceId:)that clears all 8 categories of per-surface data in one call.Sources/Workspace.swift: CallcleanupSurfaceStatefromsplitTabBar(_:didCloseTab:fromPane:)alongside the existing panel dictionary cleanup.Note on Leak 1 from #2078
The reported observer accumulation in
GhosttyAppdoes not exist —appObservers.appendruns only insideinitializeGhostty(), which is called once fromprivate init(). SinceGhosttyAppisstatic let shared, the observer count is always exactly 2.Test plan
Partially addresses #2078
Summary by CodeRabbit