1

If starting a subprocess from Python:

from subprocess import Popen

with Popen(['cat']) as p:
    pass

is it possible that the process starts, but because of a KeyboardInterrupt caused by CTRL+C from the user, the as p bit never runs, but the process has indeed started? So there will be no variable in Python to do anything with the process, which means terminating it with Python code is impossible, so it will run until the end of the program?

Looking around the Python source, I see what I think is where the process starts in the __init__ call at https://github.com/python/cpython/blob/3.11/Lib/subprocess.py#L807 then on POSIX systems ends up at https://github.com/python/cpython/blob/3.11/Lib/subprocess.py#L1782C1-L1783C39 calling os.posix_spawn. What happens if there is a KeyboardInterrupt just after os.posix_spawn has completed, but before its return value has even been assigned to a variable?

Simulating this:

class FakeProcess():
    def __init__(self):
        # We create the process here at the OS-level,
        # but just after, the user presses CTRL+C
        raise KeyboardInterrupt()

    def __enter__(self):
        return self

    def __exit__(self, _, __, ___):
        # Never gets here
        print("Got to exit")

p = None
try:
    with FakeProcess() as p:
        pass
finally:
    print('p:', p)

This prints p: None, and does not print Got to exit.

This does suggest to me that a KeyboardInterrupt can prevent the cleanup of a process?

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • Your ```print('p:', p)``` statement will always print _None_ in that place as you set ```p = None``` in the global scope so as soon as you leave your try block scope it falls back on the global definition of p. Included a link to description of scopes below. Also have you tried defining your ```__del__``` function? Try and define it with the text "Got to delete" and run the code and see what you get as a result from that. I am going to be honest this is probably my last response. Hope I was a help! https://www.geeksforgeeks.org/global-local-variables-python – SomeSimpleton Aug 17 '23 at 02:31
  • Ah @SomeSimpleton a with statement context in Python doesn’t introduce a scope I think. Any variables “in” it are in the same scope as variables out of it? – Michal Charemza Aug 17 '23 at 04:24
  • 1
    @SomeSimpleton And `__del__` does get called... but (as per my comment on your answer), there does seem to be a race condition in that the process can start in the OS level, but then `__del__` doesn't won't know about it. Although as you say, maybe the chance is really small – Michal Charemza Aug 17 '23 at 04:41
  • The ```try:``` block introduces a scope. The ```with``` statement just redefines ```p``` within that local ```try:``` block scope – SomeSimpleton Aug 17 '23 at 18:56
  • 1
    @SomeSimpleton I don't think `try:` introduces a scope either? e.g. the first line of https://stackoverflow.com/a/25666911/1319998 – Michal Charemza Aug 18 '23 at 05:02
  • Hmm your right. I am going to have to look into this a bit deeper later tonight. Also still need to read your post below. I need to be somewhere soon but hopefully we can continue the discussion in a few hours. – SomeSimpleton Aug 18 '23 at 21:56

2 Answers2

1

I dont think you should have a problem with that. Plus your opening the Popen within a context manager so as soon as it exits that indent the context manager should automatically properly exit the Popen session for you.

I believe it is the same as a finally statement in a try block where it will still run the finally statement upon a keyboard interrupt. I will include the documentation link to the context manager below so you can read more about it.

Edit: The context manager does use a try block with a finally statement to release/exit.

https://docs.python.org/3/library/contextlib.html

Edit For Follow up:

So for if the __init__ isnt fully complete and a keyboard interrupt is called it should get handled by garbage collection and the __del__ function should automatically be called. I provided the link for that below. That should handle everything for you.

https://github.com/python/cpython/blob/e3a11e12ab912f0614a90ade7acd34dda7e7f15e/Lib/subprocess.py#L1120C7-L1120C7

SomeSimpleton
  • 350
  • 1
  • 2
  • 12
  • Once in the context, yes all good and I'm very happy with that I think. But I'm concerned about what happens before entry into the context, under the hood. I'll see if I can add more detail to the question. – Michal Charemza Aug 16 '23 at 21:15
  • 1
    @MichalCharemza No need I can give you the answer to that also sorry sorta forgot about that part. Here ill update the answer. – SomeSimpleton Aug 16 '23 at 21:19
  • Ah! The `__del__` function. I didn't consider that – Michal Charemza Aug 16 '23 at 21:33
  • @MichalCharemza Hopefully that makes sense. I am guessing your coming from something like C or C++? Python is a lot more beginner friendly and has a lot of safeguards in place. – SomeSimpleton Aug 16 '23 at 21:33
  • I know some C and C++, but I guess I wouldn't say I come from it. I think just more and more realising that KeyboardInterrupt happening at any point can be... quite complicated to deal with. (I might even argue _not_ beginner friendly in this regard?) – Michal Charemza Aug 16 '23 at 21:36
  • Actually... looking at `__del__`, I'm starting to be less convinced. The `if not self._child_created` bit for example. It looks like in places `self._child_created` is set to `True` only after the process is created, which means the process can get created and then immediately a KeyboardInterrupt raised, and `self._child_created` never gets set – Michal Charemza Aug 16 '23 at 21:45
  • I'm starting to think that this is just unavoidable in Python? `a = func()` in Python means that `func()` can run but its return value _never_ gets assigned to `a` due to a KeyboardInterrupt, so it's impossible to have any cleanup code for it? – Michal Charemza Aug 16 '23 at 21:57
  • Maybe https://stackoverflow.com/q/842557/1319998 is related? – Michal Charemza Aug 16 '23 at 22:03
  • @MichalCharemza your talking nano to milliseconds of timing to be able to have a keyboard interrupt called between the process being opened and the Boolean being set. You honestly have a higher chance of being struck by lightning while being bitten by a shark at that point. – SomeSimpleton Aug 16 '23 at 22:13
1

From https://docs.python.org/3/library/signal.html#note-on-signal-handlers-and-exception a KeyboardInterrupt is caused by Python's default SIGINT handler. And specifically it suggests when this can be raised:

If a signal handler raises an exception, the exception will be propagated to the main thread and may be raised after any bytecode instruction. Most notably, a KeyboardInterrupt may appear at any point during execution.

So it can be raised between, but not during, any Python bytecode instruction. So it all boils down to is "calling a function" (os.posix_spawn in this case) and "assigning its result to a variable" one instruction, or multiple.

And it's multiple. From https://pl.python.org/docs/lib/bytecodes.html there are STORE_* instructions which are all separate from calling a function.

Which means that in this case, a process can be made at the OS level that Python doesn’t know about.

https://docs.python.org/3/library/signal.html#note-on-signal-handlers-and-exception also states

Most Python code, including the standard library, cannot be made robust against this, and so a KeyboardInterrupt (or any other exception resulting from a signal handler) may on rare occasions put the program in an unexpected state.

Which hints as to how, I think pervasive, albeit maybe rare in practice, such issues can be in Python.

But https://docs.python.org/3/library/signal.html#note-on-signal-handlers-and-exception also gives a way of avoiding this:

applications that are complex or require high reliability should avoid raising exceptions from signal handlers. They should also avoid catching KeyboardInterrupt as a means of gracefully shutting down. Instead, they should install their own SIGINT handler.

Which you can do if you really need/want to. Taking the answer from https://stackoverflow.com/a/76919499/1319998, which is itself based on https://stackoverflow.com/a/71330357/1319998 you can essentially defer SIGINT/the KeyboardInterrupt

import signal
from contextlib import contextmanager

@contextmanager
def defer_signal(signum):
    # Based on https://stackoverflow.com/a/71330357/1319998

    original_handler = None
    defer_handle_args = None

    def defer_handle(*args):
        nonlocal defer_handle_args
        defer_handle_args = args

    # Do nothing if
    # - we don't have a registered handler in Python to defer
    # - or the handler is not callable, so either SIG_DFL where the system
    #   takes some default action, or SIG_IGN to ignore the signal
    # - or we're not in the main thread that doesn't get signals anyway
    original_handler = signal.getsignal(signum)
    if (
            original_handler is None
            or not callable(original_handler)
            or threading.current_thread() is not threading.main_thread()
    ):
        yield
        return

    try:
        signal.signal(signum, defer_handle)
        yield
    finally:
        # Note: if you have installed a signal handler for another
        # signal that raises an exception, the original handler won't
        # ever get re-attached
        signal.signal(signum, original_handler)
        if defer_handle_args is not None:
            original_handler(*defer_handle_args)

to create a context manager with a stronger guarantee that you don't get a sort of zombie process due to a SIGINT during its creation:

@contextmanager
def PopenDeferringSIGINTDuringConstruction(*args, **kwargs):
    # Very much like Popen, but defers SIGINT during its __init__, which is when
    # the process starts at the OS level. This avoids what is essentially a
    # zombie process - the process running but Python having no knowledge of it
    #
    # It doesn't guarentee that p will make it to client code, but should
    # guarentee that the subprocesses __init__ method is not interrupted by a
    # KeyboardInterrupt. And if __init__ raises an exception, then its
    # __del__ method also shouldn't get interrupted, and so will clean
    # up as best as it can

    with defer_signal(signal.SIGINT):
        p = Popen(*args, **kwargs)

    with p:
        yield p

which can be used as:

with PopenDeferringSIGINTDuringConstruction(['cat']) as p:
    pass

Or... instead of dealing with signal handlers, as https://stackoverflow.com/a/842567/1319998 suggests, you can start the process in a thread, which doesn't get interrupted by signals. But then you are left with having to deal with both inter thread and iter process communication.

import threading
from contextlib import contextmanager
from subprocess import Popen

@contextmanager
def PopenInThread(*args, **kwargs):
    # Very much like Popen, but starts it in a thread so its __init__
    # (and maybe __del__ method if __init__ raises an exception) don't
    # get interrupted by SIGINT/KeyboardInterrupt

    p = None
    exception = None

    def target():
        nonlocal p, exception
        try:
            p = Popen(*args, **kwargs)
        except Exception as e:
            exception = e

    t = threading.Thread(target=target)
    t.start()
    t.join()

    if exception is not None:
        raise exception from None

    with p:
        yield p

used as

with PopenInThread(['cat']) as p:
    pass

Choose your poison...

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165