Skip to content

fix: plug NotificationCenter observer accumulation and per-surface browser state leaks#2151

Closed
anthhub wants to merge 2 commits intomanaflow-ai:mainfrom
anthhub:fix/memory-leak-observer-and-surface-cleanup
Closed

fix: plug NotificationCenter observer accumulation and per-surface browser state leaks#2151
anthhub wants to merge 2 commits intomanaflow-ai:mainfrom
anthhub:fix/memory-leak-observer-and-surface-cleanup

Conversation

@anthhub
Copy link
Copy Markdown
Contributor

@anthhub anthhub commented Mar 25, 2026

Summary

Partial fix for #2078 — addresses Leak 1 and Leak 3 only. Leak 2 (AppDelegate observer imbalance) and Leak 4 (BrowserProfileStore eviction) are left for a follow-up PR.

Leak 1 — GhosttyApp NotificationCenter observers never removed

GhosttyApp registered two NSApplication activation observers via NotificationCenter.default.addObserver(forName:object:queue:) during initializeGhostty(), storing them in appObservers. The class had no deinit, so these block-based observers were never unregistered and the token objects were never released.

Fix: Add deinit to GhosttyApp that iterates appObservers, calls NotificationCenter.default.removeObserver on each token, then clears the array. While GhosttyApp.shared is a process-lifetime singleton, the deinit ensures observers are always balanced if the object is ever deallocated and makes the lifecycle intent explicit.

Leak 3 — TerminalController per-surface browser dictionaries never cleared

Six [UUID: …] dictionaries in TerminalController accumulate state keyed by browser-surface ID:

  • v2BrowserFrameSelectorBySurface
  • v2BrowserInitScriptsBySurface
  • v2BrowserInitStylesBySurface
  • v2BrowserDialogQueueBySurface
  • v2BrowserDownloadEventsBySurface
  • v2BrowserUnsupportedNetworkRequestsBySurface

Entries were written when surfaces were created but never removed when surfaces were destroyed, causing unbounded growth over sessions with many workspace/surface creation cycles.

Fix: Add cleanupBrowserSurface(surfaceId:) to TerminalController that calls removeValue(forKey:) on all six dictionaries. Invoke it from Workspace.splitTabBar(_:didCloseTab:fromPane:) — the canonical surface-destruction callback — alongside the existing per-surface cleanup block (e.g. panelDirectories, surfaceTTYNames, etc.).

Test plan

  • Build and launch cmux; verify no regression in terminal or browser surface behavior
  • Open and close many browser surfaces (repeated workspace create/destroy cycles); confirm memory footprint does not grow unboundedly
  • Reload configuration repeatedly; confirm no observer accumulation (instrument with Allocations profiler, filter on _NSObserverTarget)
  • Verify browser dialog, download-wait, and unsupported-request commands still function correctly after a surface-close/reopen cycle

🤖 Generated with Claude Code


Summary by cubic

Fixes two memory leaks by unregistering NotificationCenter observers and clearing per-surface browser state on tab close. Also makes surface_id handling strict by returning not_found when provided but unresolvable to prevent misrouted commands.

Written for commit 53832d5. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed memory leaks from stale observers when closing the application and browser tabs.
    • Enhanced error handling for invalid browser panel references with clearer error messages.

anthhub and others added 2 commits March 25, 2026 20:38
…nresolvable

Previously, v2SurfaceSendText, v2SurfaceSendKey, v2SurfaceClearHistory, and
v2SurfaceReadText would silently fall back to ws.focusedPanelId when a caller
supplied a surface_id that could not be resolved (e.g. a stale ref or an ordinal
whose mapping had not yet been registered). This caused two distinct bugs:

- manaflow-ai#2042: Commands like `cmux send --surface surface:9999` would succeed (exit 0)
  and deliver input to the focused pane instead of returning an error, making
  automation that targets specific surfaces unreliable.

- manaflow-ai#2045: When the fallback landed on a browser panel, the subsequent
  ws.terminalPanel(for:) check failed and returned "Surface is not a terminal",
  making valid terminal surfaces appear broken when addressed by ref.

The fix adds an explicit check: if params["surface_id"] is present but
v2UUID() returns nil (resolution failure), we immediately return a not_found
error instead of falling back to the focused pane. When surface_id is absent,
the existing focused-pane fallback is preserved for backward compatibility.

Fixes manaflow-ai#2042, Fixes manaflow-ai#2045

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…owser state leaks

Leak 1 (GhosttyApp): Add deinit to GhosttyApp that removes all entries in
appObservers via NotificationCenter.default.removeObserver, then clears the
array. Although GhosttyApp.shared is a process-lifetime singleton today, the
deinit ensures observers are balanced if the object is ever deallocated and
prevents the observers from holding strong references beyond the app's intent.

Leak 3 (TerminalController per-surface dictionaries): Add
cleanupBrowserSurface(surfaceId:) to TerminalController that removes the
per-surface entries from v2BrowserFrameSelectorBySurface,
v2BrowserInitScriptsBySurface, v2BrowserInitStylesBySurface,
v2BrowserDialogQueueBySurface, v2BrowserDownloadEventsBySurface, and
v2BrowserUnsupportedNetworkRequestsBySurface. Call this from
Workspace.splitTabBar(_:didCloseTab:fromPane:) alongside the existing
per-surface cleanup so all six dictionaries are pruned when a surface is
destroyed.

Partial fix for manaflow-ai#2078 (Leak 2 and Leak 4 addressed separately).

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

vercel Bot commented Mar 25, 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 25, 2026

📝 Walkthrough

Walkthrough

This pull request adds lifecycle cleanup and resource management to prevent stale state after component teardown. A deinit method removes cached NotificationCenter observers from GhosttyApp, a new cleanupBrowserSurface method in TerminalController removes per-surface browser state, and Workspace now invokes surface cleanup when browser tabs close. Additionally, V2 terminal-action handlers are updated to properly validate surface_id parameters and return errors for invalid values.

Changes

Cohort / File(s) Summary
Observer cleanup
Sources/GhosttyTerminalView.swift
Added deinit to GhosttyApp that removes all cached observers from NotificationCenter.default and clears appObservers to prevent stale notification callbacks.
Browser surface lifecycle
Sources/TerminalController.swift
Added @MainActor method cleanupBrowserSurface(surfaceId:) to remove cached per-surface browser state across six dictionaries. Updated V2 terminal-action handlers to treat surface_id as optional with explicit error handling: if provided but unparseable, return .err(code: "not_found", ...) instead of silently falling back to focused panel ID.
Panel closure cleanup
Sources/Workspace.swift
Invokes TerminalController.shared.cleanupBrowserSurface(...) when a browser tab's panel closes, ensuring explicit browser-surface state removal at panel teardown.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#1950: Adds NotificationCenter observer registration in GhosttyTerminalView; pairs with the new deinit cleanup logic for observer lifecycle management.
  • manaflow-ai/cmux#1395: Modifies the same Workspace.splitTabBar(_:didCloseTab:fromPane:) method with explicit-user-close tracking while this PR adds browser-surface cleanup in the same flow.
  • manaflow-ai/cmux#1346: Also modifies Workspace.splitTabBar(_:didCloseTab:fromPane:) close-path logic; both PRs enhance panel closure handling in sequence.

Poem

🐰 When browsers dance and panels fall away,
We clean their surfaces without delay,
Observers heed the deinit's gentle call,
No stale state left to haunt the halls! 🧹✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% 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
Title check ✅ Passed The title accurately describes the main changes: NotificationCenter observer removal and per-surface browser state cleanup to fix memory leaks.
Description check ✅ Passed The description covers the summary section with detailed leak explanations and fixes, but the testing section lacks completion (all items unchecked) and no demo video was provided.

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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 25, 2026

Greptile Summary

This PR fixes two of four identified memory leaks: GhosttyApp now correctly removes its NotificationCenter observers in deinit (Leak 1), and a new cleanupBrowserSurface method removes per-surface browser dictionaries from TerminalController when a tab is closed (Leak 3). Four API handlers also gain proper error handling when a caller-supplied surface_id string is malformed.

  • Leak 1 (GhosttyApp observers): deinit correctly iterates appObservers and calls removeObserver on each token — well-formed fix.
  • Leak 3 (browser surface dictionaries): cleanupBrowserSurface is only wired into splitTabBar(_:didCloseTab:fromPane:). The parallel splitTabBar(_:didClosePane:) callback (line 10326) also destroys surfaces — one panel per entry in closedPanelIds — but does not call cleanupBrowserSurface. Closing a split pane rather than an individual tab will continue to leak all six browser dictionaries for every panel in that pane.
  • surface_id validation: The new explicit error on malformed UUID (instead of silent fallback to focusedPanelId) is a correct behavioral improvement, though the 10-line pattern is copy-pasted four times and could be extracted into a helper.
  • Leaks 2 and 4 are intentionally deferred per the PR description.

Confidence Score: 3/5

  • Safe to merge the GhosttyApp observer fix and the surface_id improvements, but the browser-state leak via the pane-close path remains unaddressed and limits the effectiveness of Leak 3's fix.
  • The observer-removal deinit (Leak 1) is correct and low-risk. However, the Leak 3 fix is incomplete: cleanupBrowserSurface is only hooked into one of the two surface-destruction paths. splitTabBar(_:didClosePane:) — which fires whenever a split pane is closed — does the same per-panel cleanup loop but omits the new call, so browser surface state still accumulates on pane close. This is the primary user action that triggers multi-surface destruction and is likely the higher-volume path in practice. The fix is a one-liner to add, and without it the stated goal of bounding memory growth is only partially achieved.
  • Sources/Workspace.swift — the splitTabBar(_:didClosePane:) loop at line 10326 must also call TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId)

Important Files Changed

Filename Overview
Sources/Workspace.swift Adds cleanupBrowserSurface call in splitTabBar(_:didCloseTab:fromPane:) but the parallel splitTabBar(_:didClosePane:) path (lines 10326–10345) also destroys surfaces and was not updated — browser state will still leak on pane close.
Sources/TerminalController.swift Adds well-formed cleanupBrowserSurface method and improves surface_id validation in four API handlers to return an explicit error instead of silently falling back to the focused surface when a supplied UUID is malformed. Logic is correct; the pattern is repeated four times and could be extracted.
Sources/GhosttyTerminalView.swift Adds a deinit to GhosttyApp that removes all block-based NotificationCenter observers stored in appObservers. Correct and low-risk; the appObservers.removeAll() at the end of deinit is redundant but harmless.

Sequence Diagram

sequenceDiagram
    participant User
    participant Workspace
    participant BonsplitController
    participant TerminalController
    participant NotificationCenter

    Note over User,NotificationCenter: Surface close (tab path — FIXED)
    User->>Workspace: Close tab
    Workspace->>BonsplitController: splitTabBar(_:didCloseTab:fromPane:)
    Workspace->>Workspace: Remove panelDirectories, surfaceTTYNames, etc.
    Workspace->>TerminalController: cleanupBrowserSurface(surfaceId:) ✅
    TerminalController->>TerminalController: Remove all 6 v2Browser*BySurface entries

    Note over User,NotificationCenter: Surface close (pane path — STILL LEAKS)
    User->>Workspace: Close pane
    Workspace->>BonsplitController: splitTabBar(_:didClosePane:)
    loop for each panelId in closedPanelIds
        Workspace->>Workspace: Remove panelDirectories, surfaceTTYNames, etc.
        Note right of Workspace: ❌ cleanupBrowserSurface NOT called
    end

    Note over User,NotificationCenter: GhosttyApp deinit (Leak 1 — FIXED)
    TerminalController-->>GhosttyApp: (process exit / dealloc)
    GhosttyApp->>NotificationCenter: removeObserver (didBecomeActive) ✅
    GhosttyApp->>NotificationCenter: removeObserver (didResignActive) ✅
Loading

Comments Outside Diff (2)

  1. Sources/TerminalController.swift, line 5293-5306 (link)

    P2 Repeated surface_id resolution pattern — consider extracting a helper

    The same 10-line surface_id validation block is copy-pasted verbatim into four separate functions (v2SurfaceSendText, v2SurfaceSendKey, v2SurfaceClearHistory, v2SurfaceReadText). Extracting it into a small helper would eliminate the duplication and make future changes (error messages, fallback logic) apply consistently across all callers:

    /// Resolves a surface UUID from params, or falls back to the workspace's focused panel.
    /// Returns nil and sets `result` to an error if `surface_id` is present but malformed.
    private func v2ResolveSurfaceId(
        params: [String: Any],
        workspace ws: Workspace,
        result: inout V2CallResult
    ) -> UUID? {
        if params["surface_id"] != nil {
            guard let id = v2UUID(params, "surface_id") else {
                result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil)
                return nil
            }
            return id
        }
        return ws.focusedPanelId
    }

    This is a style suggestion — the copy-paste is functionally correct as written.

    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!

  2. Sources/Workspace.swift, line 10326-10345 (link)

    P1 Browser state not cleaned up on pane close

    splitTabBar(_:didClosePane:) is a second surface-destruction path that iterates over closedPanelIds and clears all the same per-panel dictionaries (panelDirectories, surfaceTTYNames, etc.), but does not call cleanupBrowserSurface. When the user closes a split pane rather than an individual tab, every browser surface inside that pane will still accumulate stale entries in all six v2Browser*BySurface dictionaries — exactly the leak this PR intends to fix.

    The fix should mirror what was done at line 10200 in splitTabBar(_:didCloseTab:fromPane:): add a call to TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId) inside the for panelId in closedPanelIds loop here.

    Without this, Leak 3 remains fully present whenever a pane (not just a tab) is closed.

Reviews (1): Last reviewed commit: "fix: plug NotificationCenter observer ac..." | Re-trigger Greptile

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: 2

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

5293-5302: Extract shared surface_id resolution into one helper to prevent drift.

The same parse/validate/fallback block is repeated four times. A single helper will keep behavior and error text consistent.

♻️ Refactor sketch
+private func v2ResolveExplicitOrFocusedSurfaceId(
+    params: [String: Any],
+    workspace: Workspace
+) -> Result<UUID, V2CallResult> {
+    if params["surface_id"] != nil {
+        guard let surfaceId = v2UUID(params, "surface_id") else {
+            return .failure(.err(
+                code: "not_found",
+                message: "Surface not found for the given surface_id",
+                data: nil
+            ))
+        }
+        return .success(surfaceId)
+    }
+    guard let focused = workspace.focusedPanelId else {
+        return .failure(.err(code: "not_found", message: "No focused surface", data: nil))
+    }
+    return .success(focused)
+}

Also applies to: 5353-5362, 5396-5405, 5456-5465

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

In `@Sources/TerminalController.swift` around lines 5293 - 5302, Extract the
repeated parse/validate/fallback logic for surface_id into a single helper
(e.g., a private method on TerminalController like resolveSurfaceId(params:
[String: Any], ws: WebSocket) -> Result<UUID, ErrorResult> or optional UUID with
an error-out pattern) and replace the four duplicated blocks that set surfaceId
(currently using v2UUID(params, "surface_id"), ws.focusedPanelId, and producing
result = .err(code: "not_found", message: "Surface not found for the given
surface_id", data: nil)) with calls to that helper; the helper should: check
params["surface_id"], call v2UUID(params, "surface_id") when present and return
the same not_found error when v2UUID yields nil, or return ws.focusedPanelId
when no param was provided, preserving the exact error text and behavior.
🤖 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 222-229: The cleanupBrowserSurface function currently removes many
per-surface caches but omits v2BrowserElementRefs; update
cleanupBrowserSurface(surfaceId: UUID) to also remove the entry from
v2BrowserElementRefs by calling v2BrowserElementRefs.removeValue(forKey:
surfaceId) so stale element references are pruned when a surface is cleaned up.

In `@Sources/Workspace.swift`:
- Line 10200: The pane-close flow currently bypasses the didCloseTab path and
therefore doesn't call
TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId), leaving
browser-surface resources leaked; update the pane-close handling (the method
that closes panes—look for the pane-close handler / function that removes
panels) to invoke TerminalController.shared.cleanupBrowserSurface(surfaceId:
panelId) for any panelId/ surfaceId being removed (mirror the call made in
didCloseTab), ensuring cleanup runs both on tab-close and pane-close code paths.

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Around line 5293-5302: Extract the repeated parse/validate/fallback logic for
surface_id into a single helper (e.g., a private method on TerminalController
like resolveSurfaceId(params: [String: Any], ws: WebSocket) -> Result<UUID,
ErrorResult> or optional UUID with an error-out pattern) and replace the four
duplicated blocks that set surfaceId (currently using v2UUID(params,
"surface_id"), ws.focusedPanelId, and producing result = .err(code: "not_found",
message: "Surface not found for the given surface_id", data: nil)) with calls to
that helper; the helper should: check params["surface_id"], call v2UUID(params,
"surface_id") when present and return the same not_found error when v2UUID
yields nil, or return ws.focusedPanelId when no param was provided, preserving
the exact error text and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 067b8546-8cd2-437a-80f8-7a55e990005a

📥 Commits

Reviewing files that changed from the base of the PR and between 99ca3c9 and 53832d5.

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

Comment on lines +222 to +229
func cleanupBrowserSurface(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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prune stale browser element refs during surface cleanup.

Line 222 clears most per-surface browser caches, but v2BrowserElementRefs is left behind. That can accumulate stale entries across repeated browser surface churn.

🧹 Proposed fix
 `@MainActor`
 func cleanupBrowserSurface(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)
+    v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func cleanupBrowserSurface(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)
}
func cleanupBrowserSurface(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)
v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 222 - 229, The
cleanupBrowserSurface function currently removes many per-surface caches but
omits v2BrowserElementRefs; update cleanupBrowserSurface(surfaceId: UUID) to
also remove the entry from v2BrowserElementRefs by calling
v2BrowserElementRefs.removeValue(forKey: surfaceId) so stale element references
are pruned when a surface is cleaned up.

Comment thread Sources/Workspace.swift
}
clearRemoteConfigurationIfWorkspaceBecameLocal()
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply browser-surface cache cleanup in pane-close flow as well

Line 10200 fixes tab-close cleanup, but pane-close cleanup can bypass didCloseTab and currently does not call cleanupBrowserSurface(surfaceId:). That leaves a remaining leak path for browser panels closed via pane close.

💡 Proposed fix
 func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
@@
         if !closedPanelIds.isEmpty {
             for panelId in closedPanelIds {
+                TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId)
                 panels[panelId]?.close()
                 panels.removeValue(forKey: panelId)
                 untrackRemoteTerminalSurface(panelId)
                 pendingRemoteTerminalChildExitSurfaceIds.remove(panelId)
                 panelDirectories.removeValue(forKey: panelId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` at line 10200, The pane-close flow currently
bypasses the didCloseTab path and therefore doesn't call
TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId), leaving
browser-surface resources leaked; update the pane-close handling (the method
that closes panes—look for the pane-close handler / function that removes
panels) to invoke TerminalController.shared.cleanupBrowserSurface(surfaceId:
panelId) for any panelId/ surfaceId being removed (mirror the call made in
didCloseTab), ensuring cleanup runs both on tab-close and pane-close code paths.

@anthhub
Copy link
Copy Markdown
Contributor Author

anthhub commented Mar 25, 2026

Closing this PR — the Leak 1 fix (deinit on a static singleton) is ineffective since GhosttyApp is static let shared and will never be deinitialized. The correct fix should clear observers at the start of reloadConfiguration() instead. The PR also inadvertently includes unrelated surface-ref changes from #2150. Will re-submit with a correct approach.

@anthhub anthhub closed this Mar 25, 2026
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 3 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:228">
P2: cleanupBrowserSurface clears per-surface dictionaries but leaves v2BrowserElementRefs entries tied to the surfaceId, so element refs allocated for a closed browser surface are never released and can grow unbounded across create/close cycles.</violation>
</file>

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

<violation number="1" location="Sources/Workspace.swift:10200">
P1: Apply `cleanupBrowserSurface(surfaceId:)` in the pane-close flow as well. Surfaces closed through `didClosePane` can currently skip browser cache teardown and retain per-surface state.</violation>
</file>

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

Comment thread Sources/Workspace.swift
}
clearRemoteConfigurationIfWorkspaceBecameLocal()
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Apply cleanupBrowserSurface(surfaceId:) in the pane-close flow as well. Surfaces closed through didClosePane can currently skip browser cache teardown and retain per-surface state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 10200:

<comment>Apply `cleanupBrowserSurface(surfaceId:)` in the pane-close flow as well. Surfaces closed through `didClosePane` can currently skip browser cache teardown and retain per-surface state.</comment>

<file context>
@@ -10197,6 +10197,7 @@ extension Workspace: BonsplitDelegate {
         }
         clearRemoteConfigurationIfWorkspaceBecameLocal()
         AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
+        TerminalController.shared.cleanupBrowserSurface(surfaceId: panelId)
 
         // Keep the workspace invariant for normal close paths.
</file context>
Fix with Cubic

v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId)
v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId)
v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId)
v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: cleanupBrowserSurface clears per-surface dictionaries but leaves v2BrowserElementRefs entries tied to the surfaceId, so element refs allocated for a closed browser surface are never released and can grow unbounded across create/close cycles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalController.swift, line 228:

<comment>cleanupBrowserSurface clears per-surface dictionaries but leaves v2BrowserElementRefs entries tied to the surfaceId, so element refs allocated for a closed browser surface are never released and can grow unbounded across create/close cycles.</comment>

<file context>
@@ -216,6 +216,18 @@ class TerminalController {
+        v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId)
+        v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId)
+        v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId)
+        v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId)
+    }
+
</file context>
Suggested change
v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId)
v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId)
v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId }
Fix with Cubic

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.

send/send-key/read-screen silently fall back to focused pane when given a non-existent surface handle

1 participant