Conversation
Declare the extension free-threading compatible (Py_mod_gil), so importing rtmidi no longer re-enables the GIL on 3.13t and later, and make that safe: - Every public MidiIn / MidiOut method runs in a critical section on the instance, which serializes calls on one instance like the GIL does. - Methods count the calls in progress that use the C++ instance; delete() leaves the C++ instance to the last of them to free. A critical section (like the GIL) is released while its thread blocks, e.g. in an error callback, and delete() from another thread used to free the C++ instance under such a call. - RtMidi gets the instance as callback user data instead of a (func, data) tuple that replacing the callback could free while the input thread was about to use it. The input thread reads the callable under a small per-instance lock. Replacing a callback no longer cancels it in RtMidi. - MidiIn.close_port() and C++ deletion release the GIL / detach while they wait for the input thread, which may be waiting to run a callback (this deadlocked on regular builds, too). close_port() cancels the callback in RtMidi only after closePort() returned. - delete() from an instance's own input callback raises InvalidUseError. - no_gc_clear: the GC must not clear callbacks the input thread may read. Also fixes, on every build: - __dealloc__ never freed the C++ instance (hasattr() on a cdef attribute is always false), leaving ports open and the input thread running with a dangling callback. - The error callback held a bound method of the instance, so every instance was in a reference cycle. - Methods called after delete() crashed; they raise InvalidUseError now. Requires Cython >= 3.1. Adds tests/test_threads.py and a "Threads" section to the usage docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The classes were no_gc_clear, because Cython's generated tp_clear stores to the callback fields without _cb_lock, racing with the input thread, so a cycle through an instance's own callbacks (e.g. set_callback(f, data=self), or a subclass registering a bound method of itself) was never collected. Keep no_gc_clear, and install a tp_clear at import that drops the callbacks the way _swap_callback() replaces them: under _cb_lock, releasing the old references after it. Where the slot cannot be set (limited API), the old behaviour remains. The input thread can pick up a callback after the collector found the instance unreachable, and then hold its last references. _cb_func releases them while still marked as the instance's input thread, and MidiIn.__dealloc__ on that thread cancels the callbacks and destroys the C++ instance on a helper thread, which waits for the input thread, instead of leaking it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds support for free-threaded Python (3.13t and later), and fixes several lifetime and threading bugs the work uncovered. Most of those bugs affect regular builds too.
Today, importing
rtmidion a free-threaded interpreter turns the GIL back on for the whole process (RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'rtmidi._rtmidi'…). With this PR it stays off.Changes (all in
src/_rtmidi.pyxunless noted)freethreading_compatible = True. The module declaresPy_mod_gil = Py_MOD_GIL_NOT_USED.Per-instance serialization. Every public method of
MidiIn/MidiOutruns in a critical section on the instance (cython.critical_section). On free-threaded builds, calls on one instance are serialized the way the GIL serializes them today, so sharing aMidiOutbetween threads keeps working. On regular builds the critical sections compile to nothing.delete()with calls in progress. A critical section is released while its thread blocks, just as the GIL is: for example, in an error callback that sleeps, logs, or waits for a lock. So methods count the calls in progress that use the C++ instance.delete()marks the instance deleted, and the C++ instance is freed when the last of those calls returns. Before this,delete()from another thread could free the C++ instance under a call that was still in RtMidi, on regular builds too. Afterwards, methods raiseInvalidUseErrorinstead of crashing, andclose_port()anddelete()become no-ops. That addresses the concern from Removing old MidiOut ports from get_ports after use #60.Callback lifetime. RtMidi used to get the
(func, data)tuple as its user data. Replacing or cancelling a callback could free that tuple while the input thread was about to use it (a use-after-free, on regular builds too). RtMidi now gets the instance itself, and the input thread reads the current callback under a small per-instance lock (cython.pymutex). That lock is held only to read or swap the reference.set_callback()replacing an existing callback only swaps the reference, without cancelling and re-setting it in RtMidi, which also avoids an RtMidi race (see below).__dealloc__never freed the C++ instance. Since 1.4.1 it has been guarded byhasattr(self, "thisptr"), which is always false for acdefattribute. So the MIDI client, its ports and the input thread outlived the Python object. With a callback set, a later message called through a freed tuple. I reproduced that: a recycled tuple got called as the callback. It's now freed, anddelworks withoutgc.collect()too: the error callback stored a bound method of the instance, which made every instance part of a reference cycle, and that's gone.close_port()deadlock.MidiIn.close_port()held the GIL while RtMidi's ALSA backend joined the input thread. If that thread was waiting for the GIL to run a callback, both waited forever. On this machine it took seconds to reproduce under MIDI load (gdb:MidiInAlsa::closePort→pthread_join, and_cb_func→PyGILState_Ensure).close_port()and C++ deletion now release the GIL (or detach the thread state) while they wait.close_port()drops the Python callback first, and cancels it in RtMidi only afterclosePort()has returned.get_message()from other threads returnsNone,delete()defers as above, and other calls raiseInvalidUseError.The callback's own thread. A thread-local records which instance's callback is running on the current thread.
delete()from inside its own callback raisesInvalidUseError, because the destructor would join the thread it runs on. If the last reference goes away inside the callback, the callbacks are cancelled and the C++ instance is destroyed on a helper thread, which can wait for the input thread.close_port()from inside the callback still works.Garbage collection (second commit). On free-threaded builds the collector clears cycles while other threads run, and Cython's generated
tp_clearwould store to the callback fields without the lock the input thread reads them under. So the classes areno_gc_clear, and atp_clearinstalled at import drops the callbacks under that lock. Cycles through an instance's own callbacks (for exampleset_callback(f, data=midiin), or a subclass registering its own bound method) are still collected, and safely. This relies onno_gc_clearleavingtp_clearempty. On the limited API, where the slot can't be set, it falls back toclose_port()/delete()breaking such cycles.get_current_api()returns the API recorded at construction. It is fixed for the instance's lifetime, and this lets error callbacks decode messages without calling into C++.Packaging (
pyproject.toml, requirements,INSTALL.md): Cython >= 3.1, for the directive,critical_sectionandpymutex. Also adds theFree Threading :: 2 - Betaclassifier.Docs and changelog: a "Threads" section in
docs/usage.rst, and an "Unreleased" changelog entry.Tests: a new
tests/test_threads.pywith 17 tests, using virtual ports, so they are skipped on Windows MM or without an ALSA sequencer. They check that the GIL stays disabled, and stress-test:MidiOutclose_port()while receivingdelete()while other threads use the instance, while an error callback blocks, whileclose_port()waits, and from inside the callbackdel,close_port()and the garbage collector release the client and callback cycles, including while messages arriveThe stress tests run for
RTMIDI_STRESS_SECONDS(1 s by default).RtMidi bugs found along the way
The stress tests also found two bugs in RtMidi's C++ code. They are in upstream RtMidi too, and they hit regular builds as well:
cancelCallback()races the input thread, which can then call a null pointer.closePort()can join the wrong thread and hang, or fail to join and leak the thread.I've opened fixes upstream (thestk/rtmidi#395, thestk/rtmidi#396) and a backport to this repo's submodule branch (SpotlightKid/rtmidi#4). This PR does not need them. The tests are written so they don't trip either bug: they close ports from the thread that created the instance, and they don't call
cancel_callback()under load. Until the submodule is bumped:cancel_callback()while messages arrive can still crash inside RtMidi.close_port()ordelete()from a thread other than the one that created theMidiIncan still hang inside RtMidi, rarely.Once the backport is merged, a one-line submodule bump (plus a test that cancels callbacks under load) can follow.
Not in this PR
cp314t-*to the build patterns builds free-threaded wheels.open_virtual_port()still holds the GIL while RtMidi's ALSA backend joins an input thread that stopped earlier. That can only deadlock if a callback closed its own port and is still running. It is contrived, but noted.Verification
Linux with ALSA (Fedora 44), against the RtMidi currently in the submodule:
test_linux_supports_jack, because there's no JACK here; it fails onmastertoo).test_threads.pypassed every run, free-threaded and withPYTHON_GIL=1.delete()racing a blocked error callback,set_error_callback,close_port(), or the callback itself. Before these changes, those reproducers gave heap-use-after-free reports.close_port()deadlock repro, GIL buildmaster: hangs 6 of 6. This branch: completes.delprobemaster: the ALSA client stays afterdel+gc.collect(). This branch: it's gone ondel.Regular (GIL) builds, against the same tree:
test_threads.pytest_linux_supports_jack)No compiler warnings from
_rtmidion any of them. That table is for the first commit. With the second, 3.12 was rerun with both Cython 3.3.0 and 3.1.0: 54 passed, 3 skipped, and the same JACK failure. The four new garbage-collection tests fail on the first commit alone, so they do detect the change.What I did not test: macOS, Windows, PyPy, and CPython 3.9 and 3.10. This PR's CI covers them. JACK wasn't tested at runtime either.
🤖 Generated with Claude Code