Preliminary notes
There are some problems with your "special shell program" not related to the main issue, but if you want to keep using the program then consider fixing them:
Quote right.
Passing an expanded $@
(or even "$@"
, see above) as a command allows the user to run shell builtins, including builtins that change the state of the interpreting shell itself: try ./shell.sh set -x
or ./shell.sh exit
. At least run "$@"
in a subshell (this will accept shell builtins), but better exec -- "$@"
from a subshell (this won't).
Avoid echo
, unless you're echoing a fixed string. Use printf
.
You don't need cat -v
. Bash can generate the EOT character (^D
) and compare your $key
to it.
You also don't need [[
here, [
will do fine (they are not equivalent though). And the basic operator for string comparison in [
is =
, Bash understands ==
after [
or [[
, other shells may or may not. Using =
and not using [[
where [
is sufficient is my personal preference. This way I don't get used to non-portable code and I know what I can do in a shell not as rich as Bash. To be clear: this is my personal preference, [[
in your code is not a problem.
Here is the improved script (but it still (mis)handles Ctrl+c like your original script, this will be addressed later):
#! /usr/bin/env bash
set -e
while true
do
printf 'press any key to run %s\n' "${*@Q}"
read -n 1 -r -s key
if [ "$key" = $'\cD' ]
then
echo bye
exit 0
fi
printf 'running %s\n' "${*@Q}"
(exec -- "$@") || echo oops
done
The code additionally uses $*
where appropriate along with @Q
modifier to print the command unambiguously. Try ./shell.sh echo "a b"
, compare to the behavior of your original script.
Now to the point.
To the point
Upon Ctrl+c the line discipline of your terminal (usually) sends SIGINT to processes in the foreground process group. The shell may or may not be in the group, so it may or may not get the signal. It does not relay the signal.
The foreground process group is a process group that is "in the foreground" in the controlling terminal at the moment. Usually it's an interactive shell who keeps informing the controlling terminal what group is in the foreground.
In general a parent-to-be process (like your "shell") that is about to run a child process should decide if the new process will be in the same (i.e. the old, the parent's) or a new process group. See this answer: Is there a way to change the process group of a running process?. If the new process group should be the one in the foreground, the terminal should be notified; and later, when the child exits, the foreground process group should be set back to the process group of the parent.
So depending on if and how your "shell" deals with process groups, when Ctrl+c causes SIGINT to be sent to the foreground process group, the signal will be delivered:
- to the child but not to the "shell", if the child's (new) group is the foreground process group and the shell is in a different (usually the old) process group; note the child's process group may contain (some or all) its descendants and they will also get the signal;
- to the "shell" and to the child (+descendants), if they are in the foreground process group; the easiest way to achieve this is not to change the process group of the child (i.e. keep everything inside the ever-foreground process group of the shell, as if the concept of process groups did not exist);
- to the "shell" but not to the child (+descendants), if they are in different process groups and the foreground process group happens to be the one of the "shell".
This means that one way to prevent Ctrl+c from interrupting your "shell" is to make sure the "shell" is not in the foreground process group.
Another way is to allow the "shell" to be in the foreground process group and to handle the signal. These are your options:
- Your "shell" may do nothing special and SIGINT will terminate it. This is what you want to avoid. Strictly, a "shell" that "does nothing" will ignore SIGINT if the ignore mask inherited from its parent says so, but it's an edge case.
- Your "shell" may explicitly choose to ignore SIGINT. In case of your script interpreted by
bash
, you need trap '' INT
; and if you don't want the child to ignore SIGINT (because of inheritance) then you should reset to the original disposition in the subshell that is going to exec
((trap - INT; exec -- "$@")
; but note the "original disposition" means "the value it had upon entrance to the shell", so in the edge case you may be unable to make the child not ignore SIGINT).
- Your "shell" may trap SIGINT and do something. In case of your script interpreted by
bash
, you need trap 'shell code' INT
where shell code
may even be a no-op (:
) or a space, but not an empty string. The child will act as if the trap wasn't there. Note bash cannot trap or reset signals ignored upon entry to the shell.
This is our script with a trap:
#! /usr/bin/env bash
set -e
trap 'echo "the shell got SIGINT"' INT
while true
do
printf 'press any key to run %s\n' "${*@Q}"
read -n 1 -r -s key
if [ "$key" = $'\cD' ]
then
echo bye
exit 0
fi
printf 'running %s\n' "${*@Q}"
(exec -- "$@") || echo oops
done
Run it with ./shell.sh sleep 10
and experiment by pressing Ctrl+c at different stages. You should discover that while sleep
is running, SIGINT from Ctrl+c gets to it and interrupts it; the signal also gets to the interpreting shell (you see the trap in action). This is because bash
runs sleep
in the same process group and both processes get SIGINT.
If you force -m
(job control) by running bash -m shell.sh sleep 10
then SIGINT from Ctrl+c will get to the interpreting shell when sent during read
but not when sent during sleep
. Because of -m
bash
and sleep
run in different process groups (ps -ao pid,pgrp,cmd
can confirm). read
is a builtin in Bash; when it's in the foreground, bash
is in the foreground. When sleep
is in the foreground, bash
is not. SIGINT that gets to sleep
is not delivered to bash
. The trap is useless when SIGINT gets to sleep
, still it is useful when SIGINT gets to bash
during read
. If not the trap, Ctrl+c during read
would interrupt the whole bash
.
Somewhat surprisingly our bash -m
(specifically Bash 5.2.15 I tested with) does exit when sleep
is killed by SIGINT. It exits without executing echo oops
and the exit status is 130, as if the shell itself was killed by SIGINT; or as if it relayed exit status from the command executed last (this is what shells usually do), in this case from sleep
that was killed by SIGINT for sure.
This behavior actually makes sense. The point is explained in this article: Proper handling of SIGINT/SIGQUIT. You don't need to read it right now; still if you want to write a "shell" then IMO you should read it at some time. For us the following excerpt from the manual of Bash is relevant:
If the command terminates due to the SIGINT, Bash concludes that the user meant to end the entire script, and acts on the SIGINT (e.g., by running a SIGINT trap or exiting itself);
(source)
This is under "when job control is not enabled" (and the broader fragment will probably be interesting for you). I haven't found any description of what happens with job control enabled, but from the behavior of bash -m
we observed I deduce that "Bash concludes that the user meant to end the entire script" still applies. My interpretation is: despite the fact our bash
does not get SIGINT (because it's not in the foreground process group at the moment), it still sees that sleep
terminates due to a SIGINT, so it concludes that the user meant to end the entire script; it does not run the trap though, because it actually did not get the signal; it does not kill itself (I confirmed this with strace
), it just relays the exit status of sleep
.
When writing your "shell", decide whether you want to run commands in the process group of the shell or not. Handle SIGINT. Then you may (or may not) want your "shell" to detect if the last command was killed by SIGINT or just exited. In general actually handling (like trapping, not ignoring) SIGINT and noticing it happened during the execution of the command may help to decide what to do after the command exits.
Yes, real shells do catch signals even without explicit trap
s defined by the user. E.g. in Linux you can run grep ^Sig /proc/"$$"/status
and interpret SigCgt according to this answer: How can I check what signals a process is listening to?
Now is the time to read the already linked article and see what "real shells" deal with.
Other notes
I wrote "upon Ctrl+c the line discipline of your terminal (usually) sends SIGINT". It's "usually" because programs (including programs you run from your "shell") can configure this (but not all programs in general). E.g. if ./shell.sh stty intr ^D
executes stty intr ^D
at least once, then you won't be able to easily exit the loop because Ctrl+d will generate SIGINT and read
will never read ^D
.
In a shell like bash
"running in the background" is associated with the &
command terminator, but &
does not imply the process starts not in the foreground process group. The concept of foreground process group, SIGTTIN, SIGTTOU and the whole job control is a way to deny some processes access to the controlling terminal, while being able to allow them (i.e. to move them to the foreground) on demand (fg
). Without job control a process started with &
is asynchronous (the shell does not wait for it to finish) and its stdin is redirected to /dev/null
or some equivalent file, so it cannot "steal" from the terminal, in this sense it's "in the background"; but it can be in the foreground process group. E.g. bash -c 'sleep 500 & sleep 600'
will run the new bash
and two sleep
processes in a single process group (being the process group of the parent of bash
, or a new process group, depending on how the parent sets things up) that will be the foreground process group.
If you want to keep testing these things with a shell script, be careful with set -e
. If the script exits because of set -e
, sometimes it may be easy to assume it exited because of SIGINT. In case of our script such misinterpretation can happen if you remove || echo oops
.
trap
ping signals. Real shells have signal handlers. They don't just leave it to the defaults. – muru Oct 03 '23 at 19:50