1

I am trying to understand how shells are set up so that the programs they are running receive the SIGINT when you press ctrl C but the shell doesn't, because when you are running bash, and run another program inside, and then you press CTRL+C, the program you are running stops but bash doesn't.

This even applies when you start bash from within another shell.

I tried to make my own special shell program which just runs a single child program when you press any key.

#! /usr/bin/env bash

set -e

while true do echo press any key to run $@ read -n 1 -r -s key code=$(echo $key | cat -v) if [[ "$code" == "^D" ]] then echo bye exit 0 fi echo running $@ $@ || echo oops done

This is what it does:

$ ./shell.sh sleep 1
press any key to run sleep 1
running sleep 1
press any key to run sleep 1
running sleep 1
press any key to run sleep 1
bye

press any key and it runs the program you passed in as arguments, and press ctrl+d at the prompt to exit

However, if I try to stop the sleep program with ctrl+c:

$ ./shell.sh sleep 10
press any key to run sleep 10
running sleep 10
^C
$

it stops the shell program as well - which is expected.

However, when you run bash on its own, it doesn't behave like that.

I would like mine to work like interactive bash - allow the "sleep" command to exit, and then continue to the prompt.

I have tried running it with -m as well, just in case that's the trick:

$ bash -m shell.sh sleep 10
press any key to run sleep 10
running sleep 10
^C$

but it exits as well.

So I have two questions: (that might have the same answer)

  • How can I tell bash to behave as if it were running interactively, and not exit when I press ctrl+c and just allow the child program to terminate and go continue to the prompt.

  • What is bash doing internally? For example when I want to make the same shell program in python (I have done that as well, but it's not included here) what do I have to do since it won't have the bash option that will probably answer the above. I know I can make it work by handing the signal, but I would like to know that I am doing it the same way that a real shell does it.

I think I could make it work by handling signals in my "shell" but something tells me that's not how real shells work - and I keep hearing stuff about foreground, and groups - so I am hoping that can just be inserted into my example - or at least that there is an explanation about why it can't be inserted into my example.

  • Lookup trapping signals. Real shells have signal handlers. They don't just leave it to the defaults. – muru Oct 03 '23 at 19:50
  • "how shells pass on SIGINT to the processes they are running" – They don't. Upon Ctrl+c the line discipline of your terminal sends SIGINT to processes in the foreground process group. The shell may or may not be in the group, but it does not relay the signal. – Kamil Maciorowski Oct 03 '23 at 19:54
  • Thanks I tried to improve the question a little bit based this comment. That's what I am trying to figure out. How can I foreground the sleep process? And does the shell just trap the signal or does it set things up so that it never receives it? – Alex028502 Oct 03 '23 at 22:49

1 Answers1

5

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:

  1. Quote right.

  2. 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).

  3. Avoid echo, unless you're echoing a fixed string. Use printf.

  4. 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 traps 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.