Search code examples
pythonbashsubprocesssignalsposix

Linux: why does launching /bin/bash as a subprocess from Python3 (or other interpreters) make the parent process immune to SIGINT (ctrl-c)?


Can someone explain what is going on here at a low level? Why does launching a shell (bash, ksh) as a child subprocess from Python3, Rake, Ruby or Make, cause the terminal to behave like the parent process does not receive the SIGINT generated by a ctrl-c? The parent certainly does interrupt when the subprocess is not a shell, so what is really going on? I'm seeing that the child and parent are both part of the same process group throughout execution.

How does the child make the parent immune to SIGINT? If this is accomplished with channel rerouting or some other fancy trickery, please explain how this is done with an example. I'd like to fix what I'm seeing as inconsistent behavior between EDA tools that have CLI's; some act as Bash does in a subproc, others will be left as orphans.

Test code: This code will first launch bash as a subprocess that can't be interrupted with ctrl-c, despite being wrapped by Python3; you will have to exit to return back to the parent. The code will then launch child.py which has an interrupt handler; when you ctrl-c inside the child, you will see that both parent.py raise and child.py interrupt at the same time, leaving child.py in orphaned state while its SIGINT handler spews to stdout; the exit status is that of parent; the child's status is lost.

parent.py:

#!/usr/bin/env python3
import os, sys
import subprocess
import signal
import time

PID = os.getpid()
PGID = os.getpgid(PID)
PROC_LABEL = f'PARENT: pid={PID}, pgid={PGID}'

print(f"{PROC_LABEL}: spawning bash...")
proc = subprocess.Popen(['bash'])
ret = proc.wait()
print(f"{PROC_LABEL}: child exit status:", proc.returncode)

print(f"{PROC_LABEL}: spawning ./child.py...")
proc = subprocess.Popen(['./child.py'])
proc.wait()
print(f"{PROC_LABEL}: child exit status:", proc.returncode)
sys.exit(0)

child.py

#!/usr/bin/env python3
import os, sys
import signal
import time

PID = os.getpid()
PGID = os.getpgid(PID)
PROC_LABEL = f"CHILD : pid={PID}; pgid={PGID}"

def intr_handler(sig, frame):
  print(f'\n{PROC_LABEL}: Trapped: {signal.Signals(sig).name}')
  for idx in range(3,0,-1):
    print(f"{PROC_LABEL}: sleeping for {idx} seconds")
    time.sleep(1)
  print(f"{PROC_LABEL}: bye")
  sys.exit(100)
signal.signal(signal.SIGINT, intr_handler)

ret = input(f"{PROC_LABEL}: type something: ")
print("input:", ret)
sys.exit(0)

execution:

$ ./parent.py 
PARENT: pid=3121412, pgid=3121412: spawning bash...
bash> ^C
bash> exit 0
exit
PARENT: pid=3121412, pgid=3121412: child exit status: 0
PARENT: pid=3121412, pgid=3121412: spawning ./child.py...
CHILD : pid=3121728; pgid=3121412: type something: ^C
CHILD : pid=3121728; pgid=3121412: Trapped: SIGINT
CHILD : pid=3121728; pgid=3121412: sleeping for 3 seconds
Traceback (most recent call last):
  File "./parent.py", line 18, in <module>
    proc.wait()
  File "/m/tools/lang/python/pyenv/versions/3.7.6/lib/python3.7/subprocess.py", line 1019, in wait
    return self._wait(timeout=timeout)
  File "/m/tools/lang/python/pyenv/versions/3.7.6/lib/python3.7/subprocess.py", line 1653, in _wait
    (pid, sts) = self._try_wait(0)
  File "/m/tools/lang/python/pyenv/versions/3.7.6/lib/python3.7/subprocess.py", line 1611, in _try_wait
    (pid, sts) = os.waitpid(self.pid, wait_flags)
KeyboardInterrupt
$ CHILD : pid=3121728; pgid=3121412: sleeping for 2 seconds
CHILD : pid=3121728; pgid=3121412: sleeping for 1 seconds
CHILD : pid=3121728; pgid=3121412: bye
echo $?
1

Solution

  • runs in a different process group than your process and takes over as the foreground process which makes it receive the signals while the parent doesn't.

    "The setpgid() and getpgrp() calls are used by programs such as bash(1) to create process groups in order to implement shell job control."

    You can check the process group with ps o pid,pgrp <python-pid> <subprocess-pid>. For a regular sub-process, you'll see the same process group for both the script and the sub-process, while some programs, like , creates a new process group.

    also installs its own signal handlers.

    Example on Linux:

    root# grep ^Sig /proc/$SUBPROCESS_PID/status
    SigPnd: 0000000000000000                     # pending
    SigBlk: 0000000000000000                     # blocked
    SigIgn: 0000000000380004                     # ignored
    SigCgt: 000000004b817efb                     # caught
    

    The SigCgt field is the interesting. It's a bitmask:

    $ bc <<< "ibase=16; obase=2; 4B817EFB"
    1001011100000010111111011111011
                                 |
                                SIGINT
    

    You can create a program the does the same thing as . Example:

    // sig.cpp
    
    #include <unistd.h>
    
    #include <cerrno>
    #include <csignal>
    #include <cstring>
    #include <iostream>
    #include <stdexcept>
    
    static int gsig = -1; // the latest caught signal
    
    static void sighandler(int sig) {
        gsig = sig;
    }
    
    int check(int val) {
        if(val) std::runtime_error(std::strerror(errno));
        return val;
    }
    
    int main() {
        try {
            // catch a lot...
            for(auto sig : {SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM}) {
                check(std::signal(sig, sighandler)==SIG_ERR);
            }
    
            /* ignore terminal settings changes */
            check(signal(SIGTTOU, SIG_IGN)==SIG_ERR);
    
            // create new process group
            check(::setpgrp());
    
            // get the created process group
            pid_t pgrp = ::getpgrp();
    
            // set forground process group to the created process group
            check(::tcsetpgrp(::fileno(stdin), pgrp));
    
            std::cout << "-- waiting --" << std::endl;
    
            while(true) {
                ::pause();
                std::cout << "got signal " << gsig << std::endl;
            }
        } catch(const std::exception& ex) {
            std::cerr << "Exception: " << ex.what() << std::endl;
        }
    }
    

    Compile

    $ g++ -o sig sig.cpp -std=c++11 -O3
    

    If you now put this program in your ./parent.py script, you'll see a similar behavior as that of .