Skip to content

Support free-threaded Python, and fix instance lifetime and threading bugs - #230

Open
iDoMeteor wants to merge 2 commits into
SpotlightKid:masterfrom
iDoMeteor:free-threading
Open

iDoMeteor wants to merge 2 commits into
SpotlightKid:masterfrom
iDoMeteor:free-threading

Conversation

@iDoMeteor

@iDoMeteor iDoMeteor commented Oct 1, 2026 •

Copy link
Copy Markdown

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 rtmidi on 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.pyx unless noted)

  • freethreading_compatible = True. The module declares Py_mod_gil = Py_MOD_GIL_NOT_USED.

  • Per-instance serialization. Every public method of MidiIn / MidiOut runs 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 a MidiOut between 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 raise InvalidUseError instead of crashing, and close_port() and delete() 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 by hasattr(self, "thisptr"), which is always false for a cdef attribute. 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, and del works without gc.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 after closePort() has returned.
    • While it waits, get_message() from other threads returns None, delete() defers as above, and other calls raise InvalidUseError.
  • 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 raises InvalidUseError, 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_clear would store to the callback fields without the lock the input thread reads them under. So the classes are no_gc_clear, and a tp_clear installed at import drops the callbacks under that lock. Cycles through an instance's own callbacks (for example set_callback(f, data=midiin), or a subclass registering its own bound method) are still collected, and safely. This relies on no_gc_clear leaving tp_clear empty. On the limited API, where the slot can't be set, it falls back to close_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_section and pymutex. Also adds the Free Threading :: 2 - Beta classifier.

  • Docs and changelog: a "Threads" section in docs/usage.rst, and an "Unreleased" changelog entry.

  • Tests: a new tests/test_threads.py with 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:

    • a shared MidiOut
    • callback replacement during a flood of messages
    • close_port() while receiving
    • delete() while other threads use the instance, while an error callback blocks, while close_port() waits, and from inside the callback
    • error callback replacement
    • create/drop churn
    • that del, close_port() and the garbage collector release the client and callback cycles, including while messages arrive

    The 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.
  • ALSA's 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() or delete() from a thread other than the one that created the MidiIn can 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

  • There's no cp314t wheel build: ci: build Python 3.14 wheels #229 updates cibuildwheel and adds 3.14. Once it's in, adding cp314t-* to the build patterns builds free-threaded wheels.
  • On regular builds, 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:

Result
Free-threaded CPython 3.14.7 (python-build-standalone) GIL stays off after import. Full suite: 55 passed, 2 skipped, 1 failed (test_linux_supports_jack, because there's no JACK here; it fails on master too). test_threads.py passed every run, free-threaded and with PYTHON_GIL=1.
AddressSanitizer build, free-threaded 3.14 No reports for 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 build master: hangs 6 of 6. This branch: completes.
del probe master: the ALSA client stays after del + gc.collect(). This branch: it's gone on del.

Regular (GIL) builds, against the same tree:

Interpreter Cython Full suite test_threads.py
3.11.15 3.3.0 49 passed, 3 skipped, 1 failed (test_linux_supports_jack) 5 of 5 runs pass
3.12.13 3.3.0 same 5 of 5
3.12.13 3.1.0 (the new minimum) same 3 of 3
3.14.6 3.3.0 same 5 of 5

No compiler warnings from _rtmidi on 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

iDoMeteor and others added 2 commits October 1, 2026 10:39
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 branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant