Skip to content

macOS Rework: Event Loop - #32363

Draft
iccir wants to merge 22 commits into
matplotlib:mainfrom
iccir:macos-pr-eventloop
Draft

iccir wants to merge 22 commits into
matplotlib:mainfrom
iccir:macos-pr-eventloop

Conversation

@iccir

@iccir iccir commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

PR summary

Important

This PR depends on #32161 and will appear larger than it really is until #32161 is merged. To view the actual changes in this PR, compare macos-staging → macos-pr-eventloop.

This PR moves all event loop related logic to a new MPLEventLoop class.

Closes #30419

Event Loop Overview

Typically, macOS uses the following code template to run the event loop:

BOOL shouldStop = NO;
NSDate *date = …;

while (!shouldStop) {
    // Needed as we are enterring a long-running section of code
    @autoreleasepool {
        // nextEventMatchingMask:… causes the application to "sleep"
        // until either a matching event is posted or `date` is reached.
        NSEvent *event = [NSApp nextEventMatchingMask: NSEventMaskAny
                                            untilDate: date
                                               inMode: NSDefaultRunLoopMode
                                              dequeue: YES];

        if (event) [NSApp sendEvent:event];

        // Check for stop condition and update shouldStop here
        shouldStop = …;
    }
}

macOS provides -[NSApplication run]. This uses the above template with the following variables:

Variable Description
date Set to +[NSDate distantFuture].
shouldStop Reflects the value of NSApplication's private _running ivar. Is NO when _running is 0, and YES when _running > 0. _running is typically 1 under normal conditions or 2 when the application is running with a modal dialog present.

When -[NSApplication stop:] is called, the private _running ivar is set to 0. The current event will finish processing and the loop will break on the next iteration.

Calls to -[NSApplication run] are not suppose to be re-entrant. A call to -[NSApplication stop:] will break out of all -[NSApplication run] calls on the stack. This was the root cause of #30419.

To manually spin up the event loop, the above code template should be replicated with date and shouldStop set accordingly. To check the stop condition, a "fake" NSEvent should be posted via -[NSApplication postEvent:atStart:].

It's permissible to manually spin up the event loop inside of a call to -[NSApplication run] as long as there is only one -[NSApplication run] on the call stack. In addition, -[NSApplication run] should always be used when possible as it performs one-time initialization and may contain possible performance improvements.

Our Usage

We have four situations where we need to spin up the event loop:

Name Description
PyOS_InputHook Run until activity on stdin (standard input).
flush_events Run until no more events can be dequeued.
show Run until the last figure window is closed.
start_event_loop Run until stop_event_loop() or a timeout occurs.

show and start_event_loop tend to be the "main" entry points for long-running event loops. It doesn't make sense to call these in a nested fashion. These are good examples of where -[NSApplication run] should be used.

flush_events can also be a "main" entry point when used for blitting. However, by design, it is not long-running – it stops spinning when the event queue becomes empty. It's easiest to use the above code template manually rather than trying to use -[NSApplication run].

PyOS_InputHook can be called when the event loop is already running. As such, it cannot use -[NSApplication run].

MPLEventLoop

The MPLEventLoop singleton manages all interaction with the macOS event loop. It also handles our PyErr_CheckSignals logic, which needed a home and is closely related.

MPLEventLoop categorizes the above-mentioned situations into "spins" vs. "runs". "Spins" may be called while the event loop is already running. "Runs" call -[NSApplication run] and will raise an error if the event loop is already running.

The exact method calls are as follows:

Name Method
PyOS_InputHook -spinUntilStandardInput
flush_events -spinUntilNoEvents
show -runUntilStopCondition:
(stop condition of "no open figures")
start_event_loop -runUntilTimeout:

MPLEventLoop keeps track of the call stack and will correctly stop all loops when -stop is called.

Logging

Issues related to the event loop can be notoriously difficult to debug. As such, MPLEventLoop logs all calls with our os_log_t logger.

To view them, use the following log stream:

log stream --debug --predicate \
    'subsystem == "org.matplotlib" AND
    message CONTAINS "EventLoop"'

Running the example in #30419 results in:

… [EventLoop] +++ loop #1 entry +++ runUntilStopCondition
… [EventLoop] +++ loop #2 entry +++ spinUntilStandardInput
… [EventLoop] --- loop #2 exit  --- spinUntilStandardInput
… [EventLoop] stop requested, _loopCount = 1
… [EventLoop] --- loop #1 exit  --- runUntilStopCondition

SIGINT Handler Changes

matplotlib provides an _allow_interrupt context manager which enables a SIGINT to stop the event loop.

This mostly worked in the old macosx backend - we used a NSFileHandle to listen to the file descriptor and call PyErr_CheckSignals() on activity. However, the NSFileHandle instance effectively owned itself – it was kept alive by a strong reference in its own callback.

This is fixed in the new macos backend by adding an optional cleanup callback to _allow_interrupt. The callback runs after handle_sigint but before the socket pair is closed. This specific order allows us to use Apple's libdispatch to monitor the file descriptor rather than NSFileHandle. During cleanup, we can ensure that dispatch_cancel is called before close.

-[MPLEventLoop updateCheckSignalsFileDescriptor:] handles the setup or teardown of the dispatch source. To match our design, the dispatch source invokes MPLCheckSignals() from MPLUtils rather than using PyErr_CheckSignals() directly.

matplotlib API Changes

  • Calling show() when the macOS event loop is already running now raises an error.
  • Calling start_event_loop() when the macOS event loop is already running now raises an error.

AI Disclosure

  • I use AI for web search due to search engines becoming less reliable.
  • All code is my own.

PR quality check

  • Use an expressive title, e.g. "Fix title font property precedence"
  • [N/A] New and changed code is tested (Tested manually)
  • [N/A] Plotting related features are demonstrated in an example
  • [N/A] New features and API changes have release notes
  • [N/A] Documentation complies with general and docstring guidelines

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Event handling with input in callback function

2 participants