#3107

macOS: AccessKit ownership cycle retains GPUIView and Metal renderer after NSWindow destruction

Openl5zenfopened 1mo ago

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_adapter

MacWindow::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 snapshot d89e9c2124b2786a390c7a451c7488601b4da2e1), also reproduced with gpui-pre 0.3.1 (Zed 801c087af22dd189dc1aa49e2f370b4f04190b19); accesskit_macos 0.26.3. Control: official gpui 0.2.2 (not affected)
  • GPUI Component: gpui-kit 0.6.0 and 0.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

  1. Build a minimal harness with no application code. Each cycle opens a 128×128 pt window whose root is a component Root containing either (A) an empty div, or (B) a full-size img(<fixed path>) div showing one 1024×1024 px PNG. The image path and bytes are identical every cycle.
  2. 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::defer from an AsyncApp task. Do not nest a second handle.update for the same window inside AsyncWindowContext::update.
  3. 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 NSWindow deallocs before the next cycle's first Metal submission.
  4. Instrument GPUIView::dealloc, MetalRenderer::drop, MetalAtlas::drop, MacWindow::drop and NSWindow::dealloc, then take heap/vmmap snapshots at the zero-window endpoint.
  5. 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):

Metriccomponent 0.5.1 + gpui 0.2.2Kit 0.6.0 + gpui-pre 0.3.5Kit 0.6.1 + gpui-pre 0.3.5
NSWindow::dealloc105/105105/105105/105
GPUIView::dealloc105/1050/1050/105
MetalRenderer::drop105/1050/1050/105
MetalAtlas::drop105/1050/1050/105
image atlas texture drop105/1050/1050/105
live GPUIView, start→end0→05→1055→105
live AccessKitSubclassOfNSView0→05→1055→105
live CAMetalLayer0→05→1055→105
live Metal command queue0→05→1055→105
live AGXG13XFamilyTexture0→024→50925→487
physical footprint, MiB34.9→36.165.2→658.367.9→654.6
graphics VM (virtual)2432→2432 KiB25.6→434.0 MiB25.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 from MetalRenderer::new_internal unit vertices.

Activity

2
callum-gander1mo ago

also experiencing this issue, if it's as straightforward a fix as you say, any reason why you don't open a PR? If not, I'm happy to

huacnlee1mo ago

The AccessKit ownership cycle has an upstream fix: zed-industries/zed#64143, commit 0eda7703f6c88aa08a25c1d2105ff1ca46f775d4. MacWindow::drop now takes and drops accesskit_adapter before destroying the renderer.

That code is present in the gpui-pre 0.3.7 snapshot, which Kit adopted in #3283 and ships in v0.7.0. Please rerun the open/close harness on v0.7.0 and check native view/renderer destruction counts. This confirms the source fix is included; the original native leak measurements have not been rerun here.