Bash Shell, Processes & Signal handling

A couple posts ago, we saw how signals are handled by applications, containers and pods and how this is affected by container specific details. With this post as a starting point, I went down the rabbit hole of Bash shell commands, processes and signals.

Bash - A Unix Shell

The Bash (aka Bourne Again) Shell was developed by Brian Fox as a replacement for the Bourne Shell, which I guess is more known as the sh shell. It was first released in 1989 and it has been the default login shell for Linux distributions.

Bash is a command interpreter that typically runs in a text window. The main purpose of it is to allow users to interact with the system (kernel) easily. On top of that, it is also a programming language. It supports variables, functions and flow controls. Today we will explore the basics of Bash from the command interpreter view as well as some basics around processes and signal handling.

Bash Shell Command Processing

Bash shell treats everything given to it as a command to execute. For example:

protopapa@earth:~$ whats up
bash: whats: command not found
protopapa@earth:~$ 9
bash: 9: command not found
protopapa@earth:~$ 9 8
bash: 9: command not found
protopapa@earth:~$ 9 + 8
bash: 9: command not found
protopapa@earth:~$ ls
Desktop  Documents  Downloads  Dropbox  Music  Pictures  Public  snap  Templates  Videos

As we see, Bash splits the input line into separate words at the whitespace characters and considers the first word as the command to be executed. All the words following are considered input parameters to the command. Bash keeps all words as strings and has no concept of numbers. When we give as an input the 9 + 8, it will split it into three words rather than doing an arithmetic evaluation.

Special Characters

The above presentation of what Bash interprets when we feed it with text, is somehow simplified. There are some special characters, which should be interpreted prior to the execution of a given command. A limited example of these characters is:

CharacterMeaning
*String wildcard
>Output redirect
<Input redirect
'Strong quote
<">Weak quote

Internal & External Commands

Some commands that we type in Bash shell are internal, built into the shell. The shell doesn’t start a separate process to run internal commands. The type built-in command informs us if a command is built-in or external. For example:

protopapa@earth:~$ type cd
cd is a shell builtin
protopapa@earth:~$ type history
history is a shell builtin
protopapa@earth:~$ type echo
echo is a shell builtin
protopapa@earth:~$ type kill
kill is a shell builtin
protopapa@earth:~$ type pwd
pwd is a shell builtin
protopapa@earth:~$ type bash
bash is /usr/bin/bash
protopapa@earth:~$ type clear
clear is hashed (/usr/bin/clear)
protopapa@earth:~$ type mkdir
mkdir is /usr/bin/mkdir
protopapa@earth:~$ type type
type is a shell builtin

For all the built-in commands that Bash offers you can check the documentation.

All the commands that are not internal are external, which means that they are independent executables offered by the system. External commands require the shell to fork and exec a new sub-process. The executables can be found under /bin and /usr/bin or /sbin and /usr/sbin for external commands that need root privilege. All mentioned locations should and usually are in the $PATH environment variable.

What can be confusing at this point, is that a number of internal commands exist as executables with the same name under the directories we just mentioned, for example echo and /bin/echo or kill and /bin/kill.

There are several reasons for this duplication:

  • The built-in version of the commands exist for performance reasons, since bash does not load an external binary
  • Some built-in commands, by nature, can not be external processes. An example is the cd command. Invoking cd as an external command would make bash its parent process and a child process can not change the state of the parent process.
  • Interaction with the system (kernel) does not always happen through the shell and one may need to invoke actions without the shell process.

Bash Alias

An alias acts as a shortcut for command lines. It is a convenient way to simplify the use of long and repetitive commands, which are frequently used. The alias built-in command allows a string to be substituted when it is used as the first word in the command line. An example of a frequently used alias is: alias ll="ls -alF". When you type ll in the command line you will get the list of all the files in the current directory with long format and with a character revealing the nature of each file.

Bash scripting

A shell scripting is writing a program for the shell to execute and a shell script is a file or program that shell will execute. A shell script is fully-fledged programming language in itself. It can define variables, functions and we can do conditional execution of shell commands as well.

A Bash script file has .sh extension and it can directly be executed like a binary. It needs a shebang line at the top of the file to declare the interpreter. More details for the scripting side of Bash is for another chapter.

Bash & Processes & Signals

A process or job is the result of executing a command in the shell. Processes are created by UNIX commands, program executions and programs the user write and compile. It is really common to start a process, for example a Java application, from a bash script. Every time a command or a program is started from a bash script a new process is spawned. To demonstrate that we have a parent.sh script, which calls a child.sh script, which in turn starts a simple Java ‘Hello World’ application:

#!/bin/bash
./child.sh

and :

#!/bin/bash
java -jar sample.jar

when we run the parent.sh script we will see a process tree like below:

1812   29741   29741    1812 pts/0      29741 S+    1000   0:00 /bin/bash ./parent.sh
29741   29742   29741    1812 pts/0      29741 S+    1000   0:00 /bin/bash ./child.sh
29742   29743   29741    1812 pts/0      29741 Sl+   1000   0:00 java -jar sample.jar

The parent process of the parent.sh script is the the /bin/bash process with ID 1812.

Killing processes with Bash

The most common way to kill a process in UNIX like systems, is to use the kill command. So, one would guess that if we want to kill the processes that we just started we can do so with kill -9 29741. The result of it is:

1   29742   29741    1812 pts/0       1812 S     1000   0:00 /bin/bash ./child.sh
29742   29743   29741    1812 pts/0       1812 Sl    1000   0:01 java -jar sample.jar

As it is noticed, killing the parent bash script does not kill the children processes. Instead the child.sh got a new parent with ID 1, which is the boot process.

Propagate Signals to child processes

We will explore three different ways on how we can kill the parent bash process and kill its child processes as well.

Exec command

Exec command replaces the current process with the child process. Given that our scripts will look like that:

#!/bin/bash
exec ./child.sh

and :

#!/bin/bash
exec java -jar sample.jar

The result of the execution of the parent.sh script is:

1812   31717   31717    1812 pts/0      31717 Sl+   1000   0:00 java -jar sample.jar

The java process has replaced the child.sh process and the parent process. The JVM can just be killed/stopped with the usual kill command.

Trap command

Trap command allows us to catch signals and execute code when those signals occur. But there is a very important restriction:

When Bash receives a signal for which a trap has been set while waiting for a command to complete, the trap will not be executed until the command completes.

One way to overcome this, is to make the child.sh script and the Java application (JVM) to run as background processes with the & operator. Doing that, each parent process respectively will have to wait for the child process to complete. In this case the trap will not be ignored. Our scripts now are:

#!/bin/bash

term() {
  echo "Caught SIGTERM signal!"
  kill -TERM "$child" 2>/dev/null
}
trap term SIGTERM

./child.sh &

child=$!
wait "$child"

and :

#!/bin/bash

term() {
  echo "Caught SIGTERM signal!"
  kill -TERM "$child" 2>/dev/null
}
trap term SIGTERM

java -jar sample.jar &
child=$!
wait "$child"

That’s all folks! We only scratched the surface of Bash, processes and signal handling, but enough to understand and properly kill processes started from a script.