Why is SIGINT not propagated to child process when sent to its parent process?
Solution 1
How CTRL+C works
The first thing is to understand how CTRL+C works.
When you press CTRL+C, your terminal emulator sends an ETX character (end-of-text / 0x03).
The TTY is configured such that when it receives this character, it sends a SIGINT to the foreground process group of the terminal. This configuration can be viewed by doing stty -a
and looking at intr = ^C;
.
The POSIX specification says that when INTR is received, it should send a SIGINT to the foreground process group of that terminal.
What is the foreground process group?
So, now the question is, how do you determine what the foreground process group is? The foreground process group is simply the group of processes which will receive any signals generated by the keyboard (SIGTSTP, SIGINT, etc).
Simplest way to determine the process group ID is to use ps
:
ps ax -O tpgid
The second column will be the process group ID.
How do I send a signal to the process group?
Now that we know what the process group ID is, we need to simulate the POSIX behavior of sending a signal to the entire group.
This can be done with kill
by putting a -
in front of the group ID.
For example, if your process group ID is 1234, you would use:
kill -INT -1234
Simulate CTRL+C using the terminal number.
So the above covers how to simulate CTRL+C as a manual process. But what if you know the TTY number, and you want to simulate CTRL+C for that terminal?
This becomes very easy.
Lets assume $tty
is the terminal you want to target (you can get this by running tty | sed 's#^/dev/##'
in the terminal).
kill -INT -$(ps h -t $tty -o tpgid | uniq)
This will send a SIGINT to whatever the foreground process group of $tty
is.
Solution 2
As vinc17 says, there’s no reason for this to happen.
When you type a signal-generating key sequence
(e.g., Ctrl+C),
the signal is sent to all processes that are attached to
(associated with) the terminal.
There is no such mechanism for signals generated by kill
.
However, a command like
kill -SIGINT -12345
will send the signal to all processes in process group 12345; see kill(1) and kill(2). Children of a shell are typically in the shell’s process group (at least, if they’re not asynchronous), so sending the signal to the negative of the PID of the shell may do what you want.
Oops
As vinc17 points out, this doesn’t work for interactive shells. Here’s an alternative that might work:
kill -SIGINT -$(echo $(ps -pPID_of_shell o tpgid=))
ps -pPID_of_shell
gets process information on the shell.
o tpgid=
tells ps
to output only the terminal process group ID, with no header.
If this is less than 10000, ps
will display it with leading space(s);
the $(echo …)
is a quick trick to strip off leading (and trailing) spaces.
I did get this to work in cursory testing on a Debian machine.
Solution 3
The question contains its own answer. Sending the SIGINT
to the cat
process with kill
is a perfect simulation of what happens when you press Ctrl+C.
To be more precise, the interrupt character (^C
by default) sends SIGINT
to every process in the terminal's foreground process group. If instead of cat
you were running a more complicated command involving multiple processes, you'd have to kill the process group to achieve the same effect as ^C
.
When you run any external command without the &
background operator, the shell creates a new process group for the command and notifies the terminal that this process group is now in the foreground. The shell is still in its own process group, which is no longer in the foreground. Then the shell waits for the command to exit.
That's where you seem to have become the victim by a common misconception: the idea that the shell is doing something to facilitate the interaction between its child process(es) and the terminal. That's just not true. Once it has done the setup work (process creation, terminal mode setting, creation of pipes and redirection of other file descriptors, and executing the target program) the shell just waits. What you type into cat
isn't going through the shell, whether it's normal input or a signal-generating special character like ^C
. The cat
process has direct access to the terminal through its own file descriptors, and the terminal has the ability to send signals directly to the cat
process because it's the foreground process group. The shell has gotten out of the way.
After the cat
process dies, the shell will be notified, because it's the parent of the cat
process. Then the shell becomes active and puts itself in the foreground again.
Here is an exercise to increase your understanding.
At the shell prompt in a new terminal, run this command:
exec cat
The exec
keyword causes the shell to execute cat
without creating a child process. The shell is replaced by cat
. The PID that formerly belonged to the shell is now the PID of cat
. Verify this with ps
in a different terminal. Type some random lines and see that cat
repeats them back to you, proving that it's still behaving normally in spite of not having a shell process as a parent. What will happen when you press Ctrl+C now?
Answer:
SIGINT is delivered to the cat process, which dies. Because it was the only process on the terminal, the session ends, just as if you'd said "exit" at a shell prompt. In effect cat was your shell for a while.
Solution 4
There's no reason to propagate the SIGINT
to the child. Moreover the system()
POSIX specification says: "The system() function shall ignore the SIGINT and SIGQUIT signals, and shall block the SIGCHLD signal, while waiting for the command to terminate."
If the shell propagated the received SIGINT
, e.g. following a real Ctrl+C, this would mean that the child process would receive the SIGINT
signal twice, which may have unwanted behavior.
Solution 5
setpgid
POSIX C process group minimal example
It might be easier to understand with a minimal runnable example of the underlying API.
This illustrates how the signal does get sent to the child, if the child didn't change its process group with setpgid
.
main.c
#define _XOPEN_SOURCE 700
#include <assert.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
volatile sig_atomic_t is_child = 0;
void signal_handler(int sig) {
char parent_str[] = "sigint parent\n";
char child_str[] = "sigint child\n";
signal(sig, signal_handler);
if (sig == SIGINT) {
if (is_child) {
write(STDOUT_FILENO, child_str, sizeof(child_str) - 1);
} else {
write(STDOUT_FILENO, parent_str, sizeof(parent_str) - 1);
}
}
}
int main(int argc, char **argv) {
pid_t pid, pgid;
(void)argv;
signal(SIGINT, signal_handler);
signal(SIGUSR1, signal_handler);
pid = fork();
assert(pid != -1);
if (pid == 0) {
is_child = 1;
if (argc > 1) {
/* Change the pgid.
* The new one is guaranteed to be different than the previous, which was equal to the parent's,
* because `man setpgid` says:
* > the child has its own unique process ID, and this PID does not match
* > the ID of any existing process group (setpgid(2)) or session.
*/
setpgid(0, 0);
}
printf("child pid, pgid = %ju, %ju\n", (uintmax_t)getpid(), (uintmax_t)getpgid(0));
assert(kill(getppid(), SIGUSR1) == 0);
while (1);
exit(EXIT_SUCCESS);
}
/* Wait until the child sends a SIGUSR1. */
pause();
pgid = getpgid(0);
printf("parent pid, pgid = %ju, %ju\n", (uintmax_t)getpid(), (uintmax_t)pgid);
/* man kill explains that negative first argument means to send a signal to a process group. */
kill(-pgid, SIGINT);
while (1);
}
Compile with:
gcc -ggdb3 -O0 -std=c99 -Wall -Wextra -Wpedantic -o setpgid setpgid.c
Run without setpgid
Without any CLI arguments, setpgid
is not done:
./setpgid
Possible outcome:
child pid, pgid = 28250, 28249
parent pid, pgid = 28249, 28249
sigint parent
sigint child
and the program hangs.
As we can see, the pgid of both processes is the same, as it gets inherited across fork
.
Then whenever you hit:
Ctrl + C
It outputs again:
sigint parent
sigint child
This shows how:
- to send a signal to an entire process group with
kill(-pgid, SIGINT)
- Ctrl + C on the terminal sends a kill to the entire process group by default
Quit the program by sending a different signal to both processes, e.g. SIGQUIT with Ctrl + \
.
Run with setpgid
If you run with an argument, e.g.:
./setpgid 1
then the child changes its pgid, and now only a single sigint gets printed every time from the parent only:
child pid, pgid = 16470, 16470
parent pid, pgid = 16469, 16469
sigint parent
And now, whenever you hit:
Ctrl + C
only the parent receives the signal as well:
sigint parent
You can still kill the parent as before with a SIGQUIT:
Ctrl + \
however the child now has a different PGID, and does not receive that signal! This can seen from:
ps aux | grep setpgid
You will have to kill it explicitly with:
kill -9 16470
This makes it clear why signal groups exist: otherwise we would get a bunch of processes left over to be cleaned manually all the time.
Tested on Ubuntu 18.04.
Related videos on Youtube
rob87
Updated on September 18, 2022Comments
-
rob87 over 1 year
Given a shell process (e.g.
sh
) and its child process (e.g.cat
), how can I simulate the behavior of Ctrl+C using the shell's process ID?
This is what I've tried:
Running
sh
and thencat
:[user@host ~]$ sh sh-4.3$ cat test test
Sending
SIGINT
tocat
from another terminal:[user@host ~]$ kill -SIGINT $PID_OF_CAT
cat
received the signal and terminated (as expected).Sending the signal to the parent process does not seem to work. Why is the signal not propagated to
cat
when sent to its parent processsh
?This does not work:
[user@host ~]$ kill -SIGINT $PID_OF_SH
-
konsolebox almost 10 yearsThe shell has a way to ignore SIGINT signals not sent from the keyboard or terminal.
-
-
goldilocks almost 10 yearsThe shell doesn't have to implement this with
system()
. But you're right, if it catches the signal (obviously it does) then there's no reason to propagate it downward. -
vinc17 almost 10 years@goldilocks I've completed my answer, perhaps giving a better reason. Note that the shell cannot know whether the child has already received the signal, hence the problem.
-
vinc17 almost 10 yearsThis doesn't work when the process is started in an interactive shell (which is what the OP is using). I don't have a reference for this behavior, though.
-
Piotr Dobrogost over 9 yearsThe shell has gotten out of the way. +1
-
W_B about 9 yearsIt's worth pointing out that signals that come directly from the terminal bypass permission checking, so Ctrl+C always succeeds in delivering signals unless you turn it off in the terminal attributes, whereas a
kill
command might fail. -
andy about 9 years+1, for
sends a SIGINT to the foreground process group of the terminal.
-
Steven Lu about 6 yearsI don't understand why after
exec cat
pressing^C
wouldn't just land^C
into cat. Why would it terminate thecat
which has now replaced the shell? Since the shell's been replaced, the shell is the thing that implements the logic of sending SIGINT to its children upon receiving^C
. -
Sherlock about 6 yearsThe point is that the shell doesn't send SIGINT to its children. The SIGINT comes from the terminal driver, and is sent to all foreground processes.
-
Ciro Santilli Путлер Капут 六四事 over 5 yearsWorth mentioning that the process group of the child is the same as the parent after
fork
. Minimal runnable C example at: unix.stackexchange.com/a/465112/32558 -
Mikko Rantalainen over 3 years@StevenLu Your comment is pretty old but I'll answer your question to help other people that find this answer in the future: when you run
exec cat
you literally replace the shell executable in memory withcat
and keep using the same process id as far as the kernel is interested. Your terminal is now runningcat
instead of your shell and the stdin, stdout and stderr are connected tocat
. When you now pressCtrl+C
your terminal interprets that as a command to send SIGINT to foreground process group. That group includes onlycat
. Andcat
handles that signal by quitting immediately -
Mikko Rantalainen over 3 years@vinc17 I think that shell could ask the child if it has already gotten the SIGINT if it really wanted:
waitpid(child_pid, wstatus, WUNTRACED | WNOHANG | WNOWAIT)
followed byWIFCONTINUED(*wstatus)
. Haven't tested if I've misunderstood something, though. -
vinc17 over 3 years@MikkoRantalainen I don't think that
waitpid
is correct, because the child may trapSIGINT
and choose not to terminate on oneSIGINT
, but terminate on two (some tools behave like that). So, if it receives a secondSIGINT
from its parent (thinking that its child didn't receive one), this would be bad. Moreover, there could be a race condition withwaitpid
. -
Mikko Rantalainen over 3 yearswaitpid() will return immediately with the flags I had in the example. Return value can be used to check if child has received SIGINT. It was response to "shell cannot know whether the child has already received the signal".
-
vinc17 over 3 years@MikkoRantalainen I still don't see how you can know whether the child has received a
SIGINT
. You saidWIFCONTINUED(*wstatus)
. But POSIX specifiesWIFCONTINUED
as: "Evaluates to a non-zero value if status was returned for a child process that has continued from a job control stop." And there is no stopped job involved here. AndWIFCONTINUED
does not say anything aboutSIGINT
.