Skip to content

Instantly share code, notes, and snippets.

@crazygolem
Last active May 6, 2024 18:07
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save crazygolem/a7d3a2d3c0cee5d072c0cbbbdee69286 to your computer and use it in GitHub Desktop.
Save crazygolem/a7d3a2d3c0cee5d072c0cbbbdee69286 to your computer and use it in GitHub Desktop.
Prevent the screen from blanking under GNOME+Wayland while a video is playing
--[[
Prevent the screen from blanking while a video is playing.
This script is a workaround for the GNOME+Wayland issue documented in the
[Disabling Screensaver] section of the mpv manual, and depends on
gnome-session-inhibit (usually provided by the gnome-session package) to set up
the inhibitors.
# CAVEATS
This is not (yet?) a foolproof solution.
At this time GNOME's implementation of the Inhibit protocol does not support the
SimluateUserActivity method:
dbus-send --print-reply --session \
--dest=org.gnome.ScreenSaver --type=method_call \
/org/gnome/ScreenSaver org.gnome.ScreenSaver.SimulateUserActivity
So there seems to be no way to "poke" the system with a heartbeat to extend to
idle timeout for a bit and prevent blanking.
This means that inhibitors have to be installed, then removed when mpv exits.
The current implementation handles this via another application, which should
always be available on GNOME+Wayland desktops (because provided by gnome-session
itself): gnome-session-inhibit.
Executing gnome-session-inhibit to handle this is not ideal because if mpv does
not exit cleanly, gnome-session-inhibit is not necessarily killed (it can get
orphaned, and gets adopted by the init process), leaving the inhibitors intact
with no easy way for the user to figure it out.
Ideally this script should open a DBus session directly to handle the
inhibitors, as they would be removed if the DBus session gets disconnected (cf.
[Inhibit's documentation][Inhibit]):
> Applications should invoke [Inhibit ()] when they begin an operation that
> should not be interrupted [...] When the application completes the operation
> it should call Uninhibit() or disconnect from the session bus.
This is how [other applications] do it.
But really, the only secure way to handle this would be with a heartbeat to
regularly reset the idle timer, i.e. what SimulateUserActivity is supposed to
provide. Especially for a core security feature such as screen locking (cf.
xscreensaver author's [rant][jwz]).
# Work notes
## Detecting readiness
If inhibitor:remove is called too quickly after inhibitor:install, mpv sometimes
fails to remove the inhibitor. With the current implementation (where a single
"cookie" is used) it can lead to two inhibitors being installed at once, and the
first inhibitor only gets removed when closing mpv.
This cannot be reproduced by just calling inhibitor:{install,remove} one after
the other in code, but registering them on separate events sometimes triggers
the issue, something like:
mp.observe_property('foo', nil, inhibitor:install)
mp.observe_property('bar', nil, inhibitor:remove)
In particular, this happens during the script's "initialization": when
registering a property observer with mp.observe_property, the function is always
triggered once immediately. Per the [documentation][Lua API]:
> You always get an initial change notification. This is meant to initialize the
> user's state to the current value of the property.
I presume that there something funny going on when mp.command_native_async and
mp.abort_async_command get called in the same "tick" of the event loop, maybe
the cookie isn't properly registered and aborting then fails.
Anyway, at first this problem seemed to only occur during initialization, and I
attempted to solve the issue by preventing the inhibitor from being installed
until the initialization is complete.
In order to delay the inhibitor until then, an observer is registered on a dummy
property 'plugin-initialized', and the registration is done after all the other
observers.
Technically the documentation doesn't say that registration order is respected
for property observers (though it is mentioned that it is for event observers
registered on the same event) but it seems to work reliably.
However it didn't work anymore after a rewrite of the inhibitor handler, as a
single mpv event could trigger again several internal triggers in a row. I might
fix this later, but the underlying issue is still that mp.abort_async_command
doesn't work as advertised and I wanted to have that fixed in isolation.
The next attempt to solve the issue was to add a small delay before calling
mp.abort_async_command. It seemed to work reliably with
os.execute('sleep 0.01')
but I didn't like calling an extra external command, so instead I opted for
using an mpv timeout that calls the abort function.
This could have replaced the first "dummy event" fix, but I actually like being
able to easily ensure that the state has been fully initialized before checking
the trigger conditions so I'm keeping it around.
## Orphaned inhibitors
When mpv does not terminate gracefully, e.g. when killed with `kill -9 <pid>`,
installed inhibitor don't get removed, which can be difficult to figure out for
the user.
One technique that solves the issue is to use gnome-session-inhibit with a
command that is able to check whether mpv is running, and exit when it isn't
anymore.
tail has a --pid option that can do just that, and it just takes removing in
mp.command_native_async the '--inhibit-only' argument and specifying the
subcommand
'tail', '--pid', tostring(utils.getpid()), '-f', '/dev/null'
Alternatively, something similar can be achieved by using a shell command to
poll on the result of
kill -0 <mpv-pid>
and exit if it fails.
But if was that easy, you wouldn't be reading that. So what's the problem?
It turns out that when gnome-session-inhibit gets killed, including by mpv when
requested with mp.abort_async_command, gnome-session-inhibit doesn't kill its
child process, and becomes "<defunct>". So now the problem just propagated to
tail, and even worse, this makes mpv unable to gracefully exit anymore: it
hangs waiting for gnome-session-inhibit to exit, which in turn is waiting for
tail to exit, which won't happen until mpv exits because of the --pid option.
This cure is unfortunately worse than the desease.
Another attempt was made using an extra check to determine whether the
subcommand's parent process (supposedly gnome-session-inhibit) became defunct:
ppid=$PPID
ps -o stat= --pid $ppid | grep Z
Note that the initial PPID has to be stored into another variable because PPID
is automatically updated when the process gets orphaned.
The issue is that mpv still hangs on normal shutdowns, and the polling interval
has to be short enough to not become annoying. Damn :(
## Debugging
Use mpv's `--msg-level` CLI option to increase the log level for messages from
this script, e.g.:
mpv --msg-level=gnome_inhibit=debug --no-msg-color ...
Use `gnome-session-inhibit -l` to list the active inhibitors. When this script
is enabled and a video is playing, you should see
mpv: video-playing (idle)
# TODO
- Allow configuring
- Whether to inhibit at all when only audio is playing
- Whether to inhibit idle (screen blanking) when only audio is playing
- Fix inhibitors not removed when mpv does not terminate gracefully, e.g. with
`kill -9 <pid>`, either by having a wrapper around gnome-session-inhibit
checking the parent PID (see https://stackoverflow.com/a/2035683), or by
interfacing with dbus to open a session that gets disconnected if mpv
terminates uncleanly (hopefully), or ...
[Disabling Screensaver]: https://mpv.io/manual/master/#disabling-screensaver
[Inhibit]: https://people.gnome.org/~mccann/gnome-session/docs/gnome-session.html#org.gnome.SessionManager.Inhibit
[jwz]: https://www.jwz.org/blog/2021/01/i-told-you-so-2021-edition/
[other applications]: https://unix.stackexchange.com/a/438335
[Lua API]: https://github.com/mpv-player/mpv/blob/master/DOCS/man/lua.rst
]]
local mp = require 'mp'
local log = mp.msg
-- -----------------------------------------------------------------------------
-- The hands
-- -----------------------------------------------------------------------------
local inhibitor = {}
function inhibitor.new()
-- Opaque token for the async gnome-session-inhibit token
local cookie = nil
local handle = {}
function handle.remove(self)
if not cookie then return end
-- The abort call can actually silently fail to abort the command under
-- some unknown conditions, and delaying it a tiny bit seems to solve
-- the issue, cf. work notes.
-- This means that several inhibitors can be installed at the same time
-- for a fraction of a second, hopefully nobody will notice ;) ;)
local c = cookie
mp.add_timeout(0.01, function()
mp.abort_async_command(c)
end)
cookie = nil
end
function handle.install(self, level, reason)
if cookie then return end
cookie = mp.command_native_async(
{
name = 'subprocess',
args = {
'gnome-session-inhibit',
'--inhibit', level,
'--app-id', 'mpv',
'--reason', reason,
'--inhibit-only',
},
-- `playback_only = true` does not kill the command when
-- playback is paused (i.e. "pause" is still "playback == true")
-- but also kills it immediately the first time. So we need to
-- handle the paused state ourselves.
playback_only = false,
-- If not captured, mpv will just forward everything to stdout
-- and stderr. Setting capture_stdXXX with capture_size = 0 will
-- ensure that the command's output is discarded.
capture_stdout = true,
capture_stderr = true,
capture_size = 0,
},
-- The cookie could be cleaned up here, but it might prevent new
-- handlers from being installed under some race conditions. It's
-- easier to clean it in the remove function than to do it right.
-- Note: This parameter of command_native_async should be optional
-- according to the doc, but I get an error if I don't provide a
-- function: attempt to call local 'cb' (a nil value)
function() end
)
end
return handle
end
-- -----------------------------------------------------------------------------
-- Teh Brainz
-- -----------------------------------------------------------------------------
local state = {
-- We want at most a single inhibitor to be installed for each mpv instance.
inhibitor = inhibitor.new(),
-- Events cache to allow complex trigger conditions. No, not that complex...
events = {},
-- A list of rules with associated action that gets executed when the result
-- of the rule changes due to an event fired by mpv. The result of the rule
-- is passed to the action so it doesn't have to compute it again.
triggers = {
state = {
rule = function(events)
if not events['plugin-initialized'] then return false end
if not events['stop-screensaver'] then return false end
if events['mute'] and not events['vo-configured'] then return false end
-- Might not work reliably depending on the VO and windowing
-- system (cf. mpv manual)
if events['window-minimized'] and events['vo-configured'] then return false end
-- mpv's --keep-open option triggers a 'pause' event at the end
-- of the file, but --idle doesn't, and instead triggers an
-- 'idle-active' event.
return not events['pause'] and not events['idle-active']
end,
action = function(state, evt, enabled)
if enabled then
local level = state.triggers.level.rule(state.events)
local reason = (state.events['vo-configured'] and 'video' or 'audio')..' playing'
state.inhibitor:install(level, reason)
log.verbose('inhibit on ('..evt..')')
else
state.inhibitor:remove()
log.verbose('inhibit off ('..evt..')')
end
end,
},
level = {
rule = function(events)
if not events['plugin-initialized'] then return end
-- If mpv is playing a video, we prevent the screen from
-- blanking. If it is only playing audio, the screen can blank
-- and we only prevent the computer from going to sleep.
-- Note: when a video is started, mpv fires vo-configured with
-- false, and then shortly after again with true when the
-- relevant subsystems get initialized.
return events['vo-configured'] and 'suspend:idle' or 'suspend'
end,
action = function(state, evt, level)
local reason = (state.events['vo-configured'] and 'video' or 'audio')..' playing'
-- We only need to refresh the inhibitor if it's already
-- installed, otherwise we can just wait for the next
-- installation.
if state.triggers.state.rule(state.events) then
state.inhibitor:remove()
state.inhibitor:install(level, reason)
log.verbose('inhibit upd ('..evt..')')
end
end,
},
},
}
-- Update the internal state from an mpv event and check the triggers.
function state.update(self, evt, val)
local snap = {}
for name, trigger in pairs(self.triggers) do
snap[name] = {}
snap[name].old = trigger.rule(self.events)
end
self.events[evt] = val
for name, trigger in pairs(self.triggers) do
snap[name].new = trigger.rule(self.events)
snap[name].changed = snap[name].new ~= snap[name].old
end
for name, status in pairs(snap) do
log.debug('state update:', evt, '=', val, '/', name, ':', status.old, '->', status.new)
if status.changed then
self.triggers[name].action(self, evt..':'..name, status.new)
end
end
end
-- -----------------------------------------------------------------------------
-- Wire everything together
-- -----------------------------------------------------------------------------
-- I like my instance method on state, leave me alone ☹
local function event_update(evt, val)
state:update(evt, val)
end
-- Handle the dummy 'plugin-initialized' event, indicating that all the other
-- events should have been fired once already and the state is initialized.
local function event_ready(evt)
-- The dummy event gets fired regularly for no apparent reason. It wouldn't
-- harm to keep the observer around, but I prefer my debug logs clean.
mp.unobserve_property(event_ready)
state:update(evt, true)
end
mp.observe_property('stop-screensaver', 'bool', event_update)
mp.observe_property('pause', 'bool', event_update)
mp.observe_property('idle-active', 'bool', event_update)
mp.observe_property('vo-configured', 'bool', event_update)
mp.observe_property('window-minimized', 'bool', event_update)
mp.observe_property('mute', 'bool', event_update)
-- Must be registered after all the other property observers, cf. work notes
mp.observe_property('plugin-initialized', nil, event_ready)
log.info(
'GNOME+Wayland idle inhibit workaround enabled.',
'You can ignore the warning from the [vo/gpu/wayland] component.'
)
Copy link

ghost commented Dec 12, 2021

This script prevents the display from turning off when any mpv window is running (also on the background), like using gnome-session-inhibitdoes?

I do appreciate your work nonetheless for the reasons provided:

Executing gnome-session-inhibit to handle this is not ideal because if mpv does
not exit cleanly, gnome-session-inhibit is not necessarily killed (it can get
ophaned, and gets adopted by the init process), leaving the inhibitors intact
with no easy way for the user to figure it out.

@crazygolem
Copy link
Author

This script prevents the display from turning off when any mpv window is running (also on the background), like using gnome-session-inhibit does?

Yes, in facts the script calls gnome-session-inhibit automatically for you when an mpv instance is in the "playing" state (also in the background, or without window, like in audio-only mode from the CLI), and stops automatically gnome-session-inhibit when the window is in the "paused" state or is closed. It is just a way to automate calling and stopping gnome-session-inhibit.

As long as you have at least one instance of mpv playing with this script, the display should not turn off due to idling.

I do appreciate your work nonetheless for the reasons provided:

Executing gnome-session-inhibit to handle this is not ideal because if mpv does
not exit cleanly, gnome-session-inhibit is not necessarily killed (it can get
ophaned, and gets adopted by the init process), leaving the inhibitors intact
with no easy way for the user to figure it out.

This is actually a limitation of this script: since it starts gnome-session-inhibit in another process, if mpv does not exit cleanly gnome-session-inhibit will continue running in the background, and you might not notice it.

I haven't invested time in trying to fix this as the current implementation is good enough for me, and nobody expressed interest in having this fixed (or for this script at all before you ;))

@Deminder
Copy link

Deminder commented Jan 7, 2022

To avoid the risk of an orphan process you could instead periodically call gnome-session-inhibit sleep 10. It seems to me as if gnome-session-inhibit sleep 0.1 behaves like SimulateUserActivity such that it may be enough to call it maybe every 60 sec.

I also tried gnome-session-inhibit tail -f --pid=${mpv-pid-property} but somehow mp.abort_async_command orphans the tail process and leaves gnome-session-inhibit as a zombie.

However, I am not so worried about mpv not terminating cleanly and prefer the simple non-heartbeat approach. 😄
I did a heartbeat and non-heartbeat version for the io.mpv.Mpv flatpak app: https://gist.github.com/Deminder/6839fad736865cb3acabab409f9b09a1

@crazygolem
Copy link
Author

Hey @Deminder, thanks for your comment.

I finally got some time and motivation to work on this again. And then I rewrote the whole thing 😅

For now I'm still focused on permanent inhibitors (as opposed to heartbeats), and I found the same issue as you have. I found a workaround, namely instead of using tail I made a small shell script that watched for the mpv and gnome-session-inhibit processes and exited when mpv terminated or gnome-session-inhibit became a zombie. But the solution involves polling (your solution with tail does too, it seems, but should be at least way more efficient than a bash script at that), and is absolutely not satisfactory in that it makes mpv hang on otherwise perfectly normal shutdowns (for the duration of the polling interval). I want to fix an edge case, but not make the normal case worse :(

I also found another issue with mp.abort_async_command, in some edge cases it never aborts the command, namely when it is called "too quickly" after mp.command_native_async and the stars don't align. The initial version of my script didn't suffer from this because the events it was handling were unlikely to trigger the issue. I also had a look at your scripts and I think it shouldn't be impacted either (by the way, I liked how it's using the mute and window-minimized events to disable inhibition, and might have integrated that in my rewrite 😄)

Copy link

ghost commented Jun 10, 2022

a typo at the beginning:

At this time GNOME's implementation of the Inhibit protocol does not support the SimluateUserActivity [sic] method

@emansom
Copy link

emansom commented Jun 11, 2022

The official documentation refers to mpv previously calling xdg-screensaver reset every ten seconds.
On my system running GNOME 42 this command is still available.

Would that command allow "poking" the system?

@crazygolem
Copy link
Author

The command is available on my system too, and reading the doc seems to not do what I want:

Turns the screensaver off immediately. If the screen was locked the user may be asked to authenticate first.

I'm assuming that by "screensaver" they mean the lock screen, and I don't want the plugin to attempt to unlock the screen if it is already locked, just to prevent it from locking if it isn't.

In any case, it doesn't even work as documented on my system. I had a quick look at the code (it's a bash script, with some embedded perl for the gnome screensaver), and it looks like it uses X11 stuff that's probably not relevant anymore for Wayland. And on top of that, the reset subcommand is using org.gnome.ScreenSaver.SimulateUserActivity which doesn't work anymore with GNOME on Wayland.

@emansom You could use instead gnome-session-inhibit sleep 1, which would likely not have the issue with zombie processes (as you don't need to "abort" the command from mpv's side), my plugin is just not currently written to handle poking/heartbeats.

@Deminder
Copy link

Deminder commented Aug 1, 2022

Hey @crazygolem,
what do you think about this cplugin by Guldoman: mpv_inhibit_gnome?
My fork uses the mute and window-minimized again to inhibit idle,suspend for video and suspend for audio.
I prefer this approach since it doesn't require a gnome-session-inhibit process.

mpv will run mpv_inhibit_gnome.so if it is in the scripts dir:

curl -o ~/.config/mpv/scripts/mpv_inhibit_gnome.so -L https://github.com/Deminder/mpv_inhibit_gnome/releases/download/v1/mpv_inhibit_gnome.so

@crazygolem
Copy link
Author

Hi @Deminder,

I had a quick look at that plugin, it looks ok and handles the minimal subset of events to make it useful.
It shouldn't be too difficult to add handling for extra events, and since it's a repo you can open PRs.

I'm not too familiar with C, and I think that the advantage of lua plugins is that it's easier to distribute: you don't have to care about compiling for different targets, and you can just tell people they all have to get the same file and it will work (modulo dependencies, which you have in all cases).

With respect to interfacing directly with dbus (as in that C plugin) vs using gnome-session-inhibit instead (which itself AFAIK uses dbus): it's probably possible to use dbus directly in lua too, it's just a bit more complicated than using gnome-session-inhibit; there are even a bunch of libs (but if you use them you get into the distribution issue: you have to either repackage your plugin, or tell users how to install everything).

With my lua plugin I have a few edge cases that I have a hard time dealing with (and in particular abnormal termination of mpv which doesn't remove inhibitors). I'm not sure if the C plugin handles it better (I haven't tested), if so it might be worth investigating whether it's using dbus directly that solves the issue (which could be implemented in lua too) or maybe it's mpv that is nicer to C plugins than lua ones in some edge cases (in which cas not much can be done for lua scripts).

@Deminder
Copy link

Deminder commented Aug 3, 2022

I thought it was rather an issue of transparency/trust since the binary mpv_inhibit_gnome.so should work with most mpv installations and systems. 🤔 Talking to dbus in the same process (as mpv) is cleaner and does not leave any process dangling.

@crazygolem
Copy link
Author

I just tested the C plugin, it doesn't seem to leave inhibitors behind if MPV exits abnormally, which is good.
I wouldn't want to do a C plugin myself, because I'm not very familiar with it and the tooling around it, and I would expect a C plugin to need to be compiled separately for different target architectures, which is a hassle that I can avoid with lua plugins.

At some point I might try to ditch gnome-session-inhibit in favor of a lower-level interface with dbus in my plugin, but likely I won't work on in short-term.

Copy link

ghost commented May 31, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment