Summary
With MediaPlugin installed and any Media action on the active page, StreamController's GTK main thread spins at ~100% CPU for the lifetime of the process (one full core, from launch until exit). The deck keeps working, so the burn is easy to miss unless you look at top. On Wayland the constant main-loop churn can also degrade compositor responsiveness (e.g. dropped frames in video playback while StreamController runs).
Root cause
StreamController core sets the GLib main loop as the default D-Bus main loop at startup (main.py — DBusGMainLoop(set_as_default=True)).
MediaController.__init__ then uses the shared session bus singleton:
self.session_bus = dbus.SessionBus()
Because of the default main loop, this connection's watch sources are attached to the GTK main context. But all of MediaController's MPRIS calls (list_names, Properties.Get, play/pause/next, album art, ...) are blocking calls made from the tick_actions thread, not from the main loop.
This is the classic dbus-python threads pathology: the blocking thread and the main loop share one connection. The connection's GSource keeps reporting "messages pending dispatch" while the tick thread owns the connection, so the main loop wakes, dispatches nothing, and immediately re-polls — forever.
Evidence
py-spy dump shows the main thread permanently "active" inside the GLib main loop with no Python callback ever on the stack:
Thread (active): "MainThread"
run (gi/overrides/Gio.py:138)
...
main (main.py:574)
A 10-second native profile (py-spy record --native) of the main thread resolves to:
- ~43% in
g_wakeup_signal eventfd write()s inside g_main_context_check/g_main_context_wait
- ~33% in
ppoll
- remainder in
g_main_context_dispatch bookkeeping — i.e. pure wakeup ping-pong, no real work
The MPRIS calls visible on the tick thread during the same profile: update_players (MediaController.py:26), get_matching_ifaces (MediaController.py:47/62), dbus/connection.py call_blocking.
Process stats from an affected machine: 60 hours of CPU accumulated over 70 hours of uptime, ~55 of those hours on the main thread alone; every other thread near idle.
Environment
- StreamController source install (recent main), GNOME Wayland
- Python 3.14, dbus-python 1.4.0, GLib 2.88
- Reproduces from launch; independent of which player is running (tested with Firefox and MPD via MPRIS)
Fix
Give MediaController a private connection with no main-loop integration:
self.session_bus = dbus.SessionBus(private=True, mainloop=dbus.mainloop.NULL_MAIN_LOOP)
Blocking calls then poll their own socket (thread-safe in libdbus) and never touch the GLib context. The plugin registers no D-Bus signal receivers, so nothing here needs main-loop integration. Verified on a live deployment: main-thread CPU drops from a sustained 99.9% to idle and all MPRIS functionality keeps working. PR incoming.
Possibly related: some reports in StreamController/StreamController#608 (high idle CPU) may include this on top of the scroll-label issue, since this bug ships with one of the most popular plugins.
Summary
With MediaPlugin installed and any Media action on the active page, StreamController's GTK main thread spins at ~100% CPU for the lifetime of the process (one full core, from launch until exit). The deck keeps working, so the burn is easy to miss unless you look at
top. On Wayland the constant main-loop churn can also degrade compositor responsiveness (e.g. dropped frames in video playback while StreamController runs).Root cause
StreamController core sets the GLib main loop as the default D-Bus main loop at startup (
main.py—DBusGMainLoop(set_as_default=True)).MediaController.__init__then uses the shared session bus singleton:Because of the default main loop, this connection's watch sources are attached to the GTK main context. But all of MediaController's MPRIS calls (
list_names,Properties.Get, play/pause/next, album art, ...) are blocking calls made from thetick_actionsthread, not from the main loop.This is the classic dbus-python threads pathology: the blocking thread and the main loop share one connection. The connection's GSource keeps reporting "messages pending dispatch" while the tick thread owns the connection, so the main loop wakes, dispatches nothing, and immediately re-polls — forever.
Evidence
py-spy dumpshows the main thread permanently "active" inside the GLib main loop with no Python callback ever on the stack:A 10-second native profile (
py-spy record --native) of the main thread resolves to:g_wakeup_signaleventfdwrite()s insideg_main_context_check/g_main_context_waitppollg_main_context_dispatchbookkeeping — i.e. pure wakeup ping-pong, no real workThe MPRIS calls visible on the tick thread during the same profile:
update_players (MediaController.py:26),get_matching_ifaces (MediaController.py:47/62),dbus/connection.py call_blocking.Process stats from an affected machine: 60 hours of CPU accumulated over 70 hours of uptime, ~55 of those hours on the main thread alone; every other thread near idle.
Environment
Fix
Give MediaController a private connection with no main-loop integration:
Blocking calls then poll their own socket (thread-safe in libdbus) and never touch the GLib context. The plugin registers no D-Bus signal receivers, so nothing here needs main-loop integration. Verified on a live deployment: main-thread CPU drops from a sustained 99.9% to idle and all MPRIS functionality keeps working. PR incoming.
Possibly related: some reports in StreamController/StreamController#608 (high idle CPU) may include this on top of the scroll-label issue, since this bug ships with one of the most popular plugins.