A zsh script to automatically suspend browser instances that don't have focus, and unsuspend them when they receive focus. They get to run for a bit every few minutes even if they don't have focus so they can update/display notifications etc.
#!/bin/zsh | |
# | |
# Idea from http://log.or.cz/?p=356 | |
# | |
# Copyright (c) 2017-2020 András Korn. License: GPLv3 | |
# | |
# Run this from a user-level runsvdir (runit), or from an endless loop, or using systemd, set to restart. | |
# | |
# Make sure you start browsers in their own process group (e.g. `chpst -P browser`). (TODO: maybe support cgroups?) | |
exec 2>&1 | |
SVNAME=$(basename $(pwd)) | |
CONFIG=/etc/default/"$SVNAME" | |
loopdelay=500 # initial delay between checks, 1/100 seconds | |
loopdelay_min=500 # minimal delay between checks, 1/100 seconds | |
loopdelay_max=30000 # max. delay between checks, 1/100 seconds (will scale delay up when no browser is running, reset to minimal delay if it is) | |
suspendafter=10 # [s]; can be changed at runtime by writing a value into ./suspendafter | |
resumeevery=300 # [s]; browsers get $resumefor seconds of runtime each $resumeevery seconds of being suspended. Can be changed at runtime by writing a value into ./resumeevery | |
resumefor=5 # [s]; browsers get $resumefor seconds of runtime each $resumeevery seconds of being suspended. Can be changed at runtime by writing a value into ./resumefor | |
grace_time=60 # number of seconds to avoid suspending a freshly-started browser; can be changed at runtime by writing a value into ./grace_time | |
debug=1 # 1 causes debug messages to be printed; can be changed at runtime by writing a value into ./debug. With 0, the script is almost silent; higher values increase verbosity. 3 is currently the maximum. | |
debug_level=(INFO DEBUG2 DEBUG3) | |
[[ -r "$CONFIG" ]] && . "$CONFIG" | |
unset suspended last_in_focus class is_browser pid comm browser_pids | |
typeset -g -A suspended # hash of pids; we set suspended[42]=1 when suspending PID 42, and set it to zero when we resume it | |
typeset -g -A last_in_focus # hash of pids | |
typeset -g -A class # hash of window IDs (we only re-invoke xprop to obtain this once every ~100 loops) | |
typeset -g -A is_browser # hash of window IDs | |
typeset -g -A win_to_pid # hash mapping window IDs to process IDs | |
typeset -g -A comm # hash mapping process IDs to command lines | |
typeset -g -A browser_pids # hash of PIDs of known browser processes; the values are irrelevant, we only use the keys | |
typeset -g -A pid_to_win # hash mapping process IDs to window IDs (opposite of win_to_pid hash) | |
typeset -g -A suspended_at # hash mapping PIDs to times; stores when each PID was suspended. We periodically unsuspend browsers for a bit so they can retrieve notifications and whatnot. | |
zmodload zsh/datetime | |
zmodload zsh/stat | |
zmodload zsh/zselect | |
LASTMOD=$(zstat +mtime run) | |
setopt TRAPS_ASYNC | |
trap 'exit' 15 | |
trap 'exit' 1 | |
# Will only affect browser processes whose windows have ever been seen in focus. | |
function forget_process() { # if we can't send a signal to a process, try to remove it from our data structures as it probably exited | |
local mypid=$1 | |
local mywin=$pid_to_win[$mypid] | |
debug 1 "PID $mypid (originally $comm[$mypid]) apparently exited; clearing associated state." | |
unset "suspended[$mypid]" | |
unset "last_in_focus[$mypid]" | |
unset "comm[$mypid]" | |
unset "browser_pids[$mypid]" | |
unset "pid_to_win[$mypid]" | |
unset "suspended_at[$mypid]" | |
if [[ -n "$mywin" ]]; then | |
unset "is_browser[$mywin]" | |
unset "win_to_pid[$mywin]" | |
fi | |
} | |
function get_window_pid() { | |
local mypid | |
if [[ $window = 0x0 ]]; then # $window is a window ID as supplied by xprop(1) | |
mypid=0 | |
return | |
elif [[ -z "$win_to_pid[$window]" ]] || [[ $[RANDOM%10] = 0 ]]; then # eventual consistency -- re-validate win-pid mappings occasionally, but not always, to save a few fork()s | |
xprop -notype -id "$window" _NET_WM_PID | read crap crap mypid | |
win_to_pid[$window]=$mypid | |
pid_to_win[$mypid]=$window | |
fi | |
echo $mypid | |
} | |
function get_pid_command() { # prints command line (from /proc/pid/comm) associated with PID; caches values in global comm hash | |
local mypid=$1 | |
[[ -z "$1" ]] && return | |
if [[ -z "$comm[$mypid]" ]] || [[ $[RANDOM%10] = 0 ]] || [[ $2 = nocache ]]; then # eventual consistency -- even if we have a cached value, recheck /proc occasionally | |
if [[ -f /proc/$mypid/comm ]]; then | |
comm[$mypid]=$(</proc/$mypid/comm) | |
else | |
unset "comm[$mypid]" # no such process, or we can't access its /proc entry | |
fi | |
fi | |
echo $comm[$mypid] | |
} | |
function log_rss() { # can be used for debugging memory usage; not currently being called from anywhere | |
awk '{ print $24 }' /proc/$$/stat | |
} | |
function possibly_suspend_pid() { # uses global $mycomm and $mypid, as well as global hashes | |
local i | |
local downloading | |
debug 2 "possibly_suspend_pid: considering browser $mycomm (pid $mypid). last_in_focus=$(strftime %H:%M:%S $last_in_focus[$mypid]) suspended=$suspended[$mypid], focus delta=$[EPOCHSECONDS-$last_in_focus[$mypid]], suspendafter=$suspendafter" | |
if [[ $[EPOCHSECONDS-$last_in_focus[$mypid]] -gt $suspendafter ]] \ | |
&& ! [[ $suspended[$mypid] = 1 ]]; then | |
if [[ $mycomm =~ "firefox|Navigator" ]]; then | |
[[ $[RANDOM%10] = 0 ]] && debug 1 "$mycomm (pid $mypid) not in focus and hasn't been for $[EPOCHSECONDS-$last_in_focus[$mypid]]s. Possibly suspending." | |
# check if a download window is open | |
downloading=0 | |
xprop -root '_NET_CLIENT_LIST' | { | |
IFS="# ," | |
read -A winids | |
for i in $winids[@]; do | |
[[ $i =~ ^0x.. ]] \ | |
&& [[ $(xprop -id $i WM_CLASS) =~ \"Places\",\ \"Firefox\" ]] \ | |
&& [[ $(xprop -notype -id "$i" _NET_WM_PID) =~ \=[[:space:]]$mypid ]] \ | |
&& downloading=1 | |
done | |
IFS="$OLDIFS" | |
} | |
if [[ $downloading = 1 ]]; then | |
[[ $[RANDOM%10] = 0 ]] && debug 1 "Not suspending background firefox window $window, pid $mypid, because it seems to be downloading something." | |
return | |
fi | |
fi | |
pgid=$(sed -r 's/^[0-9]+ \(.*\) . [0-9]+ //;s/ .*//' /proc/$mypid/stat) | |
pname=$(sed -r 's/^[0-9]+ \((.*)\) .*/\1/' /proc/$pgid/stat) | |
case $pname in | |
*firefox*) :;; | |
*Navigator*) :;; | |
*opera*) :;; | |
*chrome*) :;; | |
*chromium*) :;; | |
*vivaldi*) :;; | |
*) debug 1 "Asked to suspend group $pgid, led by a process called $pname, which doesn't appear to be a browser. Not suspending it for your safety."; return 1; | |
esac | |
debug 1 "Suspending background $mycomm $mypid, belonging to pgroup $pgid. The group leader is a process called $pname." | |
((debug>=3)) && pgrep -a -g $pgid | |
if kill -STOP -$pgid; then | |
((debug>=3)) && ps axfuwww | fgrep "$mypid" | fgrep -v grep | |
suspended[$mypid]=1 | |
suspended_at[$mypid]=$EPOCHSECONDS | |
else | |
forget_process $mypid | |
fi | |
fi | |
} | |
function debug() { | |
local level=$1 | |
((debug>=level)) && { shift; echo "$(strftime %H:%M:%S) $debug_level[$level]: $@" } | |
} | |
function get_active_window_data() { # obtains class, pid and command associated with currently focused window; saves these in global variables | |
[[ $window = 0x0 ]] && { debug 2 "get_active_window_data: no window has focus"; return } # No window has focus | |
if [[ -z $class[$window] ]] || [[ $[RANDOM%10] = 0 ]]; then # eventual consistency -- re-obtain window class occasionally, but not always, to save a few fork()s | |
tempclass=$(xprop -notype -id "$window" WM_CLASS) | |
tempclass=${${tempclass#*\"}%%\"*} # xprop output looks like 'WM_CLASS = "Opera developer", "Opera developer"'; get the first quoted string to save some memory, and to make debug messages more readable | |
class[$window]=$tempclass # primitive caching | |
fi | |
myclass=$class[$window] | |
mypid=$(get_window_pid) | |
mycomm=$(get_pid_command $mypid) | |
if [[ $myclass =~ ([fF]irefox|[oO]pera|[cC]hrom(e|ium)|[vV]ivaldi|[nN]avigator) ]]; then | |
is_browser[$window]=1; browser_pids[$mypid]=$mypid # this way, we can use the keys or the values in browser_pids; it doesn't matter | |
else | |
is_browser[$window]=0; unset "browser_pids[$mypid]" | |
fi | |
} | |
function suspend_candidates() { | |
local mypid | |
local mycomm | |
local oldcomm | |
if [[ -n "${(k)browser_pids}" ]]; then | |
debug 2 "suspend_candidates: checking to see whether any of ${(k)browser_pids} can be suspended." | |
for mypid in ${(k)browser_pids}; do # maybe use something like $(pgrep 'firefox|opera') instead? con: it could suspend browsers on other displays | |
oldcomm=$comm[$mypid] # we need the cached value to be able to print it if the process has gone away meanwhile | |
mycomm=$(get_pid_command $mypid nocache) # we must validate that it's still a browser | |
debug 2 "Considering browser $mycomm (pid $mypid). last_in_focus=$(strftime %H:%M:%S $last_in_focus[$mypid]) suspended=$suspended[$mypid]" | |
case $mycomm in | |
*firefox*) possibly_suspend_pid;; | |
*Navigator*) possibly_suspend_pid;; | |
*opera*) possibly_suspend_pid;; | |
*chrome*) possibly_suspend_pid;; | |
*chromium*) possibly_suspend_pid;; | |
*vivaldi*) possibly_suspend_pid;; | |
*) debug 1 "pid $mypid, formerly $oldcomm, is no longer a browser" | |
resume_process $mypid | |
forget_process $mypid | |
;; | |
esac | |
done | |
else | |
debug 1 "suspend_candidates: no known browsers; nothing to suspend." | |
fi | |
} | |
function resume_process() { | |
local p=$1 | |
debug 2 "resume_process: called for pid $p" | |
if [[ -f /proc/$p/status ]]; then # also resume processes we didn't suspend, but only if they're really suspended | |
{ | |
read line | |
read line | |
read crap state crap | |
pgid=$(sed -r 's/^[0-9]+ \(.*\) . [0-9]+ //;s/ .*//' /proc/$p/stat) | |
debug 2 "resume_process: sending CONT signal to process group $pgid (which $p belongs to)" | |
if ! kill -CONT -$pgid; then | |
debug 2 "resume_process: kill -CONT -$pgid returned a failure" | |
forget_process $p | |
return | |
else | |
suspended[$p]=0 | |
unset "suspended_at[$p]" | |
fi | |
} </proc/$p/status | |
else | |
debug 1 "resume_process: called for pid $p, but /proc/$p/status doesn't exist." | |
forget_process $p | |
fi | |
} | |
function periodic_resume() { | |
local i | |
for i in ${(k)suspended_at}; do | |
if [[ $[EPOCHSECONDS-$suspended_at[$i]] -ge $resumeevery ]]; then | |
debug 1 "periodic_resume: giving $(get_pid_command $i) (pid $i) a chance to run for $resumefor seconds." | |
resume_process $i | |
last_in_focus[$i]=$[$EPOCHSECONDS-$suspendafter+$resumefor] | |
fi | |
done | |
} | |
USER=${USER:-$(whoami)} | |
OLDIFS="$IFS" | |
export DISPLAY=${DISPLAY:-:0} | |
while ! xprop -root _NET_ACTIVE_WINDOW >/dev/null 2>/dev/null; do | |
zselect -t $loopdelay_max # X is probably not running, wait | |
done | |
coproc xprop -spy -root _NET_ACTIVE_WINDOW # start xprop as a coprocess; this way we get notified of focus changes immediately and don't have to keep polling xprop | |
while [[ "$(zstat +mtime run)" = "$LASTMOD" ]] && kill -0 %1; do # if the script was modified or the coprocess died, exit (and hopefully be restarted by runit) | |
[[ -f debug ]] && debug=$(<debug) # override value of $debug at runtime from ./debug file | |
[[ -f suspendafter ]] && suspendafter=$(<suspendafter) # same for suspendafter | |
[[ -f grace_time ]] && grace_time=$(<grace_time) # and grace_time | |
[[ -f resumeevery ]] && resumeevery=$(<resumeevery) # and resumeevery | |
[[ -f resumefor ]] && resumefor=$(<resumefor) # and resumefor | |
if read -t $((loopdelay/100)) -p window; then # if the read succeeds, a new window has focus; if not, there is no focus change, but we may need to suspend a browser or two | |
window=${window#*# } # xprop output is like '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x5000017' -- get just the ID itself | |
debug 2 "mainloop: focus changed: myclass=$myclass is_browser=$is_browser[$window] browser_pids=$browser_pids[$mypid] last_in_focus=$(strftime %H:%M:%S $last_in_focus[$mypid]) suspended=$suspended[$mypid]" | |
else | |
debug 2 "mainloop: no focus change: myclass=$myclass is_browser=$is_browser[$window] browser_pids=$browser_pids[$mypid] last_in_focus=$(strftime %H:%M:%S $last_in_focus[$mypid]) suspended=$suspended[$mypid]" | |
fi | |
get_active_window_data # Sets a bunch of global variables (especially mycomm, mypid). Must do this each time even if no focus change because suspend_candidates can mess with the sate. TODO: see if using local variables in suspend_candidates() helped | |
if ((is_browser[$window])); then # does a browser window currently have focus? | |
# if browser is just starting, avoid suspending it for some time: | |
if [[ -z $last_in_focus[$mypid] ]]; then | |
last_in_focus[$mypid]=$[$EPOCHSECONDS+$grace_time] | |
else | |
# preserve future focus times set just above; if time in past, update it: | |
[[ last_in_focus[$mypid] -lt $EPOCHSECONDS ]] && last_in_focus[$mypid]=$EPOCHSECONDS | |
fi | |
if ! [[ $suspended[$mypid] = 0 ]]; then # also unsuspend browsers we haven't seen yet -- probably the CONT signal doesn't cause mayhem, but if this script is ever restarted with a browser suspended, a manual resume would be needed otherwise | |
debug 1 "$mycomm (pid $mypid) in focus, unsuspending" | |
resume_process $mypid | |
fi | |
debug 2 "mainloop: browser $mycomm (pid $mypid) in focus. last_in_focus=$(strftime %H:%M:%S $last_in_focus[$mypid]) suspended=$suspended[$mypid]" | |
else | |
debug 2 "mainloop: not_browser: window=$window; is_browser=$is_browser[$window]; comm=$mycomm; pid=$mypid; suspended=$suspended[$mypid]" | |
fi | |
suspend_candidates | |
periodic_resume | |
if [[ -n "${(k)browser_pids}" ]]; then | |
loopdelay=$loopdelay_min | |
else | |
loopdelay=$[loopdelay*2] # if there are no known browsers, wait longer between checks | |
[[ $loopdelay -gt $loopdelay_max ]] && loopdelay=$loopdelay_max | |
fi | |
done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment