macOS: AccessKit ownership cycle retains GPUIView and Metal renderer after NSWindow destruction
Description
Opening and closing a minimal GPUI window repeatedly leaves the native content
view (GPUIView), MacWindowState, MetalRenderer, CAMetalLayer, the Metal
command queue and renderer-owned textures alive. Rendering the same image every
cycle additionally retains one atlas texture per window.
This is not a collection of still-open windows: the GPUI window registry is
empty, old window handles no longer resolve, root entities are dropped, and the
original NSWindow deallocates (105/105) before the next cycle. What stays
behind are the GPUI-side objects.
The retained objects form an ownership cycle introduced with the AccessKit integration in the gpui-pre snapshots:
MacWindowState.accesskit_adapter
[gpui-window-leak-repro-source.tar.gz](https://github.com/user-attachments/files/32317880/gpui-window-leak-repro-source.tar.gz)
→ SubclassingAdapter (retains window.contentView())
→ GPUIView (subview of the content view)
→ GPUIView.windowState ivar holds Arc<Mutex<MacWindowState>>
→ back to MacWindowState.accesskit_adapterMacWindow::open stores a cloned raw Arc in the GPUIView ivar; it is
released by dealloc_view, which cannot run while the content view keeps the
subview retained. a11y_init stores SubclassingAdapter::for_window(...) in
the same state, and closing the NSWindow never clears the adapter. Official
gpui 0.2.2 has no such adapter chain in its macOS window code and releases
everything (control below).
A release matrix with real component initialization (init() + one component
Root per window, no business code, no teardown fix) draws a clean boundary:
gpui-component 0.5.1 + official gpui 0.2.2 releases 105/105 renderers and
atlases, while both gpui-kit 0.6.0 and 0.6.1 (resolving to gpui-pre 0.3.5,
Zed snapshot d89e9c2…) retain 100 additional live views/layers/queues over
100 image cycles. An engine-only matrix additionally shows gpui-pre 0.3.1
(Zed snapshot 801c087…) still retains, so downgrading within gpui-pre 0.3.x
does not help. This report covers those published snapshots; it is not a claim
about current HEAD.
Environment
- GPUI:
gpui-pre 0.3.5(Zed snapshotd89e9c2124b2786a390c7a451c7488601b4da2e1), also reproduced withgpui-pre 0.3.1(Zed801c087af22dd189dc1aa49e2f370b4f04190b19);accesskit_macos 0.26.3. Control: officialgpui 0.2.2(not affected) - GPUI Component:
gpui-kit 0.6.0and0.6.1(kit/component/base/assets locked to the matching 0.6.x). Control:gpui-component 0.5.1(old release line, different crate name; not affected) - Platform: macOS 27.0 (26A428), Apple M1 Pro , Rust 1.98.0
Steps to Reproduce
- Build a minimal harness with no application code. Each cycle opens a
128×128 pt window whose root is a component
Rootcontaining either (A) an empty div, or (B) a full-sizeimg(<fixed path>)div showing one 1024×1024 px PNG. The image path and bytes are identical every cycle. - For each window, wait until its Metal command buffer completes successfully
and contains the polychrome sprite, wait 100 ms, then remove the window via
App::deferfrom anAsyncApptask. Do not nest a secondhandle.updatefor the same window insideAsyncWindowContext::update. - Run 5 warmup + 100 measured cycles per group. Assert per cycle: 0 windows
before creation and 1 after; the old handle no longer resolves and the root
entity is dropped after removal; the old
NSWindowdeallocs before the next cycle's first Metal submission. - Instrument
GPUIView::dealloc,MetalRenderer::drop,MetalAtlas::drop,MacWindow::dropandNSWindow::dealloc, then take heap/vmmap snapshots at the zero-window endpoint. - A self-contained harness (plus full reports and lockfiles) is in the
attached
gpui-window-leak-repro-source.tar.gz— [attach the archive here when submitting].
Screenshots
Not applicable — the windows really do close, so nothing is visible on screen. The evidence is live object counts and VM metrics (below).
Expected
Once the native window closes and in-flight GPU work finishes, the native views, window state, renderer and window-scoped atlas resources (including the image texture) should be released. A bounded device-wide buffer pool would be acceptable. Live object counts should stay flat while windows are cycled.
Actual
In 0.6.x the NSWindow always deallocates, but the GPUI-side teardown never
happens. Image mode, 5 warmup + 100 measured cycles (destructor counts include
warmup, hence /105):
| Metric | component 0.5.1 + gpui 0.2.2 | Kit 0.6.0 + gpui-pre 0.3.5 | Kit 0.6.1 + gpui-pre 0.3.5 |
|---|---|---|---|
NSWindow::dealloc |
105/105 | 105/105 | 105/105 |
GPUIView::dealloc |
105/105 | 0/105 | 0/105 |
MetalRenderer::drop |
105/105 | 0/105 | 0/105 |
MetalAtlas::drop |
105/105 | 0/105 | 0/105 |
| image atlas texture drop | 105/105 | 0/105 | 0/105 |
live GPUIView, start→end |
0→0 | 5→105 | 5→105 |
live AccessKitSubclassOfNSView |
0→0 | 5→105 | 5→105 |
live CAMetalLayer |
0→0 | 5→105 | 5→105 |
| live Metal command queue | 0→0 | 5→105 | 5→105 |
live AGXG13XFamilyTexture |
0→0 | 24→509 | 25→487 |
| physical footprint, MiB | 34.9→36.1 | 65.2→658.3 | 67.9→654.6 |
| graphics VM (virtual) | 2432→2432 KiB | 25.6→434.0 MiB | 25.6→434.0 MiB |
MallocStackLogging + malloc_history attributes the 105 live image textures
to:
Img::paint → Window::paint_image → MetalAtlas::get_or_insert_with
→ MetalAtlasState::allocate → push_texture → DeviceRef::new_texture
→ -[AGXG13XFamilyDevice newTextureWithDescriptor:]Two notes to keep the interpretation honest:
- Raw RSS is a poor metric here (compression/swap): per-cycle RSS growth over the same runs was +0.0145 MiB (0.5.1), +0.8259 (0.6.0) and +0.4134 (0.6.1); 0.5.1 also passed two additional independent 100-cycle image reruns (+0.005…+0.0147 MiB/cycle, 105/105 releases). We do not claim a stable ~4–5 MB/cycle RSS slope — the retained live objects above are the finding.
- The growth is not "one leaked 4 MiB instance buffer per window": only one
live allocation originates from
InstanceBufferPool::acquire(the tested snapshot defaults the pool to 2 MiB); the per-window buffers instead come fromMetalRenderer::new_internalunit vertices.
Source: longbridge/gpui-component