Hi JUCE team,
I recently struggled a deep bug in legacy code of a customer because of a change in ListenerList (commit in Jan 15 2024 b05b73fb490e4520df4799f26b54e5bca746d025) and I would like to bring it to your attention because I suspect that I won’t be the only victim.
ListenerList has the appearance of introducing a lock to make it thread-safe when you read in most methods: “const ScopedLockType lock (listeners->getLock());”
However it isn’t because the CriticalSection is disabled by default:
using ScopedLockType = typename ArrayType::ScopedLockType;
ArrayType = Array<ListenerClass*> // ListenerList default template argument
TypeOfCriticalSectionToUse = DummyCriticalSection // Array default template argument
I think that you will agree that this isn’t obvious to the first reader of the code and it may create misunderstandings.
On the other hand, before the Jan 15 commit, if add(ListenerClass* listenerToAdd) and remove(ListenerClass* listenerToAdd) were called at the construction and destruction of a class, without any concurrent calls to call(Callback&& callback), most code would get away with using ListenerList without CriticalSections and concurrent calls to call(Callback&& callback) were fine and without crashes as long as the functions invoked by the listeners were thread safe.
However, in the commit in Jan 15 b05b73fb490e4520df4799f26b54e5bca746d025, the following code was added to prevent edge cases (I quote the most critical bit but there are changes in several methods):
indices->push_back (&index);
const ScopeGuard scope { [i = indices, &index]
{
i->erase (std::remove (i->begin(), i->end(), &index), i->end());
} };
for (const auto end = localListeners->size(); index < jmin (end, localListeners->size()); ++index)
As a result, if you were using the default ListenerList (without extra arguments to declare a CriticalSection), this started causing data runs in concurrent calls to call(Callback&& callback) that didn’t exist before.
In other words, those changes made ListenerList less thread safe and made old code crash because of those data runs.
As a result I think that those bits of code introduced to handle edge cases should be only run if the CriticalSection is defined. Here’s an idea for the code in the example but there are probably much better ways of doing it:
if (!std::is_same<ArrayType::ScopedLockType, DummyCriticalSection::ScopedLockType>())
{
indices->push_back (&index);
const ScopeGuard scope { [i = indices, &index]
{
i->erase (std::remove (i->begin(), i->end(), &index), i->end());
} };
}
Alternatively you can add a CriticalSection to your legacy code but this will introduce locks in new places, and yes you guessed, in the code I had to fix those calls were in the audio thread! ![]()
