After further digging, I've managed to track down the cause.
First, if Python inherits an OS-level setting of SIG_IGN for SIGINT from its parent process, it does not install its default SIGINT handler. It only installs that handler if it inherits a SIG_DFL setting. We can see this in the source code for the signal
module:
IntHandler = PyDict_GetItemString(d, "default_int_handler");
if (!IntHandler)
goto finally;
Py_INCREF(IntHandler);
_Py_atomic_store_relaxed(&Handlers[0].tripped, 0);
for (i = 1; i < NSIG; i++) {
void (*t)(int);
t = PyOS_getsig(i);
_Py_atomic_store_relaxed(&Handlers[i].tripped, 0);
if (t == SIG_DFL)
Handlers[i].func = DefaultHandler;
else if (t == SIG_IGN)
Handlers[i].func = IgnoreHandler;
else
Handlers[i].func = Py_None; /* None of our business */
Py_INCREF(Handlers[i].func);
}
if (Handlers[SIGINT].func == DefaultHandler) {
/* Install default int handler */
Py_INCREF(IntHandler);
Py_SETREF(Handlers[SIGINT].func, IntHandler);
PyOS_setsig(SIGINT, signal_handler);
}
Second, shell scripts run with job control disabled by default, and processes started with &
when job control is disabled inherit SIG_IGN settings for SIGINT and SIGQUIT. Quoting POSIX:
If job control is disabled (see the description of set -m) when the shell executes an asynchronous list, the commands in the list shall inherit from the shell a signal action of ignored (SIG_IGN) for the SIGINT and SIGQUIT signals.
I could not find an explicit standard quote saying job control is disabled by default in shell scripts, only quotes saying job control is enabled by default for interactive shells (vaguely implying the opposite setting for noninteractive shells):
-m
This option shall be supported if the implementation supports the User Portability Utilities option. All jobs shall be run in their own process groups. Immediately before the shell issues a prompt after completion of the background job, a message reporting the exit status of the background job shall be written to standard error. If a foreground job stops, the shell shall write a message to standard error to that effect, formatted as described by the jobs utility. In addition, if a job changes status other than exiting (for example, if it stops for input or output or is stopped by a SIGSTOP signal), the shell shall write a similar message immediately prior to writing the next prompt. This option is enabled by default for interactive shells.