…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
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):
(verified via dbus-monitor; strace of xdg-dbus-proxy showed 96 identical per-call
connection lifecycles);
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:
--socket=x11) — no manifest change needed.
_NET_ACTIVE_WINDOW on the root window. Zero CPU while nothing changes.
connection. Zero subprocesses, zero flatpak-spawn, zero D-Bus, zero helper involvement.
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:
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
vendored packages (no runtime changes, no manifest changes).
and can be dropped from X11.py.
Edge cases considered
wrapped and treated as "no change" (poller semantics preserved: skip the cycle).
(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.
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).
Full-app runs (real GUI flatpak run, Xvfb, same empty environment, 40 s):
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:
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.)
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:
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
poller and flatpak-spawn machinery removed — no fallback: without X, tracking is disabled)
sha256, for both source installs and the flatpak build)