Skip to content

Instantly share code, notes, and snippets.

@akorn
Last active May 19, 2020 13:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save akorn/a31581c30dcecfa6dc1c113f9f63930b to your computer and use it in GitHub Desktop.
Save akorn/a31581c30dcecfa6dc1c113f9f63930b to your computer and use it in GitHub Desktop.
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