Skip to content

Instantly share code, notes, and snippets.

@akorn
Last active October 22, 2025 06:55
Show Gist options
  • Select an option

  • Save akorn/a31581c30dcecfa6dc1c113f9f63930b to your computer and use it in GitHub Desktop.

Select an option

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
@akorn
Copy link
Author

akorn commented Oct 22, 2025

https://github.com/akorn/chillscope is my new (as of late 2025), much more robust and featureful take on this; but that script will only work on Linux because it relies on cgroups.

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