Skip to content

Use private non-GLib-integrated D-Bus connection to fix main-loop CPU spin#33

Open
km1tch wants to merge 1 commit into
StreamController:mainfrom
km1tch:fix/dbus-mainloop-cpu-spin
Open

Use private non-GLib-integrated D-Bus connection to fix main-loop CPU spin#33
km1tch wants to merge 1 commit into
StreamController:mainfrom
km1tch:fix/dbus-mainloop-cpu-spin

Conversation

@km1tch

@km1tch km1tch commented Jul 12, 2026

Copy link
Copy Markdown

Fixes #32 — StreamController's GTK main thread spins at ~100% CPU whenever MediaPlugin is active.

What

One functional line: MediaController now opens a private D-Bus connection with no main-loop integration (mainloop=dbus.mainloop.NULL_MAIN_LOOP) instead of the shared dbus.SessionBus() singleton.

Why

StreamController core sets DBusGMainLoop(set_as_default=True), so the shared session bus attaches its watch sources to the GTK main context. MediaController's MPRIS calls are all blocking calls from the tick_actions thread, which leaves the connection's GSource permanently "ready" while the tick thread owns the connection — the main loop wakes, dispatches nothing, and re-polls forever, pegging one core for the lifetime of the process. Full analysis with py-spy native profiles in the linked issue.

With a private non-integrated connection, blocking calls poll their own socket (thread-safe in libdbus) and never touch the GLib context.

Safety

  • The plugin registers no D-Bus signal receivers (connect_to_signal / add_signal_receiver), so nothing in this repo needs main-loop integration.
  • private=True avoids mutating behavior of the shared singleton for core or other plugins.

Tested

Live deployment on GNOME Wayland (Python 3.14, dbus-python 1.4.0, GLib 2.88): main-thread CPU drops from a sustained 99.9% to idle after this change; play/pause/next, metadata, album art, and player discovery verified working against Firefox and MPD.

Diagnosed and fixed with help from Claude (Fable 5) — attribution retained in the commit trailer.

… spin

The shared dbus.SessionBus() singleton attaches its watch sources to the
default GLib main context, because StreamController sets
DBusGMainLoop(set_as_default=True) at startup. MediaController then makes
blocking MPRIS calls (list_names, Properties.Get, ...) from the
tick_actions thread. The blocking thread and the GTK main loop end up
sharing one connection: the connection's GSource keeps reporting messages
pending dispatch while the blocking thread owns the connection, so the
main loop wakes, dispatches nothing, and immediately re-polls - forever.
The result is one core pegged at 100% for the lifetime of the process.

Confirmed with py-spy: the main thread is always inside
g_main_context_iteration with no Python callback on the stack; a native
profile shows ~43% of main-thread time in g_wakeup_signal eventfd writes
and ~33% in ppoll.

Fix: give MediaController a private connection with no main-loop
integration (mainloop=dbus.mainloop.NULL_MAIN_LOOP). Blocking calls then
poll their own socket, which is thread-safe and never touches the GLib
context. This plugin registers no D-Bus signal receivers, so nothing
here needs main-loop integration.

Tested on a live deployment (GNOME Wayland, Python 3.14, dbus-python
1.4.0, GLib 2.88): main-thread CPU went from a sustained 99.9% to idle,
and MPRIS control/metadata calls kept working against Firefox and MPD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared dbus.SessionBus + blocking MPRIS calls from tick thread spin the GTK main loop at 100% CPU

1 participant