/bin/sh: Writing Your Own watch Command

The command watch in FreeBSD has a completely different function than the popular GNU-command with the same name. Since I find the GNU-watch convenient I wrote a short shell-script to provide that functionality for my systems. The script is a nice way to show off some basics as well as some advanced shell-scripting features.

Note

As part of the move from blogger, this article was updated 2016-07-20 to a version of the script that handles the HUP signal properly and causes less terminal flicker.

To resolve the ambiguity with watch(8) I called it observe on my system. My observe command takes the time to wait between updates as the first argument. Successive arguments are interpreted as commands to run. The following listing is the complete code:

#!/bin/sh
set -f
sleep=$1
clear=
shift

runcmd() {
	local IFS line
	IFS='
'
	tput cm 0 0
	output="$(eval "$@")"
	for line in $output; do
		echo -n $line
		tput ce
		echo
	done
	tput cd
}

trap 'runcmd "$@"; tput ve' EXIT
trap 'exit 0' INT TERM HUP
trap 'clear=1' INFO WINCH

tput vi
clear
runcmd "$@"
while sleep $sleep; do
        eval ${clear:+clear;clear=}
        runcmd "$@"
done

The complete observe script.

Careful observers may notice that there is no parameter checking and the code is not commented. These shortcomings are part of what makes it a convenient example in a tutorial.

Turning Off Glob-Pattern Expansion

The second line already shows a good convention:

#!/bin/sh
set -f

Turn off glob pattern expansion.

The set builtin can be used to set parameters as if they were provided on the command line. It is also able to turn them off again, e.g. set +x would turn off tracing. The -f option turns off glob pattern expansion for command arguments. This is a good habit to pick up, glob pattern expansion is very dangerous in scripts. Of course the -f option could be set as part of the shebang, e.g. #!/bin/sh -f, but that would allow the script user to override it. By calling bash ./observe 2 ccache -s the shell could be invoked without setting the option, which is dangerous for options with safety-implications.

Global Variable Initialisation

The next block initialises some global variables:

sleep=$1
clear=
shift

Set up globals and shave off the first command line argument.

Initialising global variables at the beginning of a script is not just good style (because there is one place to find them all), it also protects the script from whatever the caller put into the environment using export or the interactive shell’s equivalent.

The shift builtin can be a very useful feature. It throws away the first argument, so what was $2 becomes $1, $3 turns into $2 etc.. With an optional argument the number of arguments to be removed can be specified.

The runcmd Function

The runcmd function is responsible for invoking the command in a fashion that overwrites its last output:

runcmd() {
	local IFS line
	IFS='
'
	tput cm 0 0
	output="$(eval "$@")"
	for line in $output; do
		echo -n $line
		tput ce
		echo
	done
	tput cd
}

Execute the list of commands and carefully print the output.

The tput(1) command is handy to directly talk to the terminal. What it can do depends on the terminal it is run in, so it is good practice to test it in as many terminals as possible. A list of available commands is provided by the terminfo(5) manual page. The following commands were used here:

cm
cursor_address #row #col
Used to position the cursor in the top-left corner
ce
clr_eol
Clear to end of line
cd
clr_eos
Clear to end of screen

The eval "$@" command executes all the arguments (apart from the one that was shifted away) as shell commands. The command is executed in a subshell. That effectively prevents it from affecting the script. It is not able to change signal handlers or variables of the script, because it is run in its own process.

The following for line block prints the output line by line and clears trailing characters after each line. Of course clearing the screen and printing everything at once is faster, but it would introduce flickering.

Signal Handlers

Signal handlers provide a method of overriding the shell’s default actions. The trap builtin takes the code to execute as the first argument, followed by a list of signals to catch. Providing a dash as the first argument can be used to invoke the default action:

trap 'runcmd "$@"; tput ve' EXIT
trap 'exit 0' INT TERM HUP
trap 'clear=1' INFO WINCH

Handle signals.

EXIT is a pseudosignal that occurs when the shell terminates, i.e. by reaching the end of the script (in this case if sleep would fail) or an exit call.

The INT signal represents a user interrupt, usually caused by the user pressing CTRL+C. The TERM signal is a request to terminate. E.g. it is sent when the system shuts down. The HUP signal is sent when the terminal is closed.

WINCH occurs when the terminal is resized. The INFO signal is a very useful BSDism. It is usually invoked by pressing CTRL+T and causes a process to print status information.

The Output Cycle

The output cycle heavily interacts with the signal handlers:

tput vi
clear
runcmd "$@"
while sleep $sleep; do
        eval ${clear:+clear;clear=}
        runcmd "$@"
done

The main loop.

The tput vi command hides the cursor, tput ve in the EXIT handler turns it back on.

The clear command clears up the terminal before the command is run the first time.

The runcmd "$@" call occurs once before the loop, because the first call within the loop occurs after the first sleep interval.

The clear global is set by the WINCH/INFO handler. The eval ${clear:+clear;clear=} line runs the clear command if the variable is set and resets it afterwards. The clear command is not run every cycle, because it would cause flickering. The ability to trigger it is required to clean up the screen in case a command does not override all the characters from a previous cycle.

Conclusion

If you made it here, thank you for reading this till the end! You probably already knew a lot of what you read. But maybe you also learned a trick or two. That’s what I hope.