Skip to content

Fix several MacOS memory management bugs#31769

Open
iccir wants to merge 8 commits into
matplotlib:mainfrom
iccir:main
Open

Fix several MacOS memory management bugs#31769
iccir wants to merge 8 commits into
matplotlib:mainfrom
iccir:main

Conversation

@iccir
Copy link
Copy Markdown

@iccir iccir commented May 28, 2026

PR summary

This pull request fixes several memory management issues in _macosx.m. It allows for windows to be properly dealloc'd, which should fix #31106.

I realize that I'm a stranger and first-time contributor, please feel free to push back and be honest! I use to do these types of object ownership audits when I worked at Apple, but it's been almost 15 years since I've looked at manual-reference-counted code :)

Autorelease Pools

One of the root causes of #31106 is that each NSWindow allocated in FigureManager_new() was added to an NSAutoreleasePool that wasn't drained by the time -[NSApplication run] was called. macOS performed several -retain/-autorelease pairs on the windows. By the time our show() function was called, the retain count of each windows was over 600 (See #31751).

Pools at Boundaries

Although messy, a good guideline is to setup an autorelease pool each time you cross from the Python layer into the Objective-C layer. This prevents objects from going into a pool that you have no control over, and may never be drained.

I have added @autoreleasepool blocks to every static function exposed to Python. Some of these aren't needed since there are no Obj-C calls (event_loop_is_running(), Timer_dealloc(), etc). I can removed the unneeded ones; however, I felt like it was better to be consistent and guard against the possibility of a future contributor using Obj-C from event_loop_is_running()/etc and then re-introducing this issue.

I followed the existing coding style and indented each @autoreleasepool block. This resulted in a messy diff due to the number of lines changed. I could instead use preprocessor macros similar to Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS without indentation. Perhaps something like BEGIN_OBJC_CALLS?

Pools in Loops

When a loop can iterate indefinitely, it's a good idea to create a pool for each iteration. I did so in FigureCanvas__start_event_loop() and flushEvents(). There existed a possibility that the call to -sendEvent: could add additional events to the event queue and thus spin the loop indefinitely without pool drainage.

Pools before -run

The structure of show() and wait_for_stdin() is a bit different. Here, we need to use two pools. The first pool will catch the autoreleased value of -[NSApp windows] and will be drained prior to the call to [NSApp run]. Without two pools, the windows will have a +1 retain count and will be waiting to be drained while [NSApp run] is running.

NSTrackingArea Leak

#31754, also fixed by PR #31661 . I can back this out if desired.

The NSTrackingArea created in FigureCanvas_init() was leaking. The +alloc/-init needs a balancing -release.

Technically, NSTrackingArea.owner was an unsafe assign reference until macOS 10.13 when it was turned into a safer weak reference. There now exists the possibility of a crash on macOS 10.12 if the owning NSView is deallocated before the NSTrackingArea. I'm not sure if this is worth addressing, however. I can fix it if desired.

wake_on_fd_write leaks

#31755

There are two leaks in wake_on_fd_write():

  1. The NSFileHandle is never -releaseed.
  2. The notification object returned from -addObserverForName:object:queue:usingBlock: is retained by the system and not released until -removeObserver:.

I fixed both of these by adding cleanup code to the end of the block.

MatplotlibAppDelegate

With pools in place and draining, the MatplotlibAppDelegate object created in lazy_init() was short-lived. As NSApplication.delegate is a weak property, there was no strong reference to it.

Per ARC conventions, I made a new static variable to point to the object. While this isn't necessary under manually reference counting, it's typically used to indicate that an +alloc/-init without a balanced -release is intentional. It also saves a step if a migration to ARC is ever desired.

isReleasedWhenClosed

Setting isReleasedWhenClosed to NO has been the standard for programmatically-created windows since the introduction of ARC in 2011. Parts of AppKit, such as NSWindowController, will automatically call [theWindow setReleasedWhenClosed:NO] on adopted windows.

While the existing code should correctly release the window per historic API documentation, I'm not sure what kind of test coverage isReleasedWhenClosed=YES windows have inside of Apple, as the recommendation is to use NSWindowController.

I changed the window to isReleasedWhenClosed=NO and modified the calls to -close as follows:

    [self->window close];
    [self->window setDelegate:nil];
    [self->window release];

The setDelegate:nil call is needed as NSWindow.delegate didn't become weak until macOS 10.13. I believe that matplotlib officially still supports 10.12.

Testing

Checking for Over-releases

Historically, fixing leaks in one section of code can expose over-releases/crashes in other sections. Prior to this PR, we are "leaking" several Objective-C objects due to them going into a pool that is never drained.

To mitigate this, I ran several built-in examples with permutations of the following environmental variables:

NSZombieEnabled=YES
NSDeallocateZombies=NO
NSAutoreleaseFreedObjectCheckEnabled=YES
MallocHelp=YES
MallocCheckHeapEach=100000
MallocCheckHeapStart=100000
MallocScribble=YES
MallocGuardEdges=YES
MallocCheckHeapAbort=1

I also checked using Instruments with the Zombies template.

That said, the amount of "leaked" objects was large, so this PR may expose existing over-releases as a crash.

Checking for Leaks

I ran the following example and then attached with Instruments with the Allocations template.

import matplotlib.pyplot as plt
import os
import gc

input(f"PID: {os.getpid()}\nPress Enter to continue...")

while True:
    gc.collect()
    fig, ax = plt.subplots()
    ax.bar(['moo', 'foo'], [157, 42])
    plt.show()

I performed several iterations of clicking "Mark Generation" and then closing the window. I proceeded to let the loop run 20+ more times in order to let Python and malloc clear/recycle any memory regions. I found three leaks (the NSTrackingArea in FigureCanvas_init() and the two in wake_on_fd_write()).

AI Disclosure

  • I used AI to search through historic macOS header files and determine when various properties moved from assign to weak. Apple doesn't make this information easily accessible.
  • I also had AI proofread this markdown document.
  • No code was modified by AI, nor did I use any code suggested by AI.

PR checklist

Note

I ran pytest and got:
8858 passed, 1555 skipped, 28 xfailed, 2 xpassed

As a first time contributor, I'm not sure how concerned I should be with "28 xfailed". I don't believe my change could affect those tests.

iccir added 8 commits May 26, 2026 11:42
Before calling [NSApp run], we need to drain the previous pool as the windows have been added to it during enumeration.
There exists a (likely rare) scenario in which new NSEvents can be added to the queue indefinitely, and thus the outermost pool will never be drained. Hence, create a new pool and drain it once per iteration.
Now that we are setting up and draining pools, our MatplotlibAppDelegate is short lived, as NSApplication.delegate is a weak reference.
The alloc/init of the NSFileHandle needs a -release

The object returned by -addObserverForName:object:queue:usingBlock: is retained by the system and needs a corresponding -removeObserver
The call to -[NSApp windows] needs to be inside of an @autoreleasepool block, else there exists the possibility that the windows array will go into a lower pool that won't be drained.
Using setReleasedWhenClosed:NO has been the standard for programmatically-created windows since the introduction of ARC.

Also add calls to setDelegate:nil as NSWindow.delegate was assign until macOS 10.13.
@github-actions
Copy link
Copy Markdown

Thank you for opening your first PR into Matplotlib!

If you have not heard from us in a week or so, please leave a new comment below and that should bring it to our attention. Most of our reviewers are volunteers and sometimes things fall through the cracks. We also ask that you please finish addressing any review comments on this PR and wait for it to be merged (or closed) before opening a new one, as it can be a valuable learning experience to go through the review process.

You can also join us on discourse chat for real-time discussion.

For details on testing, writing docs, and our review process, please see the developer guide.
Please let us know if (and how) you use AI, it will help us give you better feedback on your PR.

We strive to be a welcoming and open project. Please follow our Code of Conduct.

@scottshambaugh scottshambaugh changed the title Closes #31106, #31754, #31755 Fix several MacOS memory management bugs May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

1 participant