Skip to content

Convert macOS backend to ARC#31855

Open
iccir wants to merge 3 commits into
matplotlib:mainfrom
iccir:arc
Open

Convert macOS backend to ARC#31855
iccir wants to merge 3 commits into
matplotlib:mainfrom
iccir:arc

Conversation

@iccir

@iccir iccir commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

PR summary

This pull request enables Automatic Reference Counting for the macOS backend.

Closes #31797, #31798, and #29076

I'm still testing this PR on macOS 10.12, macOS 14, and macOS 26. I wanted to start the PR process early so that others could review my general direction and give opinions.

Note

I'm trying to follow existing coding styles already present in _macosx.m. However, this pull request converts some Objective-C instance variables (ivars) to Objective-C properties, which adds an underscore to the ivar name.

I'd like to convert more of these in the future, especially in View where there is a @public ivar (ivar scoping attributes like @public haven't been used for a very long time).

If there are any concerns, or if I'm overstepping, please let me know!

ARC C Struct Compatibility

As we support compiling on macOS 10.12, we cannot use ARC objects within C structures. Support for this was added in Xcode 10 / macOS 10.14. See WWDC 2018 Session 409 for more information about this:
Archived Video (Direct 1.3GB download)
Archived Slides

As a workaround, I added a new macro called STORE_OBJC_OBJECT which manually performs reference counting when storing into structs allocated with tp_alloc.

The basic pattern for dealing with Python/Obj-C objects and using this macro is as follows:

typedef struct {
    PyObject_HEAD
    __unsafe_unretained MyObjCObject* myObjCObject;
} MyPythonObject;


static PyObject*
MyPythonObject_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    BEGIN_OBJC_ENTRY

    MyPythonObject *self = (MyPythonObject*)type->tp_alloc(type, 0);
    return (PyObject*)self;

    END_OBJC_ENTRY
    return NULL;
}

static PyObject*
MyPythonObject_init(MyPythonObject *self, PyObject *args, PyObject *kwds)
{
    BEGIN_OBJC_ENTRY

    MyObjCObject *myObjCObject = [[MyObjCObject alloc] init];
    [myObjCObject setPythonObject:self]; // See below section, back-pointer
    STORE_OBJC_OBJECT(self->myObjCObject, myObjCObject);

    END_OBJC_ENTRY
    return 0;
}

static void
MyPythonObject_dealloc(MyPythonObject *self)
{
    BEGIN_OBJC_ENTRY

    [self->myObjCObject setPythonObject:NULL]; // Clear back-pointer
    STORE_OBJC_OBJECT(self->myObjCObject, nil);

    END_OBJC_ENTRY
    Py_TYPE(self)->tp_free((PyObject*)self);
}

If, someday, our minimum target for building matplotlib becomes macOS 10.14 / Xcode 10, then the following changes would occur:

typedef struct {
    PyObject_HEAD
    __strong MyObjCObject* myObjCObject; // Now an ARC'd strong pointer
} MyPythonObject;


static PyObject*
MyPythonObject_init(MyPythonObject *self, PyObject *args, PyObject *kwds)
{
    …
    self->myObjCObject = myObjCObject; // Can assign directly!
    …
}

static void
MyPythonObject_dealloc(MyPythonObject *self)
{
    …
    self->myObjCObject = nil; // Needs to be nil before tp_free()
    …
}

This assumes that tp_alloc() is using PyType_GenericAlloc, which zero-fills allocated memory.

Paired +alloc and -init

This PR correctly pairs each call to +alloc with an immediate call to -init…. See #31798 for more information on this.

Ownership Overview

Based on my previous experience working with language bridging, I think the following ownership structure makes sense:

  1. Each PyObject should own an Obj-C object via a strong reference.

  2. The Obj-C object, if needed, should have a weakly-assigned back-pointer to the PyObject. This back-pointer should be set in Foo_init and must be cleared in Foo_dealloc.

  3. In modern Objective-C, you would use a readwrite + assign @property for the back-pointer variable:

    @property (nonatomic, readwrite, assign) PyObject *myPythonObject;
    or simply:
    @property (nonatomic) PyObject *myPythonObject;

    This will automatically create a backing _myPythonObject ivar as well as a -setMyPythonObject: method.

FigureManager and Window

As mentioned in my last PR, FigureManager and Window strongly reference each other. I believe this is for historical reasons, likely to prevent a crash.

I made Window weakly-reference FigureManager to follow the pattern established in other objects and haven't experienced any issues. That said, I'm still wrapping my head around the interactions between Python and the main NSRunLoop.

I changed Window's raw manager ivar to a property. This synthesized a setManager: method and eliminated the need for declaring our own -init… method.

There is a new FigureManager__closeAndClearWindow() method called from FigureManager_destroy and FigureManager_dealloc. It needs to be called from both since Windows are fairly heavyweight objects and we should probably release the memory as soon as they are closed. This method zeros-out back-pointers.

NavigationToolbar2Handler

The -init… method is no longer needed since the pointer to NavigationToolbar2 is now a readwrite property.

There was a potential crash if the NavigationToolbar2 was freed and then NavigationToolbar2Handler tried to access it. The back-pointer needs to be cleared in NavigationToolbar2_dealloc to prevent this.

Modern practice is to place additional ivars directly on the @implementation block. You can also use @property syntax and make them readonly. I did the latter here so we could get rid of the @interface ivars.

View

-[View initWithFrame:] needs to check that the call to super succeeds. In the very-rare case that -[NSView initWithFrame:] returns nil, we were trying to dereference a NULL pointer. The if (self = [super init…]) { pattern is standard practice.

It's also standard practice for -init… methods to use instancetype as the return type. This is mostly to handle subclassing, so it's not actually needed here, but I figured that it was best to change it.

Clearing a weak back-pointer in -[View dealloc] isn't doing what it appears to do. These always need to be cleared by the owning object in the owning object's dealloc/ThePyObject_dealloc methods.

The rest of the changes involve removing setCanvas:, as it is synthesized, and using the synthesized ivar for the canvas @property instead of the old directly-declared one.

Timer

I fixed #29076 while I was testing Timer invalidation and ownership.

Timer__timer_stop_impl had to be moved so Timer__timer_start could call it.

AI Disclosure

  • I used AI to ask for advice about how tp_alloc works and if it zero-fills memory.
  • No code was modified by AI, nor did I use any code suggested by AI.

PR checklist

@greglucas

Copy link
Copy Markdown
Contributor

Overall this looks like a good direction to me. I would say that most people who have written/contributed to the macos backend are not objc developers, so we (at least myself) are likely just not aware of the objc conventions and patterns. Please feel free to correct things you think would make this easier to understand/follow. Your explanations so far have been really great, so thank you for that and the links for references, I've been learning myself!

I think we should bump our minimum supported deployment target to 10.14 at a minimum for our new releases. It has been unsupported by Apple for nearly 5 years now.
https://endoflife.date/macos

Python 3.12-13 already have a 10.13 (3.14 has a 10.15) minimum when building wheels, so we might not even be getting 10.12 support even if we're trying to declare it. Apple Silicon requires 11+
https://cibuildwheel.pypa.io/en/stable/platforms/

If this makes the code more reliable and easier to maintain in the future, we should do it now IMO. Moving all in on ARC and not requiring the extra manual reference counting macros looks much nicer to me.

Some links on stats from other main Python discussions for reference as well, showing this is a really small number of people to support on that old of an XCode / OS.
https://discuss.python.org/t/macos-version-support-policy/53715/21
https://discuss.python.org/t/moving-packaging-and-installers-to-macos-10-13-as-a-minimum/31907/8

@iccir

iccir commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Python 3.12-13 already have a 10.13 (3.14 has a 10.15) minimum when building wheels, so we might not even be getting 10.12 support even if we're trying to declare it. Apple Silicon requires 11+ https://cibuildwheel.pypa.io/en/stable/platforms/

10.12 and 10.13 have broken installers, as well. They are installable, but require jumping through flaming hoops:

There are also some new methods added in Mojave (10.14) related to View's device_scale math, so bumping to 10.14 may have some additional benefits.

@github-actions github-actions Bot added the CI: Run cibuildwheel Run wheel building tests on a PR label Jun 8, 2026
@iccir

iccir commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

I spent yesterday and today trying to wrap my head around the interactions between the various event-loop-related APIs and how they interact with our use of NSRunLoop. My ultimate goal was making sure that both calls modifying FigureWindowCount occcurred at the same architectural level.

There was a bit to unpack :)

Ideal Architecture

First, an NSView should not be the delegate of its window, as a window has strong ownership of its views.

Generally, you would have something like an NSWindowController or another controller-level object own a window (strong reference) and that object be the window's delegate window (weak reference).

In a larger-scale system with lots of bridged object types, you would probably have each PyModule *Type linked to a similarly-named Objective-C class which then uses Cocoa objects. Something like this:

PyModule Type Obj-C Class Owns / Uses
FigureManagerType MPLFigureManager NSWindow
FigureCanvasType MPLFigureCanvas NSView
NavigationToolbar2Type MPLNavigationToolbar NSButton, NSTextField, etc.
TimerType MPLTimer NSTimer

In our smaller-scale system, a lot of this would be overkill!

TimerType is fine using an NSTimer directly. While the object owned by FigureCanvasType should probably be called MPLFigureCanvas, it likely needs to be an NSView in order to participate in AppKit's responder hierarchy.

In any case, the "FigureManager" concept should own the window and be its delegate. It's not possible to have FigureManagerType directly be a delegate since it's not an Obj-C class, so we will need an intermediary MPLFigureManager. That's for the next refactor, however!

With that said, on to the latest changes:

FigureWindowCount

The FigureWindowCount static variable is now directly tied to how many FigureManagers still have windows. It is managed by the FigureManager_* functions rather than being incremented at one level and decremented in -[Window close].

There needs to be a new IsRunningFromShow boolean to keep track of whether or not closing the last window should call -[NSApp stop:].

-[View windowWillClose:]

-windowWillClose: is always called when a window is about to close (whether initiated from a user action or programatically).

This method was directly posting a close_event. This is needed to properly clean up the subplot tool (see configure_subplots() in backend_bases.py).

For now, I'm calling a new FigureManagerMac._handle_window_will_close in backend_macosx.py which posts the close_event from the Python level. This change will hopefully make sense in the next section.

-[View windowShouldClose:]

-windowShouldClose: is called when the user clicks the close button, selects the "File -> Close" command in the menu bar, or performs the ⌘W keyboard shortcut.

Note

⌘W is currently handled by our own key bindings and bypasses -windowShouldClose:

Based on code analysis, I was confused as to why FigureCanvas__start_event_loop() wasn't breaking when this method was invoked, since we are posting an NSEventTypeApplicationDefined-type event. I'm still not sure, I think it had to do with event ordering and/or run loop modes. That said, the end result was that closing a window didn't break out of the loop. Per @timhoffm 's comment in #31858, this sounds like the correct behavior.

Thus, the NSEvent posting in -[View windowShouldClose:] can go away as it was not doing anything.

-[Window closeButtonPressed] was calling "_close_button_pressed" at the Python level and then always returning YES. That's a bit weird per Objective-C naming conventions, as -closeButtonPressed sounds like an accessor method.

Instead, I'm calling FigureManagerMac._handle_window_should_close. This Python method used to be called FigureManagerMac.closed_button_pressed; however, that's not an ideal name as it can be invoked in response to other UI elements (not just the close button).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI: Run cibuildwheel Run wheel building tests on a PR GUI: MacOSX

Projects

None yet

2 participants