Last active
May 19, 2020 13:19
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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