Signal interception? propagation? No
wait does not intercept the signal. The shell does not pass it. The signal is neither intercepted nor propagated. The subshells running your three "background" commands either get the signal directly or not.
You can test with the following script:
#!/usr/bin/env bash
printf 'My PID is %s.\n' "$$"
sleep 15 && echo '123' &
sleep 15 && echo '456' &
sleep 15 && echo '999' &
wait < <(jobs -p)
The script behaves like your script with wait. I mean you can terminate it and the three "background" jobs with Ctrl+C.
Run it again and don't hit Ctrl+C. The script will tell you its own PID ($$). Now if you send SIGINT from another terminal (kill -s INT <pid_here>) then it will terminate the script but not the three jobs.
But if you send SIGINT to the entire process group (kill -s INT -- -<pid_here>) then the script and the jobs will get it. The same happens when you hit Ctrl+C and your terminal is set to send an interrupt signal upon the keystroke (it normally is, stty -a includes intr = ^C), the entire group receives the signal.
Who gets the signal?
It's about the foreground process group. One of the tasks a shell does is informing the terminal which process group is in the foreground.
A terminal may have a foreground process group associated with it. This foreground process group plays a special role in handling signal-generating input characters […].
A command interpreter process supporting job control can allocate the terminal to different jobs, or process groups, by placing related processes in a single process group and associating this process group with the terminal. […]
(source)
This is what really happens in your case:
- Initially you're in an interactive shell. The shell is the leader of its own process group (with process group ID equal to the PID of the shell). The terminal recognizes this process group as the foreground process group.
- The said shell has job control enabled. (In general shells that support job control enable it by default when running interactively.) When you start the script (or basically any non-builtin), the shell makes it the leader of another process group. The terminal is informed the new process group should now be considered the foreground process group. The interactive shell puts itself in the background this way.
- From the shebang we know it's
bash interpreting the script. Non-interactive Bash starts with job control disabled by default. Commands run by it will not become leaders of their own process groups (unless the command itself changes its process group; it's possible but it doesn't happen in your case). The three subshells and the three sleep processes belong to the same process group as the running script.
- When the script terminates, the terminal is informed the process group of the interactive shell should now be considered the foreground process group (again). If any process from the process group of the script is still running, it will no longer be in the foreground process group.
This explains the behavior you observed:
If the script is running when you hit Ctrl+C then it and basically all its descendants will receive SIGINT. It doesn't matter if the script waits because of wait or because of some command executed without &. What matters is its process group is still the foreground process group and the (grand)children belong to the group.
If the script is no longer running when you hit Ctrl+C then its descendants (if any) will not receive SIGINT because they do not belong to the current foreground process group.
Playing with job control
Note you can alter this behavior by disabling or enabling job control.
If you disable job control (set +m) in the interactive shell and run the script then the shell will run the script without making it the leader of a process group. There is no job control in the script. The script and basically all its children will belong to the process group of the interactive shell. This group will be the foreground process group the entire time. Upon Ctrl+C all the processes (including the interactive shell) will receive SIGINT, regardless whether the script is still running or not.
If you enable job control (set -m) in the script itself then the three subshells will be put in their respective process groups. In similar circumstances a command without & would become a process group leader and the terminal would be informed about the new foreground process group. But your commands are with &, they will become leaders but the foreground process group won't change. Upon Ctrl+C they won't receive SIGINT, regardless of whether the script is still running or not, and regardless if the interactive shell has job control enabled.
Notes
The meaning of & separator or terminator is often described as "run in background", but it's not equivalent to "run not in the foreground process group". Commands run with & can stay in the foreground process group (e.g. if job control is disabled). Commands run without & can leave the foreground process group. What you can be sure is & means "run asynchronously".
You may be surprised by the fact that an interactive shell puts itself in the background while running commands. This really happens. Run these commands in an interactive Bash:
set -m
trap 'echo "Signal received."' INT
sleep 999
Ctrl+C
You will see the sleep was interrupted but the shell did not receive the signal. This is because the shell had put sleep in a separate process group which was the foreground process group at the moment of the keystroke. The shell was not in the (then) foreground process group, this means it was in the background.
Now change set -m to set +m and run again:
set +m
trap 'echo "Signal received."' INT
sleep 999
Ctrl+C
With job control disabled, sleep will run in the process group of the shell. The group will be the foreground process group the entire time. You will see a message from the trap.