Honor D-Bus idle inhibits again: screensaver no longer fires over video playback - #8452
Honor D-Bus idle inhibits again: screensaver no longer fires over video playback#8452itz4blitz wants to merge 6 commits into
Conversation
…, omacom#7220, omacom#7199) Quattro's idle service honors only the Wayland idle-inhibit protocol, and nothing has owned org.freedesktop.ScreenSaver since hypridle was replaced, so an inhibit requested over D-Bus — every browser playing a video, VLC — was dropped and the screensaver fired over the playing video. omarchy-idle-inhibit-daemon owns org.freedesktop.ScreenSaver (both object paths: Chromium calls /org/freedesktop/ScreenSaver, VLC and Firefox the legacy /ScreenSaver) and org.freedesktop.PowerManagement, reaps inhibits whose client left the bus, and publishes its state as JSON to the runtime dir. The idle service reads that state through a tolerant parser (a daemon that is down reads as no inhibit, never as a permanent one) and holds its idle cycle off while any inhibit is held, like stay-awake. D-Bus activation is rejected with evidence — Chromium and VLC probe with NameHasOwner, which never auto-starts — so the daemon ships as a graphical-session service, enabled for existing installs by migration. No new dependencies: python-gobject is already in the base package set. plans/idle-inhibit.md records the design and the rejected approaches.
Create the temp file at 0600 in the same step that writes it: a plain open() honors the umask first, which under a permissive umask left a window where holder names were world-readable. Refuse to run without XDG_RUNTIME_DIR rather than invent a state directory under $HOME or /tmp — a predictable home/tmp path is the exact class the diagnostics fixes removed. Quiet the PyGObject register_object deprecation notice that would otherwise land in the journal on every start.
Four findings from the independent review, each fixed test-first: - Restart=on-failure: standing down when the names are owned elsewhere is a clean exit 0, and Restart=always restarts on clean exits too — the stand-down respawned forever on machines with their own screensaver daemon. - Phantom inhibits: a SIGKILL mid-inhibit left the state file claiming holds nobody had, and no code path could clean it up. Snapshots now carry the serving pid, and the probe (bin/omarchy-idle-inhibit-probe) falls silent for a dead pid — daemon death self-heals without any cross-process coordination. An orderly stop still republishes an empty snapshot first. - GetActive/ActiveChanged dropped: they mean "screensaver blanked", which this daemon cannot honestly report — emitting them for inhibit-held state was exactly inverted. HasInhibitChanged stays; it has unambiguous inhibit semantics and Clight-class clients listen for it. - A publish failure can no longer hang a D-Bus caller: handlers always answer, failures land in stderr. Oversized app/reason strings are truncated, outstanding inhibits capped at LimitsExceeded, and the uint32 cookie counter wraps instead of overflowing GLib's variant.
The SIGTERM test caught a bug the review missed: a Python signal handler never runs while the C mainloop sits inside loop.run(), so the daemon died by default disposition and the final publish never happened. Registered through GLibUnix.signal_add instead — verified firing via mutation. stop_serving now also writes a forced-empty snapshot rather than republishing the live table: the holder client is usually still connected while systemd stops us, so an honest final claim is nothing.
There was a problem hiding this comment.
Pull request overview
Adds D-Bus idle-inhibit support so browser and media playback can suppress Omarchy’s Quickshell idle timers.
Changes:
- Adds a D-Bus inhibit daemon and state probe.
- Gates idle handling on external inhibit state.
- Adds systemd rollout, migration, documentation, and tests.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 10 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
bin/omarchy-idle-inhibit-daemon |
Serves inhibit APIs and publishes state. |
bin/omarchy-idle-inhibit-probe |
Validates daemon state for Quickshell. |
default/systemd/user/omarchy-idle-inhibit.service |
Runs the daemon per graphical session. |
install/user/first-run/enable-user-units.sh |
Enables the service for new users. |
migrations/1787769449.sh |
Enables it on existing installations. |
shell/plugins/services/idle/Service.qml |
Integrates external inhibits into idle handling. |
shell/plugins/services/idle/IdleModel.js |
Parses and gates inhibit state. |
plans/idle-inhibit.md |
Documents the design and rollout. |
test/shell.d/idle-test.sh |
Tests model parsing and gating. |
test/shell.d/idle-inhibit-wiring-test.sh |
Checks Quickshell wiring. |
test/shell.d/idle-inhibit-migration-test.sh |
Tests service migration behavior. |
test/shell.d/idle-inhibit-daemon-test.sh |
Exercises the D-Bus daemon contract. |
Suppressed comments (1)
plans/idle-inhibit.md:191
- The wiring summary specifies both
After=graphical-session.targetandRestart=always, but the daemon must be ordered before that target and the unit deliberately usesRestart=on-failure. Update this summary alongside the unit so it records the actual pre-target D-Bus ordering and restart policy.
- `default/systemd/user/omarchy-idle-inhibit.service` — house pattern
(`After=graphical-session.target`, `WantedBy=graphical-session.target`,
`Restart=always`, `ExecStart=/usr/bin/omarchy-idle-inhibit-daemon`; the
PKGBUILD install line lives in omarchy-pkgs and is called out in the PR).
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Browsers and players request idle inhibition over D-Bus | ||
| # (org.freedesktop.ScreenSaver); the daemon must own the names before any of | ||
| # them probe, which means starting with the graphical session, not on demand. | ||
| After=graphical-session.target |
There was a problem hiding this comment.
After=graphical-session.target did start us after autostarted browsers could already NameHasOwner-probe. Unit now uses After=dbus.socket / Requires=dbus.socket and keeps WantedBy=graphical-session.target so default target ordering starts us before the target is reached — same pattern as omarchy-sleep-lock.service. Migration test asserts the dbus ordering and fails if the graphical-session After= comes back.
| // line and stays silent when the serving pid is gone — silence is the | ||
| // consumer's "not inhibited", so a dead daemon cannot pin idle off. | ||
| command: [root.omarchyPath + "/bin/omarchy-idle-inhibit-probe", root.externalInhibitStateDir + "/state"] | ||
| stdout: SplitParser { | ||
| onRead: function(line) { root.applyExternalInhibitState(line) } |
There was a problem hiding this comment.
SplitParser.onRead does not fire on a zero-byte probe, so the last applied inhibit stuck. Two halves: the probe now emits one empty line for a dead pid (same as missing/torn), and the Process tracks whether that run produced stdout and applies empty state from onExited if it did not.
| # cannot pin the idle timers off; no bystander cleanup or coordination is | ||
| # involved. | ||
|
|
||
| stop_daemon |
There was a problem hiding this comment.
The previous path sent SIGTERM via stop_daemon against an already-uninhibited snapshot, so it never exercised SIGKILL-mid-inhibit. It now creates a live holder, SIGKILLs the daemon, asserts the file still claims that holder and the dead serving pid, then asserts the probe emits one empty line.
| held=$(python3 - <<'PY' | ||
| import time | ||
| import gi | ||
| gi.require_version("Gio", "2.0") | ||
| from gi.repository import Gio, GLib | ||
|
|
||
| bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) | ||
| res = bus.call_sync( | ||
| "org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", | ||
| "Inhibit", GLib.Variant("(ss)", ("term.client", "hold across sigterm")), | ||
| GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, -1, None) | ||
| print(res.unpack()[0], flush=True) | ||
| time.sleep(600) | ||
| PY | ||
| ) & | ||
| holder_term_pid=$! |
There was a problem hiding this comment.
var=$(python3 ...) & made $! the assignment subshell. Both the SIGKILL and SIGTERM holders now run python3 directly in the background, same as the stacked-holder client earlier in the file.
| - **Daemon crashes**: the bus name drops; the state file goes stale. The | ||
| service's tolerant parser does not treat a stale file as a held inhibit, and | ||
| `Restart=always` brings the name back. Chromium/VLC re-probe per video. |
There was a problem hiding this comment.
The unit is Restart=on-failure on purpose: clean stand-down (names owned elsewhere, exit 0) must not respawn. Plan rev 2 records that in both the crash edge-case and the wiring summary.
| Quattro replaced `hypridle` with the Quickshell idle service. hypridle registered | ||
| `org.freedesktop.ScreenSaver` on the session bus; the Quickshell service does | ||
| not, and `IdleMonitor { respectInhibitors: true }` honors only the Wayland | ||
| `zwp_idle_inhibit_manager_v1` protocol. An application that requests its inhibit |
There was a problem hiding this comment.
Reflowed to full lines, breaks only at headings and list items.
| - **Daemon, over a real bus** (`dbus-run-session` + `busctl`): inhibit returns | ||
| a cookie and flips the state file; both object paths answer; a second sender | ||
| stacks; `UnInhibit` drops one; unknown cookie tolerated; a sender that exits | ||
| without uninhibiting is reaped via `NameOwnerChanged`; the name-loss case | ||
| exits 0; `HasInhibit`/`GetActive` agree with the state file; the state file | ||
| is 0600 and always complete JSON under an inhibit/uninhibit storm; the | ||
| initial "nothing inhibited" snapshot exists without any caller. |
There was a problem hiding this comment.
The live-bus test requires GetActive to be absent (KDE-legacy screensaver-blanked semantics this daemon cannot honestly report). Test plan now describes that absence check; HasInhibit is what agrees with the state file.
After=graphical-session.target started us after autostarted browsers could already NameHasOwner-probe. After=dbus.socket matches sleep-lock. SplitParser.onRead never fires on a zero-byte probe (dead pid), so the last applied inhibit stuck. The probe now emits an empty line for a dead pid, and the Process applies empty state from onExited when that run produced no stdout. The SIGKILL live-bus case now actually SIGKILLs a held daemon. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Lockstep packaging: omarchy-pkgs install line for the unit is in omacom/omarchy-pkgs#210 ( |
Stable is pinned to v4.0.1, which predates the unit, and settings-dev tracks quattro, which also lacks it until omacom/omarchy#8452 merges. An unconditional install aborts package() on those trees. Gate on the file so this recipe can land ahead of the upstream commit pin. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Experiencing the exact same issue on Omarchy 4.0.1 where Firefox video playback fails to inhibit idle and triggers screen lock / screensaver. Looking forward to having this merged! |
|
Confirmed on my machine. Environment:
Before the workaround, Zen reported MPRIS I tested a user-space adaptation of this PR using a cloned idle plugin plus the This confirms both the regression described here and the proposed D-Bus bridge |
Closes #6475. Closes #7220. Closes #7199.
Summary
Quattro's idle service honors only the Wayland
zwp_idle_inhibit_manager_v1protocol, and nothing has ownedorg.freedesktop.ScreenSaversince hypridle was replaced — so an inhibit requested over D-Bus (every browser playing a video; VLC) was dropped on the floor and the screensaver fired over playing videos.This adds
omarchy-idle-inhibit-daemon(python3 + PyGObject, already ininstall/omarchy-base.packages), which:org.freedesktop.ScreenSaveron both/org/freedesktop/ScreenSaver(Chromium) and/ScreenSaver(VLC/Firefox legacy path), plusorg.freedesktop.PowerManagement.InhibitwithHasInhibit/HasInhibitChanged(Chromium's second inhibit, Firefox's third backend);NameOwnerChanged, spec-mandated lifetime);UnInhibitcookies (hypridle precedent — restarted clients must not wedge);GetActive/ActiveChanged: they mean "screensaver blanked", which this daemon cannot honestly report;LimitsExceeded);$XDG_RUNTIME_DIR/omarchy/idle-inhibit/state— atomic rename, 0600 from birth, written before each method reply, carrying the serving pid so that any consumer can tell live state from a SIGKILLed daemon's last write with no cross-process cleanup.The idle service reads that state through
omarchy-idle-inhibit-probe(one line of JSON when the serving pid is alive; silence/empty for absence, garbage, or a dead pid — every failure mode lands on "not inhibited") and gatesIdleMonitorthroughIdleModel.idleEnabledAfter, the same node-tested module pattern as before. An inhibit arriving mid-cycle cancels it like Stay Awake does; an already-fired lock is left alone.Why not the obvious alternatives
Plans doc included (
plans/idle-inhibit.md) with the evidence: D-Bus activation cannot work here because Chromium and VLC probe withNameHasOwner(never auto-starts) and silently skip inhibition when unowned — the daemon ships as agraphical-session.targetsystemd user unit instead; and a D-Bus→Wayland bridge fails on sway-class compositors that ignore inhibitors without visible surfaces.Wiring
Unit under
default/systemd/user/, enabled for fresh installs viaenable-user-units.shand existing installs via migration (with the SSH-fallback symlink precedent).Restart=on-failure— the clean-exit stand-down (names owned elsewhere) must not respawn forever. No new dependencies.Test
All red-green, mutations proven:
idle-inhibit-daemon-test.sh, realdbus-run-session): both paths answer, holders stack across Chromium/VLC/PM, bus-name recorded per cookie, held-state via one persistent connection, disconnect reaping, unknown-cookie tolerance, duplicate daemon exits 0 while the survivor keeps serving, probe silent for dead-pid state, missing/torn state → empty line, orderly SIGTERM leaves a forced-empty snapshot. 23 assertions; three consecutive clean runs.idle-test.sh); QML gate/wiring assertions in houserg -Fstyle (idle-inhibit-wiring-test.sh).idle-inhibit-migration-test.sh).An independent adversarial review pass ran between first implementation and this final form; its four majors/minors (restart flap, phantom inhibit after SIGKILL+takeover, inverted GetActive semantics, publish-failure hangs) are all addressed above, plus nits (cookie wraparound, truncation).
One note for merge ordering:
ExecStart=/usr/bin/omarchy-idle-inhibit-daemonneeds the omarchy-pkgs install lines (bin + unit) merged alongside.