Repository navigation
macOS: retained Ghostty IOSurfaces after closing tabs/windows; possible SurfaceView retain cycle #13090
macOS: retained Ghostty IOSurfaces after closing tabs/windows; possible
|
| Category | Before closing tab | After closing tab and waiting > undo timeout |
|---|---|---|
| Process footprint | 38 GB | 38 GB |
IOSurface |
25 GB, 1545 regions | 25 GB, 1554 regions |
IOAccelerator (graphics) |
10 GB, 11470 regions | 10 GB, 11509 regions |
MALLOC_SMALL |
1245 MB | 1248 MB |
| App-specific tag 1 | 895 MB | 906 MB |
Grouping full vmmap output by Ghostty-named IOSurface entries:
| Owner/name | WindowServer state | Before | After close + wait |
|---|---|---|---|
ghostty |
unshared | 901 surfaces, ~15.3 GB resident | 903 surfaces, ~15.3 GB resident |
ghostty |
shared with WindowServer | 590 surfaces, ~10.0 GB resident | 591 surfaces, ~10.0 GB resident |
After closing one tab and waiting longer than the default undo-timeout, the Ghostty-named surface count did not decrease. It increased slightly.
The app model at that point reported:
windows=6, tabs=44, terminals=44
The rough steady-state expectation for visible terminal render targets would be much smaller than ~1494 Ghostty-named IOSurfaces. For example, with Metal triple buffering, 44 visible terminals suggests an order of magnitude near 132 swapchain textures before accounting for resizing/history/layer retention. I realize this is a simplification, but it is why the full vmmap surface count looked suspicious.
Representative full vmmap lines looked like:
IOSurface ... 1920x2079 (BGRA) 15.2M 'ghostty'
IOSurface ... 1920x2079 (BGRA) 15.2M 'ghostty', shared with WindowServer[...]
IOSurface ... 2056x2458 (BGRA) 19.5M 'ghostty'
IOSurface ... 3840x2458 (BGRA) 36.0M 'ghostty'
Concrete A/B test
I then tested a smaller controlled case using local debug builds:
- unpatched debug app preserved before patching;
- patched debug app from the same checkout;
- one window plus 12 additional tabs;
- then close the test window;
- capture
footprint,top, and fullvmmap; - count only lines that start with
IOSurface, containSurfaceID:, and contain'ghostty'.
The controlled result:
| Build | Scenario | After create Ghostty IOSurfaces | After close Ghostty IOSurfaces | After close footprint | After close threads |
|---|---|---|---|---|---|
| unpatched | one window + 12 tabs, then close | 39 / 64.6 MiB | 39 / 64.6 MiB | 582 MB | 59 |
| patched | same workload | 39 / 64.6 MiB | 0 / 0 MiB | 66 MB | 5 |
That 39 count matches 13 surfaces * 3 Metal buffers.
Primary excerpt:
== unpatched ==
after-create ghostty_iosurfaces=39 virtual_mib=64.6
after-close ghostty_iosurfaces=39 virtual_mib=64.6
== patched ==
after-create ghostty_iosurfaces=39 virtual_mib=64.6
after-close ghostty_iosurfaces=0 virtual_mib=0.0
Footprint excerpt:
unpatched after close:
PID COMMAND #TH MEM
<pid> ghostty 59 582M
phys_footprint: 582 MB
IOSurface: 65 MB
IOAccelerator (graphics): 378 MB
patched after close:
PID COMMAND #TH MEM
<pid> ghostty 5 66M
phys_footprint: 66 MB
IOSurface: 16 KB
IOAccelerator (graphics): 448 KB
I also ran a support pass at a larger window size:
== unpatched support run ==
after-create ghostty_iosurfaces=39 virtual_mib=257.2
after-close ghostty_iosurfaces=39 virtual_mib=257.2
== patched support run ==
after-create ghostty_iosurfaces=39 virtual_mib=257.2
after-close ghostty_iosurfaces=0 virtual_mib=0.0
Suspected retain cycle
The suspicious code is in:
macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Current shape:
accessibilitySelectionCancellable = NotificationCenter.default
.publisher(for: .ghosttySelectionDidChange, object: self)
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
NSAccessibility.post(element: self, notification: .selectedTextChanged)
}Even though the sink captures self weakly, the publisher is created with object: self, and the cancellable is stored on self. The plausible cycle is:
SurfaceView
-> accessibilitySelectionCancellable
-> NotificationCenter publisher/subscription
-> object self
-> SurfaceView
Tested patch
This is the patch shape I tested:
accessibilitySelectionCancellable = NotificationCenter.default
.publisher(for: .ghosttySelectionDidChange, object: nil)
.filter { [weak self] notification in
guard let self else { return false }
return (notification.object as AnyObject?) === self
}
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
NSAccessibility.post(element: self, notification: .selectedTextChanged)
}And I also explicitly cleared lifetime members in deinit:
accessibilitySelectionCancellable = nil
searchNeedleCancellable = nil
progressReportTimer?.invalidate()
progressReportTimer = nilBoth commands passed locally:
mise x zig@0.15.2 -- zig build test
mise x zig@0.15.2 -- zig buildCaveats
- The tested patch includes both the weak publisher-object change and explicit
deinitcleanup. I have not yet isolated which subset is minimally sufficient. - The GUI harness uses Ghostty's AppleScript support rather than a hermetic unit test. That seemed appropriate for reproducing AppKit/Metal teardown behavior, but it is not a unit test.
- I have not yet opened a PR because I wanted to check whether this lifecycle shape is intentional or whether there is already a preferred pattern for Combine subscriptions in the macOS app.
Proposed next step
The local A/B evidence points to SurfaceView being retained by its accessibilitySelectionCancellable, which keeps the native surface and Metal-backed IOSurfaces alive after window/tab teardown.
I can open a small PR that changes the NotificationCenter publisher to avoid storing a subscription whose publisher owns self, then add whatever focused lifecycle check is preferred for SurfaceView teardown. I would also reduce the patch to the minimum necessary change before opening it.
Replies: 2 comments
|
Great findings👍
I don't think so. I checked the memory graph myself, your claim is correct, feel free to open a pr, I'll vouch you. |
|
Lol I've been chasing this the past few days and was going insane. Wanted to point out that in addition to retaining Ghostty IOSurfaces, this can retain the surrounding terminal surface lifetime; in a local long-running process, closed tabs still had corresponding SurfaceView, Ghostty.Surface.surface, NSWindowTab, PTY, and login children alive after the undo timeout. |
Great findings👍
I don't think so.
I checked the memory graph myself, your claim is correct, feel free to open a pr, I'll vouch you.