diff --git a/CHANGELOG.md b/CHANGELOG.md index 557a2279..0086b814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ For details and minor changes, please see the [version control log messages](https://github.com/SpotlightKid/python-rtmidi/commits/master). +## Unreleased + +Features: + +- Support free-threaded Python builds (3.13t and later): importing `rtmidi` + no longer re-enables the GIL. Calls on one `MidiIn` / `MidiOut` instance + are serialized, on every build; see "Threads" in the usage docs. +- `delete()` may be called while another thread is in a call on the same + instance; the C++ instance is destroyed when that call returns. Calling + it from the instance's own input callback raises `InvalidUseError`. + +Changes: + +- While `MidiIn.close_port()` waits for the input thread, `get_message()` + from another thread returns `None` and other calls on the instance raise + `InvalidUseError`. + +Fixes: + +- Deleting the last reference to a `MidiIn` / `MidiOut` instance never + freed the C++ instance (since 1.4.1), so its MIDI client and ports stayed + open, and a `MidiIn` input thread kept running and could call a freed + callback when a message arrived. If the last reference goes away in the + instance's own input callback, the C++ instance is destroyed on another + thread. +- `MidiIn.close_port()` (and deleting a `MidiIn`) could deadlock when a + message arrived for the input callback at the same moment. +- `MidiIn` / `MidiOut` instances no longer form a reference cycle with + their error callback, so `del` frees them without the garbage collector. +- Methods called after `delete()` raise `InvalidUseError` instead of + crashing; `close_port()` and `delete()` do nothing then. Calls that were + in progress in other threads no longer use the freed C++ instance. +- Replacing a callback with `set_callback()` while messages arrive could + call a freed callback. + +Project infrastructure: + +- Building from the Cython source requires Cython >= 3.1. + + ## 1.6.0 (2025-04-18) Project infrastructure: diff --git a/INSTALL.md b/INSTALL.md index aa27d686..36bc595d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -127,7 +127,7 @@ build tools are installed. If you are installing from a Git repository checkout, since this does not include the C++ module source code pre-compiled from the Cython source, you'll -also need to install Cython >= 0.29, either via pip or from its Git repository. +also need to install Cython >= 3.1, either via pip or from its Git repository. Using virtualenv / virtualenvwrapper is strongly recommended in this scenario: Make a virtual environment: diff --git a/docs/usage.rst b/docs/usage.rst index e90a0360..ee237168 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -33,6 +33,40 @@ available MIDI output port and send a middle C note on MIDI channel 1: last message is sent and before the output port is closed, otherwise the message may be lost. +Threads +======= + +Calls on one ``MidiIn`` or ``MidiOut`` instance are serialized, so several +threads can share an instance, for example to send messages through one +``MidiOut``. On a free-threaded Python build (3.13t and later), +**python-rtmidi** does not re-enable the GIL, and calls on different instances +run in parallel. + +The callback set with ``MidiIn.set_callback`` runs on a thread started by the +MIDI backend, not on one of your threads: + +* A new callback set with ``set_callback`` gets the next message. But a + message that is being delivered while ``cancel_callback`` or ``close_port`` + is called may still reach the old callback after that call returns. +* ``MidiIn.close_port`` and ``MidiIn.delete`` wait for that thread to stop. + ``close_port`` may be called from inside the callback; ``delete`` may not + (``InvalidUseError``). While ``close_port`` waits, ``get_message`` returns + ``None`` and most other calls on the same instance from other threads raise + ``InvalidUseError``. + +``delete`` may be called while another thread is in a call on the same +instance: the C++ instance is then destroyed when that call returns. After +``delete``, methods raise ``InvalidUseError``, except ``get_current_api`` and +``is_port_open``, which still work, and ``close_port`` and ``delete``, which +do nothing. + +Reference cycles through an instance's callbacks (for example a callback whose +``data`` is the ``MidiIn`` itself) are collected by the garbage collector like +any other; a message being delivered at that moment is dropped. If the last +reference to a ``MidiIn`` goes away in its own input callback, the C++ +instance is destroyed on another thread, which waits for the callback to +return. + More usage examples can be found in the examples_ and tests_ directories of the source repository. diff --git a/pyproject.toml b/pyproject.toml index 187145b0..b734d178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,8 @@ [build-system] build-backend = "mesonpy" requires = [ - "cython", + # 3.1: freethreading_compatible, critical_section and pymutex + "cython>=3.1", "wheel", "meson-python", "ninja" @@ -33,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Free Threading :: 2 - Beta", "Topic :: Multimedia :: Sound/Audio :: MIDI", "Topic :: Software Development :: Libraries :: Python Modules", ] diff --git a/requirements-dev.in b/requirements-dev.in index 760f1c9f..3ce7fe2c 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -1,5 +1,5 @@ coverage -Cython +Cython>=3.1 flake8 myst-parser pip-tools diff --git a/requirements-dev.txt b/requirements-dev.txt index 9966b550..52e99038 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -24,7 +24,7 @@ colorama==0.4.6 # via tox coverage==7.6.11 # via -r requirements-dev.in -cython==3.0.11 +cython==3.1.0 # via -r requirements-dev.in distlib==0.3.9 # via virtualenv diff --git a/src/_rtmidi.pyx b/src/_rtmidi.pyx index 00c9e5ca..71642d47 100644 --- a/src/_rtmidi.pyx +++ b/src/_rtmidi.pyx @@ -1,6 +1,7 @@ # cython: embedsignature = True # cython: language_level = 3 # cython: show_performance_hints = False +# cython: freethreading_compatible = True # distutils: language = c++ # # rtmidi.pyx @@ -109,6 +110,8 @@ the `RtMidi API reference`_. import sys import warnings +cimport cython +from cpython.ref cimport PyObject from libcpp cimport bool from libcpp.string cimport string from libcpp.vector cimport vector @@ -177,7 +180,9 @@ cdef extern from "RtMidi.h": void *userData) except * with gil cdef cppclass RtMidi: - void closePort() except * + # nogil: MidiIn.close_port() calls it without the GIL / an attached + # thread state, because it waits for the input thread (see there). + void closePort() except * nogil unsigned int getPortCount() except * string getPortName(unsigned int portNumber) except * void openPort(unsigned int portNumber, string &portName) except * @@ -204,19 +209,100 @@ cdef extern from "RtMidi.h": # internal functions +cdef extern from *: + """ + /* The MidiIn whose input callback is running on this thread, if any. */ + static thread_local void *rtmidi_callback_owner = NULL; + + /* Destroys a RtMidiIn on a new thread (the thread that runs this does not + need a Python thread state); -1 if the thread could not be started. */ + static void rtmidi_destroy_midiin_thread(void *ptr) { + delete static_cast(ptr); + } + + static int rtmidi_destroy_midiin_later(RtMidiIn *ptr) { + return PyThread_start_new_thread(rtmidi_destroy_midiin_thread, ptr) + == (unsigned long)-1 ? -1 : 0; + } + + /* Replaces the tp_clear slot of a GC type that has none; see MidiBase. + Returns 0 where the slot cannot be set (limited API, not CPython). */ + static int rtmidi_set_tp_clear(PyObject *type, int (*clear)(PyObject *)) { + #if CYTHON_COMPILING_IN_CPYTHON && !CYTHON_COMPILING_IN_LIMITED_API + PyTypeObject *tp = (PyTypeObject *)type; + + if (!PyType_HasFeature(tp, Py_TPFLAGS_HAVE_GC) || tp->tp_traverse == NULL) + return 0; + + tp->tp_clear = clear; + return 1; + #else + return 0; + #endif + } + """ + void *rtmidi_callback_owner + int rtmidi_destroy_midiin_later(RtMidiIn *ptr) + int rtmidi_set_tp_clear(PyObject *type, int (*clear)(PyObject *) noexcept) + + cdef void _cb_func(double delta_time, vector[unsigned char] *msg_v, void *cb_info) except * with gil: - """Wrapper for a Python callback function for MIDI input.""" - func, data = ( cb_info) + """Wrapper for a Python callback function for MIDI input. + + Runs on the backend's input thread. ``cb_info`` is the ``MidiIn`` + instance, borrowed: its deallocator stops this thread before the instance + goes away, so no reference is taken here (taking one while the instance is + being deallocated on another thread would corrupt its reference count). + + """ + global rtmidi_callback_owner + cdef void *outer_owner + + if cb_info == NULL: + # RtMidi read its user data while cancelCallback() was resetting it. + return + + callback = ( cb_info)._get_callback() + + if callback is None: + # The callback was cancelled while this message was in flight. + return + + func, data = callback message = [msg_v.at(i) for i in range(msg_v.size())] - func((message, delta_time), data) + outer_owner = rtmidi_callback_owner + rtmidi_callback_owner = cb_info + + try: + func((message, delta_time), data) + finally: + # These may be the last references to the instance (the garbage + # collector can clear its callbacks while this thread has picked one + # up). Release them while still marked as the instance's input thread, + # so that its deallocation does not wait for this thread. + callback = func = data = None + rtmidi_callback_owner = outer_owner cdef void _cb_error_func(ErrorType errorType, const string &errorText, void *cb_info) except * with gil: - """Wrapper for a Python callback function for errors.""" - func, data, decoder = ( cb_info) - func(errorType, decoder(errorText), data) + """Wrapper for a Python callback function for errors. + + Called synchronously by the C++ method that hit the error, on the thread + that called it, which holds a reference to the ``MidiIn`` / ``MidiOut`` + instance passed as ``cb_info``. + + """ + midi = cb_info + callback = midi._get_error_callback() + + if callback is None: + # Deleted while the call that hit the error was in progress. + return + + func, data = callback + func(errorType, midi._decode_string(errorText), data) def _to_bytes(name): @@ -424,14 +510,144 @@ def _default_error_handler(etype, msg, data=None): raise RtMidiError(msg, type=etype) +# Thread safety +# ------------- +# +# Every public method of ``MidiIn`` / ``MidiOut`` runs in a critical section on +# the instance (``@cython.critical_section``). On a free-threaded build that +# serializes calls on one instance the way the GIL does on a regular build; +# there, it compiles to nothing. Calls on different instances run in parallel. +# +# Like the GIL, a critical section is released while its thread blocks, e.g. +# in an error callback. So methods that use the C++ instance bracket that with +# ``_enter()`` / ``_exit()``, which count the calls in progress: ``delete()`` +# leaves the C++ instance to the last of them to free. +# +# The callbacks are passed to RtMidi as the instance itself and the Python +# callable is read under ``_cb_lock``, which is only ever held to read or swap +# the stored reference; so replacing a callback cannot free what the input +# thread is about to call, and the input thread never waits for the critical +# section. +# +# ``MidiIn.close_port()`` and the C++ destructor (``MidiIn.delete()``, +# ``__dealloc__``) wait for the input thread to stop, and that thread may be +# waiting to run a callback. So they wait without the GIL (regular build) or +# with the thread state detached (free-threaded build). While ``close_port()`` +# waits, other calls on the instance raise ``InvalidUseError`` (or, for +# ``get_message()``, return ``None``). Neither can be done by the input thread +# itself: ``delete()`` raises there, and a deallocation there leaves the +# destruction to another thread (``rtmidi_callback_owner`` tells). +# +# Garbage collection: the callbacks can be part of a reference cycle (e.g. +# ``midiin.set_callback(func, midiin)``), which the garbage collector must be +# able to break. Cython's generated ``tp_clear`` would store to the callback +# fields without ``_cb_lock``, racing with the input thread, which reads them +# under it (on a free-threaded build the collector runs while other threads +# do). So the classes are ``no_gc_clear``, which makes Cython generate no +# ``tp_clear`` (``tp_traverse`` stays), and ``_install_gc_clear()`` sets +# ``_gc_clear`` as their ``tp_clear`` at import. That drops the callbacks the +# way ``_swap_callback()`` replaces them: under ``_cb_lock``, releasing the old +# references after it. The input thread either got a callback before (and +# holds its own references, which keep the instance alive) or finds ``None``. +# Python subclasses reach it through ``subtype_clear``. Only unreachable +# instances are cleared, so no method call on one is in progress: callers hold +# a reference to it. The exception is the input thread, which is handed the +# instance without one: it may pick up a callback after the collector found +# the instance unreachable and before it clears it. The thread then holds the +# last references to the instance, so ``_cb_func`` releases them before it +# stops being the instance's ``rtmidi_callback_owner``, and +# ``MidiIn.__dealloc__`` leaves the destruction of the C++ instance to another +# thread if it runs on the input thread anyway. +# +# Where ``tp_clear`` cannot be set (limited API, not CPython), the classes +# keep Cython's no_gc_clear behavior: cycles through callbacks are only broken +# by ``close_port()`` and ``delete()``. + +@cython.no_gc_clear cdef class MidiBase: cdef object _port cdef object _error_callback - cdef object _deleted - - cdef RtMidi* baseptr(self): + cdef bint _deleted + cdef Api _api # fixed when the C++ instance is created + cdef cython.pymutex _cb_lock + # Calls in progress that use the C++ instance (see _enter()), and the C++ + # instance that delete() left for the last of them to free. + cdef int _calls + cdef RtMidi *_doomed + # True while MidiIn.close_port() waits for the input thread without the + # critical section (see there). + cdef bint _closing + + cdef RtMidi* baseptr(self) noexcept: + # The C++ instance, NULL once deleted. return NULL + cdef void _forget_ptr(self) noexcept: + pass + + cdef void _destroy(self, RtMidi *ptr) noexcept nogil: + # Deletes through the concrete type (RtMidi's destructor is protected). + pass + + cdef RtMidi* _enter(self) except NULL: + # Call with the critical section on self held; pair with _exit(). + if self._deleted: + raise InvalidUseError("%r has been deleted." % self) + if self._closing: + raise InvalidUseError("%r is closing its port." % self) + + self._calls += 1 + return self.baseptr() + + cdef void _exit(self) noexcept: + # Call with the critical section on self held. + cdef RtMidi *ptr = self._doomed + self._calls -= 1 + + # Not on the input thread of this instance: see _delete(). + if self._calls == 0 and ptr != NULL and rtmidi_callback_owner != self: + self._doomed = NULL + self._free(ptr) + + cdef int _delete(self) except -1: + cdef RtMidi *ptr + + with cython.critical_section(self): + if self._deleted: + return 0 + + if rtmidi_callback_owner == self: + # The destructor would stop and join the thread running this. + raise InvalidUseError("%r cannot be deleted from its own input " + "callback." % self) + + ptr = self.baseptr() + self._forget_ptr() + self._deleted = True + + if self._calls: + self._doomed = ptr + return 0 + + self._free(ptr) + return 0 + + cdef void _free(self, RtMidi *ptr) noexcept: + # The destructor stops the input thread, which may be waiting to run a + # callback, so let it run. + with nogil: + self._destroy(ptr) + + # They may be part of a reference cycle. + self._drop_callbacks() + + cdef void _drop_callbacks(self) noexcept: + self._swap_error_callback(None) + + cdef object _get_error_callback(self): + with self._cb_lock: + return self._error_callback + # context management def __enter__(self): """Support context manager protocol. @@ -470,7 +686,7 @@ cdef class MidiBase: """ self.close_port() - def _check_port(self): + cdef str _check_port(self): inout = "input" if isinstance(self, MidiIn) else "output" if self._port == -1: raise InvalidUseError("%r already opened virtual %s port." % @@ -480,12 +696,12 @@ cdef class MidiBase: (self, inout, self._port)) return inout - def _decode_string(self, s, encoding='auto'): + cdef object _decode_string(self, s, encoding='auto'): """Decode given byte string with given encoding.""" if encoding == 'auto': if sys.platform.startswith('win'): encoding = 'latin1' - elif (self.get_current_api() == API_MACOSX_CORE and + elif (self._api == API_MACOSX_CORE and sys.platform == 'darwin'): encoding = 'macroman' else: @@ -493,10 +709,17 @@ cdef class MidiBase: return s.decode(encoding, "ignore") + @cython.critical_section def get_port_count(self): """Return the number of available MIDI input or output ports.""" - return self.baseptr().getPortCount() + cdef RtMidi *ptr = self._enter() + try: + return ptr.getPortCount() + finally: + self._exit() + + @cython.critical_section def get_port_name(self, unsigned int port, encoding='auto'): """Return the name of the MIDI input or output port with given number. @@ -511,7 +734,13 @@ cdef class MidiBase: as type ``bytes``. """ - cdef string name = self.baseptr().getPortName(port) + cdef RtMidi *ptr = self._enter() + cdef string name + + try: + name = ptr.getPortName(port) + finally: + self._exit() if len(name): return self._decode_string(name, encoding) if encoding else name @@ -531,6 +760,7 @@ cdef class MidiBase: return [self.get_port_name(p, encoding=encoding) for p in range(self.get_port_count())] + @cython.critical_section def is_port_open(self): """Return ``True`` if a port has been opened and ``False`` if not. @@ -542,6 +772,7 @@ cdef class MidiBase: """ return self._port is not None + @cython.critical_section def open_port(self, unsigned int port=0, name=None): """Open the MIDI input or output port with the given port number. @@ -579,10 +810,17 @@ cdef class MidiBase: if name is None: name = "RtMidi %s" % inout - self.baseptr().openPort(port, _to_bytes(name)) + cdef RtMidi *ptr = self._enter() + + try: + ptr.openPort(port, _to_bytes(name)) + finally: + self._exit() + self._port = port return self + @cython.critical_section def open_virtual_port(self, name=None): """Open a virtual MIDI input or output port. @@ -634,13 +872,19 @@ cdef class MidiBase: .. _loopmidi: http://www.tobias-erichsen.de/software/loopmidi.html """ - if self.get_current_api() == API_WINDOWS_MM: + if self._api == API_WINDOWS_MM: raise NotImplementedError("Virtual ports are not supported " "by the Windows MultiMedia API.") inout = self._check_port() - self.baseptr().openVirtualPort(_to_bytes(("RtMidi virtual %s" % inout) - if name is None else name)) + cdef RtMidi *ptr = self._enter() + + try: + ptr.openVirtualPort(_to_bytes(("RtMidi virtual %s" % inout) + if name is None else name)) + finally: + self._exit() + self._port = -1 return self @@ -656,10 +900,23 @@ cdef class MidiBase: delete its ``MidiIn`` or ``MidiOut`` instance. """ - if self._port != -1: - self._port = None - self.baseptr().closePort() + cdef RtMidi *ptr + with cython.critical_section(self): + if self._deleted: + return + + if self._port != -1: + self._port = None + + ptr = self._enter() + + try: + ptr.closePort() + finally: + self._exit() + + @cython.critical_section def set_client_name(self, name): """Set the name of the MIDI client. @@ -678,12 +935,18 @@ cdef class MidiBase: client name. """ - if self.get_current_api() in (API_MACOSX_CORE, API_UNIX_JACK, API_WINDOWS_MM): + if self._api in (API_MACOSX_CORE, API_UNIX_JACK, API_WINDOWS_MM): raise NotImplementedError( "API backend does not support changing the client name.") - self.baseptr().setClientName(_to_bytes(name)) + cdef RtMidi *ptr = self._enter() + try: + ptr.setClientName(_to_bytes(name)) + finally: + self._exit() + + @cython.critical_section def set_port_name(self, name): """Set the name of the currently opened port. @@ -704,15 +967,21 @@ cdef class MidiBase: port name. """ - if self.get_current_api() in (API_MACOSX_CORE, API_WINDOWS_MM): + if self._api in (API_MACOSX_CORE, API_WINDOWS_MM): raise UnsupportedOperationError( "API backend does not support changing the port name.") if self._port is None: raise InvalidUseError("No port currently opened.") - self.baseptr().setPortName(_to_bytes(name)) + cdef RtMidi *ptr = self._enter() + try: + ptr.setPortName(_to_bytes(name)) + finally: + self._exit() + + @cython.critical_section def set_error_callback(self, func, data=None): """Register a callback function for errors. @@ -736,9 +1005,21 @@ cdef class MidiBase: handler. """ - self._error_callback = (func, data, self._decode_string) - self.baseptr().setErrorCallback(&_cb_error_func, - self._error_callback) + cdef RtMidi *ptr = self._enter() + + try: + # Released at the end, after the C++ call. + old = self._swap_error_callback((func, data)) + ptr.setErrorCallback(&_cb_error_func, self) + finally: + self._exit() + + cdef object _swap_error_callback(self, callback): + # Returns the old callback, so that it is released after _cb_lock is. + with self._cb_lock: + old = self._error_callback + self._error_callback = callback + return old def cancel_error_callback(self): """Remove the registered callback function for errors. @@ -750,6 +1031,7 @@ cdef class MidiBase: self.set_error_callback(_default_error_handler) +@cython.no_gc_clear cdef class MidiIn(MidiBase): """Midi input client interface. @@ -794,9 +1076,31 @@ cdef class MidiIn(MidiBase): cdef RtMidiIn *thisptr cdef object _callback - cdef RtMidi* baseptr(self): + cdef RtMidi* baseptr(self) noexcept: return self.thisptr + cdef void _forget_ptr(self) noexcept: + self.thisptr = NULL + + cdef void _destroy(self, RtMidi *ptr) noexcept nogil: + cdef RtMidiIn *midiin = ptr + del midiin + + cdef void _drop_callbacks(self) noexcept: + MidiBase._drop_callbacks(self) + self._swap_callback(None) + + cdef object _get_callback(self): + with self._cb_lock: + return self._callback + + cdef object _swap_callback(self, callback): + # Returns the old callback, so that it is released after _cb_lock is. + with self._cb_lock: + old = self._callback + self._callback = callback + return old + def __cinit__(self, Api rtapi=UNSPECIFIED, name=None, unsigned int queue_size_limit=1024): """Create a new client instance for MIDI input. @@ -812,10 +1116,11 @@ cdef class MidiIn(MidiBase): except RuntimeError as exc: raise SystemError(str(exc), type=ERR_DRIVER_ERROR) - self.set_error_callback(_default_error_handler) + self._api = self.thisptr.getCurrentApi() self._callback = None self._port = None self._deleted = False + self.set_error_callback(_default_error_handler) def get_current_api(self): """Return the low-level MIDI backend API used by this instance. @@ -829,33 +1134,61 @@ cdef class MidiIn(MidiBase): print("Using JACK API for MIDI input.") """ - return self.thisptr.getCurrentApi() + return self._api def __dealloc__(self): """De-allocate pointer to C++ class instance.""" - if hasattr(self, "thisptr"): - del self.thisptr + cdef RtMidi *ptr = self._doomed if self.thisptr == NULL else self.thisptr + + if ptr == NULL: + return + + if rtmidi_callback_owner == self: + # The last reference went away in this instance's own input + # callback, and the destructor cannot stop the thread it runs on. + # So stop the thread from calling into this instance (and its + # error callback, which the destructor may call) and destroy the + # C++ instance on another thread, which waits for this one. If no + # thread can be started, it is leaked. + self._swap_error_callback(None) + + try: + (ptr).cancelCallback() + except BaseException: + pass + + ptr.setErrorCallback(NULL, NULL) + rtmidi_destroy_midiin_later(ptr) + return + + # The destructor stops the input thread, which may be waiting to run a + # callback, so let it run. + with nogil: + self._destroy(ptr) def delete(self): """De-allocate pointer to C++ class instance. - .. warning:: the instance **must not** be used anymore after calling - this method, otherwise the program will crash with a segmentation - fault! + .. note:: after calling this method, all other methods of the + instance, except ``get_current_api``, ``is_port_open``, + ``close_port`` and the ``is_deleted`` property, raise + ``InvalidUseError``. If another thread is in a call on the + instance, the C++ instance is destroyed when that call returns. + + The reason this method exists is that in some cases it is + desirable to destroy the internal ``RtMidiIn`` C++ class instance + with immediate effect, thereby closing the backend MIDI API client + and all the ports it opened. By merely using ``del`` on the + ``rtmidi.MidiIn`` Python instance, the destruction of the C++ + instance may be delayed until the last reference to it is gone, + which may only happen when the Python garbage collector runs. - The reason this potentially dangerous method exists is that in - some cases it is desirable to destroy the internal ``RtMidiIn`` - C++ class instance with immediate effect, thereby closing the - backend MIDI API client and all the ports it opened. By merely - using ``del`` on the ``rtmidi.MidiIn`` Python instance, the - destruction of the C++ instance may be delayed for an arbitrary - amount of time, until the Python garbage collector cleans up the - instance. + It is safe to call this method repeatedly, but not from the input + callback of the same instance (``InvalidUseError``): the destructor + waits for the thread that runs it. """ - if not self._deleted: - del self.thisptr - self._deleted = True + self._delete() @property def is_deleted(self): @@ -868,13 +1201,57 @@ cdef class MidiIn(MidiBase): registered. """ - if self._callback: - self.thisptr.cancelCallback() - self._callback = None + cdef RtMidi *ptr + + with cython.critical_section(self): + if self._deleted or self._closing or self._callback is None: + return + + ptr = self._enter() + + try: + # Released at the end, after the C++ call. + old = self._swap_callback(None) + (ptr).cancelCallback() + finally: + self._exit() def close_port(self): - self.cancel_callback() - MidiBase.close_port(self) + cdef RtMidi *ptr + cdef bint had_callback + + with cython.critical_section(self): + if self._deleted or self._closing: + # Deleted, or another thread is closing it already. + return + + ptr = self._enter() + self._closing = True + # Messages that arrive while the port closes are dropped. + had_callback = self._swap_callback(None) is not None + + if self._port != -1: + self._port = None + + # closePort() waits for the input thread, which may be waiting to run + # a callback. So it runs without the GIL (regular build) or with the + # thread state detached (free-threaded build), which also suspends the + # critical section; _closing makes other calls on this instance raise + # InvalidUseError meanwhile, rather than use the C++ instance with it. + try: + with nogil: + ptr.closePort() + finally: + with cython.critical_section(self): + self._closing = False + + try: + if had_callback: + # Only after closePort(): RtMidi does not synchronize + # cancelCallback() with its input thread. + (ptr).cancelCallback() + finally: + self._exit() close_port.__doc__ == MidiBase.close_port.__doc__ @@ -892,7 +1269,20 @@ cdef class MidiIn(MidiBase): """ cdef vector[unsigned char] msg_v - cdef double delta_time = self.thisptr.getMessage(&msg_v) + cdef double delta_time + cdef RtMidi *ptr + + with cython.critical_section(self): + if self._closing: + # Another thread is closing the port. + return None + + ptr = self._enter() + + try: + delta_time = (ptr).getMessage(&msg_v) + finally: + self._exit() if not msg_v.empty(): message = [msg_v.at(i) for i in range(msg_v.size())] @@ -928,7 +1318,15 @@ cdef class MidiIn(MidiBase): size of the buffers with the ``set_buffer_size`` method. """ - self.thisptr.ignoreTypes(sysex, timing, active_sense) + cdef RtMidi *ptr + + with cython.critical_section(self): + ptr = self._enter() + + try: + (ptr).ignoreTypes(sysex, timing, active_sense) + finally: + self._exit() def set_callback(self, func, data=None): """Register a callback function for MIDI input. @@ -949,17 +1347,43 @@ cdef class MidiIn(MidiBase): or the ``MidiIn`` instance is deleted. """ - if self._callback: - self.cancel_callback() - - self._callback = (func, data) - self.thisptr.setCallback(&_cb_func, self._callback) + cdef RtMidi *ptr + + with cython.critical_section(self): + ptr = self._enter() + + try: + # Released at the end, after the C++ call. + old = self._swap_callback((func, data)) + + # Otherwise RtMidi already calls _cb_func with this instance, + # which picks up the new callback. Not cancelling and setting + # it again in RtMidi avoids its input thread racing with the + # change: RtMidi does not synchronize them, and could call a + # null pointer. + if old is None: + try: + (ptr).setCallback(&_cb_func, self) + except BaseException: + self._swap_callback(None) + raise + finally: + self._exit() def set_buffer_size(self, size, count): """Set the size and number of MIDI input buffers.""" - self.thisptr.setBufferSize(size, count) + cdef RtMidi *ptr + + with cython.critical_section(self): + ptr = self._enter() + + try: + (ptr).setBufferSize(size, count) + finally: + self._exit() +@cython.no_gc_clear cdef class MidiOut(MidiBase): """Midi output client interface. @@ -997,9 +1421,16 @@ cdef class MidiOut(MidiBase): cdef RtMidiOut *thisptr - cdef RtMidi* baseptr(self): + cdef RtMidi* baseptr(self) noexcept: return self.thisptr + cdef void _forget_ptr(self) noexcept: + self.thisptr = NULL + + cdef void _destroy(self, RtMidi *ptr) noexcept nogil: + cdef RtMidiOut *midiout = ptr + del midiout + def __cinit__(self, Api rtapi=UNSPECIFIED, name=None): """Create a new client instance for MIDI output. @@ -1014,35 +1445,39 @@ cdef class MidiOut(MidiBase): except RuntimeError as exc: raise SystemError(str(exc), type=ERR_DRIVER_ERROR) - self.set_error_callback(_default_error_handler) + self._api = self.thisptr.getCurrentApi() self._port = None self._deleted = False + self.set_error_callback(_default_error_handler) def __dealloc__(self): """De-allocate pointer to C++ class instance.""" - if hasattr(self, "thisptr"): - del self.thisptr + cdef RtMidi *ptr = self._doomed if self.thisptr == NULL else self.thisptr + + if ptr != NULL: + self._destroy(ptr) def delete(self): """De-allocate pointer to C++ class instance. - .. warning:: the instance **must not** be used anymore after calling - this method, otherwise the program will crash with a segmentation - fault! + .. note:: after calling this method, all other methods of the + instance, except ``get_current_api``, ``is_port_open``, + ``close_port`` and the ``is_deleted`` property, raise + ``InvalidUseError``. If another thread is in a call on the + instance, the C++ instance is destroyed when that call returns. + + The reason this method exists is that in some cases it is + desirable to destroy the internal ``RtMidiOut`` C++ class instance + with immediate effect, thereby closing the backend MIDI API client + and all the ports it opened. By merely using ``del`` on the + ``rtmidi.MidiOut`` Python instance, the destruction of the C++ + instance may be delayed until the last reference to it is gone, + which may only happen when the Python garbage collector runs. - The reason this potentially dangerous method exists is that in - some cases it is desirable to destroy the internal ``RtMidiOut`` - C++ class instance with immediate effect, thereby closing the - backend MIDI API client and all the ports it opened. By merely - using ``del`` on the ``rtmidi.MidiOut`` Python instance, the - destruction of the C++ instance may be delayed for an arbitrary - amount of time, until the Python garbage collector cleans up the - instance. + It is safe to call this method repeatedly. """ - if not self._deleted: - del self.thisptr - self._deleted = True + self._delete() @property def is_deleted(self): @@ -1060,7 +1495,7 @@ cdef class MidiOut(MidiBase): print("Using JACK API for MIDI output.") """ - return self.thisptr.getCurrentApi() + return self._api def send_message(self, message): """Send a MIDI message to the output port. @@ -1102,4 +1537,30 @@ cdef class MidiOut(MidiBase): raise ValueError("'message' longer than 3 bytes but does not " "start with 0xF0.") - self.thisptr.sendMessage(&msg_v) + cdef RtMidi *ptr + + with cython.critical_section(self): + ptr = self._enter() + + try: + (ptr).sendMessage(&msg_v) + finally: + self._exit() + + +cdef int _gc_clear(PyObject *o) noexcept: + # tp_clear of the classes above (see "Garbage collection"). Must not raise. + (o)._drop_callbacks() + return 0 + + +cdef void _install_gc_clear() noexcept: + # Before any instance exists, so that no subclass has inherited a slot yet. + # MidiIn and MidiOut have their own tp_traverse, so they do not inherit + # their base's tp_clear. + rtmidi_set_tp_clear(MidiBase, &_gc_clear) + rtmidi_set_tp_clear(MidiIn, &_gc_clear) + rtmidi_set_tp_clear(MidiOut, &_gc_clear) + + +_install_gc_clear() diff --git a/tests/test_threads.py b/tests/test_threads.py new file mode 100644 index 00000000..82523c6b --- /dev/null +++ b/tests/test_threads.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python +"""Tests for using MidiIn / MidiOut instances from several threads. + +They matter most on free-threaded Python builds, where no GIL serializes calls +into the extension module, but they run on every build. They use virtual ports, +so they are skipped on backends without them (Windows MM). + +Each stress test runs for ``RTMIDI_STRESS_SECONDS`` (default 1) seconds. + +""" + +import gc +import os +import subprocess +import sys +import sysconfig +import textwrap +import threading +import time +import unittest + +import pytest + +import rtmidi + + +def _find_api(): + for api in (rtmidi.API_LINUX_ALSA, rtmidi.API_MACOSX_CORE): + if api in rtmidi.get_compiled_api(): + try: + rtmidi.MidiOut(api).delete() + except rtmidi.SystemError: # e.g. no ALSA sequencer device + return None + return api + + +API = _find_api() + +DURATION = float(os.environ.get("RTMIDI_STRESS_SECONDS", "1")) +# Port names are system-wide; this keeps concurrent test runs apart. +PREFIX = "rtmidi-thr-%d" % os.getpid() +# How long a thread may take to finish after it was told to stop, before the +# test counts it as hung. +JOIN_TIMEOUT = 30 +FREE_THREADED = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + + +def find_port(midi, name, timeout=2): + # Listing ports is not atomic: while other clients come and go (other + # threads here, or other programs), one can be missed or fail to list. + deadline = time.monotonic() + timeout + + while True: + try: + ports = midi.get_ports() + except rtmidi.InvalidPortError: + ports = [] + + for i, port in enumerate(ports): + if port and name in port: + return i + + if time.monotonic() > deadline: + raise AssertionError("port %r not found in %r" % (name, ports)) + + time.sleep(0.01) + + +def open_named(midi, name): + """Open the port with ``name`` in its name on ``midi``.""" + # By number: another client (another thread or program) adding or removing + # a port in between can make the number invalid, so look it up again. + deadline = time.monotonic() + 2 + + while True: + try: + return midi.open_port(find_port(midi, name)) + except rtmidi.InvalidPortError: + if time.monotonic() > deadline: + raise + + +def port_names(): + """Names of all ports that a MidiIn or MidiOut can list.""" + names = [] + probes = [rtmidi.MidiOut(API), rtmidi.MidiIn(API)] + + try: + for probe in probes: + try: + names.extend(p for p in probe.get_ports() if p) + except rtmidi.InvalidPortError: # one vanished while listing + pass + finally: + for probe in probes: + probe.delete() + + return names + + +def has_port(name): + return any(name in p for p in port_names()) + + +def run_threads(*targets, duration=DURATION): + """Run each ``target(stop)`` in its own thread for ``duration`` seconds. + + ``stop`` is a ``threading.Event``; targets loop until it is set. Re-raises + the first exception a target raised, and fails if a thread does not finish. + + """ + stop = threading.Event() + errors = [] + + def wrap(target): + def run(): + try: + target(stop) + except BaseException as exc: + errors.append(exc) + stop.set() + return run + + threads = [threading.Thread(target=wrap(t), daemon=True) for t in targets] + + for thread in threads: + thread.start() + + stop.wait(duration) + stop.set() + + for thread in threads: + thread.join(JOIN_TIMEOUT) + + hung = [t for t in threads if t.is_alive()] + + if errors: + raise errors[0] + + assert not hung, "%d thread(s) did not finish (deadlock?)" % len(hung) + + +@unittest.skipIf(not FREE_THREADED, "free-threaded Python build only") +class GILTests(unittest.TestCase): + def test_import_keeps_gil_disabled(self): + code = textwrap.dedent(""" + import sys, warnings + warnings.simplefilter("error", RuntimeWarning) + import rtmidi + print(sys._is_gil_enabled()) + """) + env = {k: v for k, v in os.environ.items() if k != "PYTHON_GIL"} + out = subprocess.run([sys.executable, "-c", code], env=env, check=True, + capture_output=True, text=True).stdout + self.assertEqual(out.strip(), "False") + + +@unittest.skipIf(API is None, "needs a backend with virtual ports") +# Polling get_message() while another thread has set a callback warns. +@pytest.mark.filterwarnings("ignore:.*a user callback is currently set:UserWarning") +class ThreadTests(unittest.TestCase): + + def setUp(self): + self.objects = [] + + def tearDown(self): + for obj in self.objects: + obj.delete() + + def midi_in(self): + obj = rtmidi.MidiIn(API, name="RtMidiThreadTest In") + self.objects.append(obj) + return obj + + def midi_out(self): + obj = rtmidi.MidiOut(API, name="RtMidiThreadTest Out") + self.objects.append(obj) + return obj + + def loopback(self, name): + """Return (midi_in, midi_out): a virtual input port and an output + connected to it.""" + midi_in = self.midi_in() + midi_in.open_virtual_port(name) + midi_out = self.midi_out() + open_named(midi_out, name) + return midi_in, midi_out + + def test_shared_midiout(self): + # Several threads send through one MidiOut; every message that arrives + # must be one that was sent, intact. + midi_in, midi_out = self.loopback(PREFIX + "-shared") + received = [] + midi_in.set_callback(lambda event, data: received.append(event[0])) + sent = [[0x90, note, 100] for note in range(4)] + + def sender(message): + def run(stop): + while not stop.is_set(): + midi_out.send_message(message) + time.sleep(0.0005) + return run + + run_threads(*[sender(m) for m in sent]) + time.sleep(0.1) + self.assertTrue(received) + self.assertEqual([m for m in received if m not in sent], []) + + def test_replace_callback_while_receiving(self): + # Replacing a callback must never free one that the input thread is + # about to call. (Not cancel_callback() here: RtMidi itself does not + # synchronize cancelling a callback with its input thread.) + midi_in, midi_out = self.loopback(PREFIX + "-callback") + wrong = [] + + def make_callback(expected): + def callback(event, data): + if data != expected: + wrong.append((expected, data)) + return callback + + def flood(stop): + while not stop.is_set(): + midi_out.send_message([0x90, 60, 100]) + + def swap(n): + def run(stop): + while not stop.is_set(): + midi_in.set_callback(make_callback(n), n) + return run + + run_threads(flood, swap(1), swap(2)) + self.assertEqual(wrong, []) + + def test_close_port_while_receiving(self): + # close_port() waits for the input thread, which may be waiting to run + # a callback. This used to deadlock on regular builds, too. + # The MidiIn is closed by the thread that created it: when another + # thread closes it, RtMidi's ALSA backend can join the wrong thread and + # hang (thestk/rtmidi#395), which is not what this test is about. + source = self.midi_out() + source.open_virtual_port(PREFIX + "-close") + shared = [] + created = threading.Event() + received = [] + + def flood(stop): + while not stop.is_set(): + source.send_message([0x90, 60, 100]) + + def open_close(stop): + midi_in = self.midi_in() + shared.append(midi_in) + created.set() + + while not stop.is_set(): + open_named(midi_in, PREFIX + "-close") + midi_in.set_callback(lambda event, data: received.append(event)) + time.sleep(0.001) + midi_in.close_port() + + def poll(stop): + if not created.wait(JOIN_TIMEOUT): + return + + midi_in = shared[0] + + while not stop.is_set(): + try: + midi_in.get_message() + midi_in.ignore_types(timing=True) + except rtmidi.InvalidUseError: + pass # close_port() in progress in another thread + + run_threads(flood, open_close, poll) + self.assertTrue(received) + + def test_delete_while_in_use(self): + # Deleted by the thread that created them; see + # test_close_port_while_receiving. + shared = {} + created = threading.Event() + started = threading.Event() + + def use(name, *calls): + def run(stop): + if not created.wait(JOIN_TIMEOUT): + return + + obj = shared[name] + started.set() + + while not stop.is_set(): + try: + for call in calls: + call(obj) + except rtmidi.InvalidUseError: + if not obj.is_deleted: + raise + return + return run + + def create_and_delete(stop): + shared["in"], shared["out"] = self.loopback(PREFIX + "-delete") + shared["in"].set_callback(lambda event, data: None) + created.set() + started.wait(JOIN_TIMEOUT) + time.sleep(DURATION / 2) + shared["out"].delete() + shared["in"].delete() + + run_threads( + use("out", lambda o: o.send_message([0x90, 60, 100]), + lambda o: o.get_port_count()), + use("in", lambda o: o.get_message(), lambda o: o.get_port_count(), + lambda o: o.set_callback(lambda event, data: None)), + create_and_delete, + ) + + for obj in (shared["in"], shared["out"]): + self.assertTrue(obj.is_deleted) + obj.close_port() # a no-op after delete() + obj.delete() # likewise + self.assertRaises(rtmidi.InvalidUseError, obj.get_port_count) + + def test_error_callback_replaced_concurrently(self): + midi_out = self.midi_out() + wrong = [] + + def make_handler(expected): + def handler(etype, msg, data): + if data != expected: + wrong.append((expected, data)) + return handler + + def trigger(stop): + while not stop.is_set(): + midi_out.open_port(9999) # invalid port: calls the error callback + midi_out.close_port() # the handler does not raise + + def swap(n): + def run(stop): + while not stop.is_set(): + midi_out.set_error_callback(make_handler(n), n) + return run + + midi_out.set_error_callback(make_handler(0), 0) + run_threads(trigger, swap(1), swap(2)) + self.assertEqual(wrong, []) + + def test_create_and_drop_in_threads(self): + # Dropping the last reference must close the client, including its + # input thread, also when a callback is set and messages arrive. + # Ports are opened by number, and numbers shift as other threads add + # and remove ports, so do that under a lock. + port_lock = threading.Lock() + + def churn(n): + def run(stop): + name = "%s-churn-%d" % (PREFIX, n) + while not stop.is_set(): + midi_in = rtmidi.MidiIn(API) + midi_in.set_callback(lambda event, data: None) + midi_out = rtmidi.MidiOut(API) + with port_lock: + midi_in.open_virtual_port(name) + open_named(midi_out, name) + midi_out.send_message([0x90, 60, 100]) + with port_lock: + del midi_in, midi_out + return run + + run_threads(*[churn(n) for n in range(3)]) + ports = rtmidi.MidiOut(API).get_ports() + self.assertEqual([p for p in ports if PREFIX + "-churn" in p], []) + + +@unittest.skipIf(API is None, "needs a backend with virtual ports") +class DeleteTests(unittest.TestCase): + + def test_delete_while_error_callback_blocks(self): + # A blocking error callback releases the critical section (and, on a + # regular build, the GIL); delete() must not free the C++ instance + # under the call that is still in it. + midi_out = rtmidi.MidiOut(API) + entered = threading.Event() + + def slow_handler(etype, msg, data): + entered.set() + time.sleep(0.2) + + midi_out.set_error_callback(slow_handler) + opener = threading.Thread(target=midi_out.open_port, args=(9999,)) + opener.start() + self.assertTrue(entered.wait(JOIN_TIMEOUT)) + midi_out.delete() + opener.join(JOIN_TIMEOUT) + self.assertFalse(opener.is_alive()) + self.assertTrue(midi_out.is_deleted) + self.assertRaises(rtmidi.InvalidUseError, midi_out.get_port_count) + + def test_delete_while_closing(self): + # close_port() waits for a callback that is still running; delete() + # from another thread meanwhile must not raise, and the C++ instance + # goes away once close_port() returns. + midi_in = rtmidi.MidiIn(API) + source = rtmidi.MidiOut(API) + source.open_virtual_port(PREFIX + "-delclose") + open_named(midi_in, PREFIX + "-delclose") + in_callback = threading.Event() + closing = threading.Event() + results = [] + + def slow_callback(event, data): + in_callback.set() + time.sleep(0.3) + + def delete_while_closing(): + closing.wait(JOIN_TIMEOUT) + time.sleep(0.05) + try: + results.append(midi_in.get_message()) # closing: None + midi_in.delete() + except Exception as exc: + results.append(exc) + + midi_in.set_callback(slow_callback) + source.send_message([0x90, 60, 100]) + self.assertTrue(in_callback.wait(JOIN_TIMEOUT)) + deleter = threading.Thread(target=delete_while_closing) + deleter.start() + closing.set() + midi_in.close_port() # in the creating thread; see test_close_port... + deleter.join(JOIN_TIMEOUT) + self.assertEqual(results, [None]) + self.assertTrue(midi_in.is_deleted) + source.delete() + + def test_delete_in_own_callback(self): + # The destructor waits for the input thread, so the callback running + # on it cannot delete its own instance. + midi_in = rtmidi.MidiIn(API) + midi_in.open_virtual_port(PREFIX + "-selfdelete") + midi_out = rtmidi.MidiOut(API) + open_named(midi_out, PREFIX + "-selfdelete") + raised = [] + done = threading.Event() + + def callback(event, data): + try: + midi_in.delete() + except rtmidi.InvalidUseError as exc: + raised.append(exc) + done.set() + + midi_in.set_callback(callback) + midi_out.send_message([0x90, 60, 100]) + self.assertTrue(done.wait(JOIN_TIMEOUT)) + self.assertEqual(len(raised), 1) + self.assertFalse(midi_in.is_deleted) + midi_in.delete() + midi_out.delete() + + +@unittest.skipIf(API is None, "needs a backend with virtual ports") +class DeallocTests(unittest.TestCase): + def test_del_closes_client(self): + # The C++ instance used to be leaked on deallocation, leaving its + # ports open and its input thread running. + midi_in = rtmidi.MidiIn(API) + midi_in.set_callback(lambda event, data: None) + midi_in.open_virtual_port(PREFIX + "-dealloc") + probe = rtmidi.MidiOut(API) + self.assertTrue(any(PREFIX + "-dealloc" in p for p in probe.get_ports())) + del midi_in + self.assertFalse(any(PREFIX + "-dealloc" in p for p in probe.get_ports())) + probe.delete() + + def test_close_port_breaks_callback_cycle(self): + # close_port() releases the input callback, so a cycle through it does + # not need the garbage collector. + midi_in = rtmidi.MidiIn(API) + midi_in.open_virtual_port(PREFIX + "-cycle") + midi_in.set_callback(lambda event, data: None, midi_in) + midi_in.close_port() + del midi_in + self.assertFalse(has_port(PREFIX + "-cycle")) + + def check_collected(self, name, make): + # ``make(name)`` returns an instance with an open virtual port ``name`` + # that is part of a reference cycle, which only the garbage collector + # can break. + gc.collect() + gc.disable() # not before we say so + + try: + instance = make(name) + self.assertTrue(has_port(name)) + del instance + self.assertTrue(has_port(name), "not in a cycle") + gc.collect() + self.assertFalse(has_port(name), "cycle was not collected") + finally: + gc.enable() + + def test_gc_collects_input_callback_data_cycle(self): + def make(name): + midi_in = rtmidi.MidiIn(API) + midi_in.open_virtual_port(name) + midi_in.set_callback(lambda event, data: None, midi_in) + return midi_in + + self.check_collected(PREFIX + "-gc-data", make) + + def test_gc_collects_subclass_bound_method_cycle(self): + class Receiver(rtmidi.MidiIn): + def start(self, name): + self.open_virtual_port(name) + self.set_callback(self.on_message) # self -> bound method -> self + self.myself = self # a cycle through __dict__ as well + + def on_message(self, event, data): + pass + + def make(name): + receiver = Receiver(API) + receiver.start(name) + return receiver + + self.check_collected(PREFIX + "-gc-method", make) + + def test_gc_collects_error_callback_data_cycle(self): + def make_in(name): + midi_in = rtmidi.MidiIn(API) + midi_in.open_virtual_port(name) + midi_in.set_error_callback(lambda *args: None, midi_in) + return midi_in + + def make_out(name): + midi_out = rtmidi.MidiOut(API) + midi_out.open_virtual_port(name) + midi_out.set_error_callback(lambda *args: None, midi_out) + return midi_out + + self.check_collected(PREFIX + "-gc-errin", make_in) + self.check_collected(PREFIX + "-gc-errout", make_out) + + def test_gc_keeps_callbacks_of_live_instance(self): + # An instance in a cycle that is still referenced keeps its callbacks. + received = [] + midi_in = rtmidi.MidiIn(API) + midi_in.open_virtual_port(PREFIX + "-gc-live") + midi_in.set_callback(lambda event, data: received.append(event[0]), midi_in) + gc.collect() + midi_out = rtmidi.MidiOut(API) + open_named(midi_out, PREFIX + "-gc-live") + midi_out.send_message([0x90, 60, 100]) + deadline = time.monotonic() + JOIN_TIMEOUT + + while not received and time.monotonic() < deadline: + time.sleep(0.01) + + self.assertEqual(received, [[0x90, 60, 100]]) + midi_in.delete() + midi_out.delete() + + def test_gc_collects_cycles_while_receiving(self): + # The collector clears an instance's callbacks while its input thread + # may be picking them up: another thread floods its port, a second one + # collects garbage, and each instance is dropped in the middle of it. + delivered = [] + + class Receiver(rtmidi.MidiIn): + received = 0 + + def start(self, name): + self.open_virtual_port(name) + self.set_callback(self.on_message, self) + + def on_message(self, event, data): + self.received += 1 + delivered.append(1) + + def flood(source, stop): + while not stop.is_set(): + try: + source.send_message([0x90, 60, 100]) + except rtmidi.RtMidiError: + return + + names = [] + + def churn(stop): + while not stop.is_set(): + name = "%s-gc-race-%d" % (PREFIX, len(names)) + names.append(name) + receiver = Receiver(API) + receiver.start(name) + source = rtmidi.MidiOut(API) + open_named(source, name) + flooding = threading.Event() + sender = threading.Thread(target=flood, args=(source, flooding)) + sender.start() + deadline = time.monotonic() + JOIN_TIMEOUT + + while not receiver.received and time.monotonic() < deadline: + time.sleep(0.001) + + del receiver + gc.collect() + flooding.set() + sender.join(JOIN_TIMEOUT) + source.delete() + + def collect(stop): + while not stop.is_set(): + gc.collect() + time.sleep(0.0005) + + run_threads(churn, collect) + self.assertTrue(delivered) + left = names + deadline = time.monotonic() + JOIN_TIMEOUT + + while left and time.monotonic() < deadline: + gc.collect() + left = [n for n in names if has_port(n)] + time.sleep(0.05) + + self.assertEqual(left, [], "instances not destroyed") + + +if __name__ == "__main__": + unittest.main()