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

Author: l5zenfCreated Sep 17, 2026Updated Sep 17, 2026

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

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 from MetalRenderer::new_internal unit vertices.

Source: longbridge/gpui-component