Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

REFAC(client, audion): Various refactorings in AudioOutput #6708

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

Krzmbrzl
Copy link
Member

All changes are related to making the control flow more explicit and also more deterministic than it currently is with relying on Qt's signal/slot mechanism for handling buffer deletions. This is important as sometimes, a given buffer really needs to be removed now and not just sometime in the future.
The current code achieves this because in the specific context that the relevant functions are called, the connection that Qt will make are direct and therefore no asynchronous operations take place. But this is absolutely not obvious from the current code. It should, however, be with the refactored code.

Checks

If QMultiHash::value() was called at the exact same time that other
parts of the code mutate the map, this could lead to a data race.

By protecting the map access via the associated lock, concurrent
modification is prevented (assuming all mutating code obtain a write
lock as they should).
This is now encoded into the type system as it is illegal to dereference
a void *. Previously, calling functions just needed to know that the
passed pointer was never going to be dereferenced (in order to
determined whether or not the function call may happen while not holding
the qrwlOutputs lock.
The AudioOutput::removeUser function used to initiate a signal being
emitted that leads to the deletion of the audio buffer related to the
given user and also to the removal of that user (and buffer) from the
qmOutputs map.

Since removeUser has (thus far) always been called from the main thread
(in which the AudioOutput QThread object lives), the connection has been
direct, meaning that once removeUser returns to the caller, the user has
indeed been removed from AudioOutput.
However, if removeUser was called from a different thread, the
connection would be queued, meaning that AudioOutput still holds a
reference (or rather pointer) to that user when removeUser returns. This
can easily lead to a memory corruption issue.

Therefore, this commit ensures that removeUser always makes a direct
function call to remove all references to that user so that the caller
can be sure that AudioOutput will have forgotten about that particular
user once that function returns.
Before, the actual wiping of elements was delegated to the removeBuffer
function by means of Qt's signal/slot mechanism. This required a rather
inefficient implementation inside wipe() (using an auxiliary list), lead
to constant locking and re-locking (since a write lock is acquired and
released for every buffer individually) and made it unclear whether
after AudioOutput::wipe() returns, the buffer has actually been wiped
(due to the way the Auto connection type for Qt signal/slots work).

This commit ensures that wipe no longer delegates the work, thereby
fixing all the abovementioned issues.
AudioOutput::addFrameToBuffer used to emit a signal for deleting the old
speech buffer. However, it implicitly relied on that connection being
direct (i.e. it relied on addFrameToBuffer to be called from the main
thread) as otherwise, there would be the potential for a memory leak
where the speech buffer is not processed by removeBuffer before the
addFrameToBuffer call has replaced the associated entry in qmOutputs. At
that point speech will be the last reference pointing to that object,
and as soon as speech goes out of scope, the memory for that object
would have leaked.

In order to make the control flow more clear, we now skip the signal
emission and instead call removeBuffer directly. This has been done in a
way that also prevents the need for an additional acquisition of a write
lock.
src/mumble/AudioOutput.cpp Dismissed Show dismissed Hide dismissed
@Krzmbrzl Krzmbrzl requested a review from Hartmnt January 18, 2025 18:42
@Krzmbrzl
Copy link
Member Author

@Hartmnt I just went through our discussion in #5926 again and I think the entire business of double-deletion was wrong because the removeBuffer function that does the deletion searches for the to-be-deleted buffer in the qmOutputs map. Only if it finds it in there, will it do the deletion and in that case, it will remove the associated map entry.
Therefore, no double deletion should be possible 🤔

Copy link
Member

@Hartmnt Hartmnt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Krzmbrzl I tried to read through our old conversation, but I am not able to follow all the thinking from back then. I approve refactoring the AudioOutput logic anyway 😄

This then probably also addresses #6613 right?

What are your thoughts on back-porting this? I tend towards "too big and too prone to introduce new problems"

}

// We rely on removeBuffer not actually dereferencing the passed pointer.
// It it did, releasing the lock before calling the function cries for trouble.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// It it did, releasing the lock before calling the function cries for trouble.
// If it did, releasing the lock before calling the function cries for trouble.

@@ -1031,7 +1031,8 @@ void AudioInput::encodeAudioFrame(AudioChunk chunk) {

if (stopActiveCue) {
// Cancel active cue first, if there is any
ao->removeToken(m_activeAudioCue);
ao->invalidateToken(m_activeAudioCue);
m_activeAudioCue = {};
Copy link
Member

@Hartmnt Hartmnt Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What advantage has this change? Wouldn't we rely on future code to always make sure to set their token to {} when calling invalidateToken?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage is that the houskeeping of the token should be the responsibility of the owner. If non-owning code starts to do delete-like functionality, things can get very messy very quickly.

// that attempting to lock for read access will fail, whereas it
// would work if the calling scope only held a read lock).
assert(acquireWriteLock || !qrwlOutputs.tryLockForRead(0));
QWriteLocker locker(acquireWriteLock ? &qrwlOutputs : nullptr);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without reading up on the documentation myself, acquiring a lock on nullptr is defined and a no-op?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's what the docs state. I double-checked that myself as well :D

@Krzmbrzl
Copy link
Member Author

Krzmbrzl commented Jan 19, 2025

This then probably also addresses #6613 right?

Unfortunately, I don't think it does. These really are refactorings that leave the actual behavior (mostly) the same.

And given that the only fix in here is the potential data race on map access, which I would consider to ne very unlikely in the first place, I would tend to not backport this.

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.

2 participants