-
Notifications
You must be signed in to change notification settings - Fork 15
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
Implement AlsaMixerObserver with actor model approach #34
base: main
Are you sure you want to change the base?
Conversation
It allows to recover after temporary device disconnection
616a754
to
414d880
Compare
It seems context switch can occur between os.write() calls and change expected state in the end
def restart_observer(self, exc=None): | ||
self._stop_observer() | ||
self._observer = AlsaMixerObserver.start( | ||
self._await_mixer(exc), self.actor_ref.proxy() | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like the call to AlsaMixerObserver.start()
should be moved to a new method named _start_observer()
, to match what you've done for the "stop observer" call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It came to my mind, but there's more than one way to implement _start_observer
:
def _start_observer(self, exc):
# Perhaps we should raise an exception is observer is already running?
self._observer = AlsaMixerObserver.start(exc, self.actor_ref.proxy())
def _start_observer(self, exc):
# This check is meaningless iif _start_observer()
# is used only within AlsaMixer class
if self._observer is None:
self._observer = AlsaMixerObserver.start(exc, self.actor_ref.proxy())
def _start_observer(self, exc):
# This is actually restart_observer()
self._stop_observer()
self._observer = AlsaMixerObserver.start(exc, self.actor_ref.proxy())
I struggled to decide which one to choose, so being guided by "There should be one -- and preferably only one -- obvious way to do it" I introduced restart_observer()
as public method. This way _start_observer()
would then need to be implemented as no. 1 from above, which is very repetitive in my opinion and unnecessary because it's internal method.
Does such reasoning make sense or it's still better to explicitly define _start_observer()
as in no. 1 only for the sake of consistency?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think it's important for consistency. It helps define the One True Way of doing what needs to be done.
mopidy_alsamixer/mixer.py
Outdated
except alsaaudio.ALSAAudioError as exc: | ||
logger.debug(f"Getting mute state failed: {exc}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious why this logging has been removed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed it for consistency (get_volume
has no such logging) and because I thought this method (as well as get_volume
) may be called repeatedly generating lots of error messages due to the actual error being suppressed.
I'm grateful you bring this up because now I think it's better to do the opposite and add logging to get_volume
too, as ALSAAudioError
can occur during alsaaudio.Mixer.getvolume
, not only during alsaaudio.Mixer
.
mopidy_alsamixer/mixer.py
Outdated
# FIXME: Remove when a new version of pyalsaaudio is released | ||
# See https://github.com/larsimmisch/pyalsaaudio/pull/108 | ||
def check_fd(fd): | ||
return fd != -1 and fd != struct.unpack("I", b"\xFF\xFF\xFF\xFF")[0] | ||
|
||
def stop(self): | ||
self.running = False | ||
super().__init__( | ||
fds=tuple( | ||
(fd, event_mask | select.EPOLLET) | ||
for (fd, event_mask) in self._mixer.polldescriptors() | ||
if check_fd(fd) | ||
) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It isn't super clear exactly what code needs updating (and how) when a new version of pyalsaaudio is released.
Is this code forwards-compatible, or will it break with newer versions of pyalsaaudio?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added clarifications. It's compatible with newer versions, I tried to underline it now phrasing it as "it can be removed".
Compatibility follows from the fact that max absolute value of correct file descriptor data type -- signed int32 -- is about a half of unsigned int32 (used in current latest release). So this check essentially becomes just garbage.
Thank you for commenting. It allowed me to add a few more improvements about logging. |
class AlsaMixerObserver(threading.Thread): | ||
daemon = True | ||
name = "AlsaMixerObserver" | ||
class AlsaMixerObserver(PollingActor): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could set use_daemon_thread = True
as a class variable here to make sure this thread will not block the application from exiting.
Ref https://pykka.readthedocs.io/en/stable/runtimes/threading/#pykka.ThreadingActor.use_daemon_thread
This PR addresses several issues and proposes a solution based on a customized Pykka actor runtime. I tried to keep it as simple as I could.
Issues:
poll 2
:So ALSA devices ideally must provide at least 2 poll descriptors, one for notifications about changes (a read end of some pipe) and another for notifications about device disconnection (a write end of some other pipe, which therefore can't generate read events and leads to use of 2 descriptors).
It's important to mention that
poll()
behaviour is undefined if FD was closed during polling [3]. On my system a blockingpoll()
continues to block execution after FD has been closed, so it can lead to an unresponsive state of application ifpoll()
isn't interrupted by something else later.Actual event polling is currently implemented as an infinite loop. I'm not sure to what degree, but wasted CPU ticks on context switching can matter for users on battery power. Increasing
timeout
value could decrease this, but in current state it also leads to unpleasant delays on application shutdown.Currently once a sound device has been disconnected, Mopidy-ALSAMixer enters a broken state and requires restart of Mopidy to work again, even though Mopidy itself continues to play music if I plug device back.
(Related to Pykka) If some actor enters blocking call, it won't process any messages and will block all senders until it finishes this call. For example, if an actor calls simple
time.sleep
, it will break normal shutdown of whole application, even though under normal circumstances it shuts down due toSIGINT
signal caught by Python and raised as an exception. To demonstrate there's simple script [4]. I understand there's no general way no interrupt any kind of blocking call due to Python's nature, but in case ofpoll
there actually is a way, which I exploited in this PR. There's also a working example how to use this new actor runtime [4]. Potentially this approach can be used to implement actual interruptable sleep() call as part of Pykka's interfaces. I haven't thoroughly read Pykka's issue tracker, but i believe it's mentioned somewhere.Proposal:
The general idea is to use auxiliary pipe to interrupt
select.epoll.poll()
blocking call. Any message sent to an actor is preceded with a write operation to this pipe, so once Python internal execution context switches to the blocked thread, it will be able to continue and handle received messages, returning back to blocking interruptable sleep thereafter.I tested this PR on x86_64 with Pipewire backend and aarch64 with bluez-alsa.