3

I'm writing a script that runs a background process in parallel. When restarting the script I want to be able to kill the background process and exit it cleanly by sending it a CTRL_C_EVENT signal. For some reason though, sending the CTRL_C_EVENT signal to the child process also causes the same signal to be sent to the parent process. I suspect that the KeyboardInterrupt exception isn't being cleaned up after the child process gets it and is then caught by the main process.

I'm using Python version 2.7.1 and running on Windows Server 2012.

import multiprocessing
import time
import signal
import os

def backgroundProcess():
    try:
        while(True):
            time.sleep(10)

    except KeyboardInterrupt:
        #exit cleanly
        return


def script():
    try:
        print "Starting function"

        #Kill all background processes
        for proc in multiprocessing.active_children():
            print "Killing " + str(proc) + " with PID " + str(proc.pid)
            os.kill(proc.pid, signal.CTRL_C_EVENT)

        print "Creating background process"
        newProc = multiprocessing.Process(target=backgroundProcess)
        print "Starting new background process"
        newProc.start()
        print "Process PID is " + str(newProc.pid)

    except KeyboardInterrupt:
        print "Unexpected keyboard interrupt"

def main():
    script()
    time.sleep(5)
    script()

I expect that the script() function should never be receiving a KeyboardInterrupt exception, but it is triggered the second time that the function is called. Why is this happening?

  • 1
    `os.kill` in Windows is designed badly. Someone didn't understand that `GenerateConsoleCtrlEvent` is only for process groups (i.e. POSIX `kill` with a negative PID or `killpg`), and its behavior is buggy if you pass it a process ID that's not a process group. – Eryk Sun Oct 18 '19 at 18:56
  • However, multiprocessing doesn't allow creating a new process group anyway. So console control events are not a possible answer here. While you could broadcast to group 0 and disable the event for your current process, this would have undesired side effects if your parent process is attached to the same console and does not ignore the console control event. – Eryk Sun Oct 18 '19 at 18:57
  • You can use a [`multiprocessing.Event`](https://docs.python.org/2/library/multiprocessing.html#multiprocessing.Event) as an exit signal and have the workers either poll it or wait on it in a background thread. – Eryk Sun Oct 18 '19 at 19:03
  • @ErykSun What if my background process calls a blocking function and I can't poll for an event? I want to be able to send a Ctrl+C or some other signal that will immediately trigger a clean exit. – werepancake Oct 18 '19 at 19:44
  • I know in terminals ctrl_c gets "propagated out" by default...maybe break doesn't? – rogerdpack Oct 18 '19 at 20:03
  • @rogerdpack I tried break already, it also exits out of the parent process and I don't know how to catch it. Can you please explain what you mean by ctrl_c gets "propagated out" by default? Is there a way to make this not the default? – werepancake Oct 18 '19 at 20:13
  • @werepancake, use a background watchdog thread in each process that waits on the shared event. The main thread would still poll the event and exit normally, if possible. Once the shared event is set, the watchdog thread waits a few seconds for the main thread to set a local "exiting" event. If this wait times out, assume the main thread is blocked, and call `os._exit` in the watchdog thread. The system will terminate all other threads, so the watchdog thread is directly responsible for any graceful shutdown that's possible. Python `atexit` handlers will not be called. – Eryk Sun Oct 18 '19 at 20:37
  • Note that Windows console control events -- mapped to fake `SIGINT` / `SIGBREAK` 'signals' -- execute on a new thread and will never interrupt I/O or kernel waits in the main thread, unless the wait specifically includes Python's `SIGINT` event for Ctrl+C, which to date is just a couple of cases, including `time.sleep`. So a blocked main thread is *almost always* a problem in Windows. – Eryk Sun Oct 18 '19 at 20:40
  • Apparently the "normal way" to do this is catch and ignore ctrl+c signal in the parent: https://stackoverflow.com/questions/813086/can-i-send-a-ctrl-c-sigint-to-an-application-on-windows/15281070#15281070 – rogerdpack Oct 21 '19 at 03:07

1 Answers1

1

I'm still looking for an explanation as to why the issue occurs, but I'll post my (albeit somewhat hacky) workaround here in case it helps anyone else. Since the Ctrl+C gets propagated to the parent process (still not entirely sure why this happens), I'm going to just catch the exception when it arrives and do nothing.

Eryk suggested using an extra watchdog thread to handle terminating the extra process, but for my application this introduces extra complexity and seems a bit overkill for the rare case that I actually need to kill the background process. Most of the time the background process in my application will close itself cleanly when it's done.

I'm still open to suggestions for a better implementation that doesn't add too much complexity (more processes, threads, etc.).

Modified code here:

import multiprocessing
import time
import signal
import os

def backgroundProcess():
    try:
        while(True):
            time.sleep(10)

    except KeyboardInterrupt:
        #Exit cleanly
        return


def script():
    print "Starting function"

    #Kill all background processes
    for proc in multiprocessing.active_children():
        print "Killing " + str(proc) + " with PID " + str(proc.pid)
        try:
            #Apparently sending a CTRL-C to the child also sends it to the parent??
            os.kill(proc.pid, signal.CTRL_C_EVENT)
            #Sleep until the parent receives the KeyboardInterrupt, then ignore it
            time.sleep(1)
        except KeyboardInterrupt:
            pass

    print "Creating background process"
    newProc = multiprocessing.Process(target=backgroundProcess)
    print "Starting new background process"
    newProc.start()
    print "Process PID is " + str(newProc.pid)

def main():
    script()
    time.sleep(5)
    script()
  • Under the hood, `os.kill` calls `GenerateConsoleCtrlEvent` for this case. It is wrong to pass it a PID that's not a process group ID. You're triggering undefined, buggy behavior that causes the console host (conhost.exe) to send the event to all processes, or possibly far worse if the child isn't attached to the same console, in which case it breaks future control events. If you want this behavior reliably, explicitly target group 0 (all processes). But, as I noted, you're sending to all attached ancestor processes as well, which can have unknown consequences and is not a reliable design. – Eryk Sun Oct 18 '19 at 22:53