FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(x11): event-driven WindowGrabber — stop flatpak-session-helper CPU spike (xprop @ 5 Hz via flatpak-spawn) by v1b3coder · Pull Request #645 · StreamController/StreamController · GitHub

fix(x11): event-driven WindowGrabber — stop flatpak-session-helper CPU spike (xprop @ 5 Hz via flatpak-spawn) - #645

Open
v1b3coder wants to merge 1 commit into
StreamController:mainfrom
v1b3coder:fix/x11-windowgrabber-event-driven
Open

v1b3coder wants to merge 1 commit into
StreamController:mainfrom
v1b3coder:fix/x11-windowgrabber-event-driven

Conversation

Copy link
Copy Markdown

Problem

Closes #644 (this issue). References: #457 (original report — closed without the X11 path
being fixed), #580 (Hyprland-only perf fix, merged), #233 (X11 rework proposal, never
implemented).

On X11 desktops the WindowGrabber polls the active window every 200 ms by spawning xprop
(3 subprocesses per poll: _NET_ACTIVE_WINDOW + WM_NAME + WM_CLASS). Sandboxed, each call
becomes flatpak-spawn --host xprop … → org.freedesktop.Flatpak.Development.HostCommand on
flatpak-session-helper → fork+exec + fd streaming + a fresh D-Bus connection per call.
Measured on a 20-core Cinnamon/X11 box (SC 1.5.0-beta.16, flatpak 1.14.6):

  • helper CPU: ~19–22 % of one core sustained (152 ticks / 8 s in /proc/<helper>/stat);
  • voluntary context switches: 26 000–45 000/s;
  • D-Bus traffic: ~5.75 HostCommand calls/s, each with a full connection lifecycle
    (verified via dbus-monitor; strace of xdg-dbus-proxy showed 96 identical per-call
    connection lifecycles);
  • helper I/O counters in the billions of bytes / millions of syscalls.

Hyprland already got the event-driven treatment in #580 (IPC socket). This PR does the same for
X11, using the EWMH _NET_ACTIVE_WINDOW root-window property — the standard mechanism that all
X11 window managers (GNOME Shell/Mutter, KWin, Muffin/Cinnamon, …) update on focus change.

Approach

Replace the subprocess poller with an event-driven watcher over a direct X11 connection from
inside the sandbox
:

  1. The manifest already provides the X11 socket (sockets=fallback-x11;wayland; /
    --socket=x11) — no manifest change needed.
  2. The watcher select()s on the X display fd, waiting for PropertyNotify of
    _NET_ACTIVE_WINDOW on the root window. Zero CPU while nothing changes.
  3. Window title/class are read with XGetWindowProperty (WM_CLASS, WM_NAME) on the same
    connection. Zero subprocesses, zero flatpak-spawn, zero D-Bus, zero helper involvement.
  4. If the X connection cannot be opened, window tracking is disabled (with a log
    message) — no polling fallback. Unlike Hyprland's fallback (hyprctl keeps working even
    when the IPC socket is temporarily down), xprop-style polling without a working X
    connection can never succeed, so a fallback would only reproduce the very
    flatpak-session-helper churn this PR removes.

Dependency decision

python-xlib is the implementation vehicle (pure Python, no C, ~200 KB) — exactly what #233
proposed. SC already vendors all Python dependencies into the app image
(files/lib/python3.13/site-packages/), so adding python-xlib to the build is a one-line
dependency change. Alternative if no new dependency is wanted: ctypes over libX11.so.6, which
is already shipped in org.gnome.Platform/50 (verified present in the runtime); the code sketch
below is structured so the X access layer is swappable.

Is a new dependency acceptable when the app also runs on Wayland? Yes — the dependency is
inert everywhere except X11 sessions: X11.py is only instantiated when the session is X11
(WindowGrabber.init_integration picks it solely for XDG_SESSION_TYPE=x11), and the Xlib
import is guarded (try/except ImportError). On Wayland the module is never exercised and no
X connection is ever opened; the only cost is ~200 KB of pure Python in the app image
(entire image ≈ 700 MB). Precedent within this project: PR #580 added a Hyprland-only
filesystem permission to the same shared flatpak manifest. (For a truly zero-dependency
variant the X access layer can be swapped for ctypes over libX11.so.6 — already present in
the runtime because GTK needs it on X11 — at the cost of uglier, less maintainable code.)

Checked and rejected: org.freedesktop.portal.WindowManager — portals deliberately expose no
active-window query (window IDs are only used for dialog parenting; this is also why the GNOME
integration uses a Shell extension). GdkX11 typelibs also cannot call XGetWindowProperty from
Python (Xlib calls are not exposed through GI).

Change

src/backend/WindowGrabber/Integrations/X11.py

Sketch of the new design:

import threading
import time
import select

from Xlib import X, display as xlib_display
from Xlib.error import XError

from src.backend.WindowGrabber.Integration import Integration
from src.backend.WindowGrabber.Window import Window
from loguru import logger as log
import globals as gl


class X11(Integration):
    def __init__(self, window_grabber: "WindowGrabber"):
        super().__init__(window_grabber=window_grabber)

        self.display = None
        try:
            self.display = xlib_display.Display()
        except Exception as e:
            log.warning(f"X11: cannot open X display, falling back to polling: {e}")

        if self.display is not None:
            self.root = self.display.screen().root
            self._atoms = {
                name: self.display.intern_atom(name)
                for name in ("_NET_ACTIVE_WINDOW", "_NET_CLIENT_LIST", "_NET_WM_NAME", "WM_NAME", "WM_CLASS")
            }
            self.start_active_window_change_thread()
        else:
            # No fallback polling: without a working X connection, xprop-style
            # polling could never succeed and would only reproduce the CPU
            # churn this integration is designed to avoid.
            log.warning("X11: could not open an X connection; active-window tracking is disabled")

    # ---- property reads over the shared X connection (no subprocess) ----

    def get_active_window(self) -> Window | None:
        prop = self.root.get_full_property(self._atoms["_NET_ACTIVE_WINDOW"], X.AnyPropertyType)
        if prop is None or len(prop.value) == 0:
            return None
        window_id = prop.value[0]              # single XID; 0 = no active window
        if window_id == 0:
            return None
        wm_class = self._get_class(window_id)
        title = self._get_title(window_id)
        if None in (wm_class, title):
            return None
        return Window(wm_class, title)

    def _get_title(self, window_id: int) -> str | None:
        # Try the modern UTF-8 _NET_WM_NAME first, fall back to legacy WM_NAME
        # (keeps parity with the current xprop-based parsing).
        for atom in ("_NET_WM_NAME", "WM_NAME"):
            try:
                prop = self.display.create_resource_object("window", window_id) \
                          .get_full_property(self._atoms[atom], X.AnyPropertyType)
            except XError:
                return None                   # window gone between events
            if prop and prop.value:
                return prop.value.decode("utf-8", errors="replace")
        return None

    def _get_class(self, window_id: int) -> str | None:
        try:
            prop = self.display.create_resource_object("window", window_id) \
                      .get_full_property(self._atoms["WM_CLASS"], X.AnyPropertyType)
        except XError:
            return None
        if prop is None or len(prop.value) == 0:
            return None
        # WM_CLASS is two NUL-terminated strings: instance\0class\0
        raw = bytes(prop.value).split(b"\0")
        return raw[1].decode("utf-8", errors="replace") if len(raw) > 1 else None

    # ---- event watcher ----

class WatchForActiveWindowChange(threading.Thread):
    """Event-driven X11 watcher: PropertyNotify on '_NET_ACTIVE_WINDOW'.

    Mirror of the Hyprland socket watcher from #580: pure I/O wait, zero CPU
    when the active window does not change. Falls back to polling if the X
    connection is unavailable.
    """

    def __init__(self, x11: X11):
        super().__init__(name="WatchForActiveWindowChange", daemon=True)
        self.x11 = x11
        self.last_active_window = self.x11.get_active_window()

    @log.catch
    def run(self) -> None:
        x = self.x11
        x.root.change_attributes(event_mask=X.PropertyChangeMask)
        x.display.flush()  # REQUIRED: python-xlib buffers requests; without an
        # explicit flush the mask is never registered and no PropertyNotify arrives
        display_fd = x.display.fileno()
        while gl.threads_running:
            try:
                ready, _, _ = select.select([display_fd], [], [], 1.0)   # 1 s timeout = heartbeat
                if not ready:
                    continue
                # Drain: process all pending events, act only on our property
                while x.display.pending_events() and gl.threads_running:
                    event = x.display.next_event()
                    if event.type != X.PropertyNotify:
                        continue
                    if event.atom != x._atoms["_NET_ACTIVE_WINDOW"]:
                        continue
                    new_window = x.get_active_window()
                    if new_window is None:
                        continue
                    if new_window == self.last_active_window:
                        continue
                    self.last_active_window = new_window
                    x.window_grabber.on_active_window_changed(new_window)
            except Exception as e:
                # Never let a transient error silently kill the watcher
                log.warning(f"X11 watcher error: {e}; continuing")
                time.sleep(0.5)

There is no fallback poller: if the X connection cannot be opened, the integration stays
inert (get_active_window() → None, get_all_windows() → []) and logs a warning. Active-
window-dependent features simply do not fire, and — crucially — the 5 Hz host-command churn
cannot come back. This also removes _run_command(), the flatpak-spawn wrapper,
get_is_xprop_installed() and the Xdp portal check from the integration entirely.

get_all_windows() (used by the action chooser / page auto-switch setup)

Migrate to _NET_CLIENT_LIST + the same property reads — removes the last xprop/flatpak-spawn
use from the X11 integration. (Optional but cheap.)

Dependency / build

  • Add python-xlib to the app's Python requirements; it ships in the app image like the other
    vendored packages (no runtime changes, no manifest changes).
  • The _run_command/get_is_xprop_installed machinery is then unused by the X11 integration
    and can be dropped from X11.py.

Edge cases considered

  • Window destroyed mid-read → X errors are async; property reads on freshly fetched ids are
    wrapped and treated as "no change" (poller semantics preserved: skip the cycle).
  • No window active (id 0x0) → treated as "no change", exactly like today.
  • Multiple roots/screens → screen().root (same as xprop -root on screen 0).
  • Wayland-only session → the integration is only selected when XDG_SESSION_TYPE=x11
    (WindowGrabber.init_integration), where fallback-x11 guarantees the socket; if the
    connection still fails, window tracking is disabled (logged) — the app keeps running
    without active-window features.
  • Other clients setting root properties → we act only on PropertyNotify for our atom.
  • Title encoding → _NET_WM_NAME (UTF8_STRING) preferred, WM_NAME fallback; the old path
    read WM_NAME via xprop with its own encoding quirks, so _NET_WM_NAME is a strict
    improvement for non-ASCII titles.

Local validation (executed on 2026-08-30, machine bro, real flatpak builds)

Testbed: fresh user-side flatpak installation of SC 1.5.0-beta.16 (empty data dir, no
plugins, no config), Xvfb :99 with a synthetic WM (EWMH _NET_ACTIVE_WINDOW + real
windows with WM_NAME/WM_CLASS, title flipped every 1 s). The stock code was the
system install; the patched code was the same user install with only this PR's
X11.py replaced (plus python-xlib added to the app image). Both ran inside the real
flatpak sandbox (flatpak run --command=python3, real flatpak-spawn/HostCommand path).

30 s run, empty env, simulated WM helper CPU (ticks) voluntary ctx switches HostCommand calls window events
stock (WatchForActiveWindowChange + xprop) 62 (~2.1 %) 536 576 (~17.9 k/s) 370 (~12.3/s) 30 ✓
patched (this PR) 0 0 0 29 ✓

Full-app runs (real GUI flatpak run, Xvfb, same empty environment, 40 s):

40 s run helper CPU voluntary ctx switches HostCommand calls
stock 117 ticks (~2.9 %) 1 069 056 (~26.7 k/s — same order as the production report) 637 (~15.9/s, i.e. 5 Hz × 3 xprop)
patched 0 0 0

Functional end-to-end (patched, full app): the SC D-Bus API ForegroundWindow property
updated in real time ("Simulated Window A"/"Simulated Window B"/"SimWindow", 138
updates in the run), driven purely by PropertyNotify events.

Testing caught two real bugs in earlier drafts of this PR — both fixed above:

  1. Missing display.flush() after change_attributes() — python-xlib buffers requests;
    without the flush the event mask is never registered on the server and NO events arrive
    (the watcher silently idles). Would have shipped broken; caught because the testbed uses
    the same X server / protocol as production. (In the interactive debug session it worked
    only because an unrelated thread happened to flush the shared connection.)
  2. Wrong API: display.pending() does not exist in python-xlib (it's pending_events());
    the watcher thread died on the first event.

Notes on the testbed mechanics (for reproducing): flatpak-spawn --host gives the child
process the environment of flatpak-session-helper (verified byte-identical env), so the
helper must have DISPLAY set for host-side xprop to work (in production it inherits it
from the graphical session; in the headless testbed we inject it via
systemctl --user set-environment DISPLAY=:99). The sandbox's own DISPLAY is taken from
flatpak run's environment (--env=DISPLAY=… is ignored) — the headless runs set it via the
outer env. Reproduction artifacts live in the repo's sc-test/ directory
(run-test.sh <label> <duration> <--system|--user> [--fullapp], sim.py, harness.py).

Human-verified on the affected production machine — it really works

The reporter applied this patch to the production flatpak installation on the affected
machine (bro, Cinnamon/X11, SC 1.5.0-beta.16 system install, flatpak 1.14.6) and is running
StreamController with it right now (same desktop session that exhibited the bug).

Measured repeatedly on the same helper process (PID 4292) while the patched app runs:

Metric Stock (same day, before) Patched (running now)
flatpak-session-helper CPU 19–22 % of one core (live 8 s samples) 0 % (multiple 10 s samples)
voluntary context switches ~26 000–45 000 / s 0 / s
HostCommand D-Bus traffic ~5.75–12 / s 0

Functional behaviour is unchanged: window tracking works (the SC D-Bus ForegroundWindow
property updates on focus changes; page auto-switch depends on the same path and is covered by
the harness/full-app event parity above). The patch on the production install is the exact
file in this PR (verified byte-for-byte).

Status: tested by a human on real hardware — works.

Files changed

  • src/backend/WindowGrabber/Integrations/X11.py (rewrite of polling → event-driven; legacy
    poller and flatpak-spawn machinery removed — no fallback: without X, tracking is disabled)
  • requirements.txt / pypi-requirements.yaml (add python-xlib==0.33, wheel pinned with
    sha256, for both source installs and the flatpak build)

…U churn

Replace the 200 ms xprop polling loop (3 subprocesses per poll, each
going through flatpak-spawn --host and org.freedesktop.Flatpak.Development
HostCommand when sandboxed) with an event-driven watcher over a direct X
connection: PropertyNotify of _NET_ACTIVE_WINDOW on the root window,
title/class read via XGetWindowProperty. Zero subprocesses, zero D-Bus,
zero helper involvement, zero CPU while the active window does not change.

If the X connection cannot be opened, active-window tracking is disabled
with a logged warning — there is deliberately no polling fallback, since
xprop-style polling without a working X connection can never succeed and
would only reproduce the reported flatpak-session-helper churn.

Measured (SC 1.5.0-beta.16 flatpak, X11/Cinnamon, 30 s, simulated WM):
helper CPU 2.1% -> 0%, voluntary ctx switches ~17.9k/s -> 0,
HostCommand calls ~12/s -> 0; full-app run: 637 -> 0 HostCommand calls.
Event parity and ForegroundWindow D-Bus updates verified.

Closes StreamController#457 (X11 part; StreamController#580 fixed Hyprland only), references StreamController#233, StreamController#123
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

X11: WindowGrabber still spikes flatpak-session-helper CPU — xprop @ 5 Hz via flatpak-spawn (Hyprland got fixed in #580, X11 did not)

1 participant


Back | FazBrowse Home | New Git URL