KeyboardInterrupt and SystemExit in exception groups should be considered for Python's exit code

I’ll start with an example program to try to show what I think is a pitfall. Then I’ll be more specific.

Example program

Let’s say I have a framework where I want to add some sort of “shutdown” process. A prototype could look something like:

finalizers = []

def add_finalizer(function):
  finalizers.append(function)

def shutdown():
  global finalizers
  held_finalizers = finalizers
  finalizers = []

  for finalizer in held_finalizers:
    finalizer()

However, this has a flaw: I will skip any finalizers added after a finalizer that would error. This is not good, as finalizers probably perform some important task! Luckily as of Python 3.11 exception groups exist – I can use those. Now my shutdown looks like:

def shutdown():
  global finalizers
  held_finalizers = finalizers
  finalizers = []
  exceptions = []

  for finalizer in held_finalizers:
    try:
      finalizer()
    except BaseException as e:
      exceptions.append(e)

  if exceptions:
    raise BaseExceptionGroup("exceptions during shutdown", exceptions)

All’s good right? I’ve handled all the cases, right? Let’s say I wanted the program to exit with exit code 3 when shut down – maybe this logic is gated behind a global boolean or something.

import sys

@add_finalizer
def f():
  sys.exit(3)

shutdown()

… huh?

PS C:\Users\A5rocks\Documents\Temp> python x.py
  + Exception Group Traceback (most recent call last):
  |   File "C:\Users\A5rocks\Documents\Temp\x.py", line 28, in <module>
  |     shutdown()
  |     ~~~~~~~~^^
  |   File "C:\Users\A5rocks\Documents\Temp\x.py", line 19, in shutdown
  |     raise BaseExceptionGroup("exceptions during shutdown", exceptions)
  | BaseExceptionGroup: exceptions during shutdown (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "C:\Users\A5rocks\Documents\Temp\x.py", line 14, in shutdown
    |     finalizer()
    |     ~~~~~~~~~^^
    |   File "C:\Users\A5rocks\Documents\Temp\x.py", line 26, in f
    |     sys.exit(3)
    |     ~~~~~~~~^^^
    | SystemExit: 3
    +------------------------------------
PS C:\Users\A5rocks\Documents\Temp> $LastExitCode
1

Just for completeness, the “correct” shutdown is

def shutdown():
  # as before

  if exceptions:
    handled_exit = False

    try:
      raise BaseExceptionGroup("exceptions during shutdown", exceptions)

    except* SystemExit as eg:
      codes = [e.code for e in eg.exceptions]
      assert len(codes) == 1
      handled_exit = True
      raise SystemExit(codes[0])

    except* KeyboardInterrupt:
      if not handled_exit:
        raise KeyboardInterrupt()

I think that a KeyboardInterrupt or a SystemExit within an exception group should behave as if those exceptions weren’t in an exception group with regards to the final exit code. Given that this is an exception group and multiple exceptions can be within,

  • I think SystemExit should take priority over KeyboardInterrupt;
  • and if there’s multiple SystemExits, a warning should be emitted and an arbitrary one (the first?) should be used.

There are some benefits to doing this work. Evidently the pitfall would be removed, but in addition the current “solution” prevents printing out any other exceptions if there’s a SystemExit or KeyboardInterrupt.

I’ve already made Treat `KeyboardInterrupt` or `SystemExit` the same on program exit even if they are inside exception groups · Issue #130713 · python/cpython · GitHub but was redirected here. The behavior for KeyboardInterrupt was added due to Python doesn't exit with proper resultcode on SIGINT · Issue #41078 · python/cpython · GitHub in CPython 3.8 and the exit code for KeyboardInterrupt differs on Windows for PyPy vs CPython so I don’t think a PEP is necessary. I volunteer to implement this if this is fine; I think implementation would only change pythonrun.c’s _Py_HandleSystemExitAndKeyboardInterrupt and (potentially) main.c.

I’m not familiar with the norms here so let me know if I forgot some important information!

In this particular case I think the problem is connected with two design decisions:

notes
  1. I find it questionable that a shutdown+cleanup sequence should continue even after sys.exit(3) in one of the finalizers. There are other ways to deal with the requirement to set the exit code. It makes impossible to exit only the shutdown and let the Python do its cleanup.

  2. This point is arguable. It’s about the apporach to sequentially gather caught exceptions in different finalizers and then to raise them as a single group. Exception groups were introduced mainly for concurent errors or multiple errors during retrying etc. Also, there is no reasonable way to handle shutdown problems other than just exiting, that’s why there is no reason to summarize the shutdown errors.

Anyway, the posted proposal is more general and I would like to know the answer too: when the Python interpreter catches an exception group (a tree-like structure of errors) , should we expect that it selects the most important BaseException from the group? Or is the opposite is true? If we want a clearly defined exit status, we have to give an unambiguous termination cause.

This is a very good question. If we accept the proposal, what should the exit code be if there are multiple SystemExit instances in the exception group?

1 Like

The one with the largest value. On the basis that the higher the number the worse the problem discovered.

Maybe you misunderstood? This is solely about error codes. The termination cause is already unambiguously the exception group – and it’s not like SystemExit’s behavior of not printing out a stack trace is very unambiguous as a termination cause either.

As stated in the post:

However it sounds like max(exit codes) is a better heuristic than first SystemExit given others think that.

I don’t get it. After reading 8. Compound statements — Python 3.13.2 documentation I wrote test program below and it worked exactly as I expected. Why would we change it?

def one():
    raise SystemExit(3)

def two():
    raise SystemExit(4)

def three():
    input("Ctrl-C")

def four():
    raise ValueError("Tuesday")


bad_fn = [four,three,two,one]

exceptions = []
for fn in bad_fn:
    try:
        fn()
    except BaseException as e:
        exceptions.append(e)

try:
    raise BaseExceptionGroup('disccus',exceptions)

except* SystemExit as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")
except* KeyboardInterrupt as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")
except* ValueError as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")

output was:

Ctrl-C^Ctype <class 'SystemExit'> 4
type <class 'SystemExit'> 3
type <class 'KeyboardInterrupt'> 
type <class 'ValueError'> Tuesday

Things are fine at runtime! The only issue is the exit code of the program when run. This proposal wouldn’t change anything at runtime (unless that thing is a subprocess call to a Python script which exits differently now).

Oh. The exit code is whatever I want it to be. e.g.

ec = 0
try:
    raise BaseExceptionGroup('disccus',exceptions)
except* SystemExit as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")
        ec += sub.code
except* KeyboardInterrupt as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")
    ec = 99 
except* ValueError as e:
    for sub in e.exceptions:
        print(f"type {type(sub)} {sub}")
    ec = 100
sys.exit(ec)

it would not matter to me if the next release of Python had some rules what to make it. I probably won’t remember them and would probably set an explicit exit code as shown above. (Edited for clarity)

My two cents regarding the cross-implementation problem is that this is probably fine for KeyboardInterrupt, but not so much for SystemExit. I’m not too comfortable with raise BaseExceptionGroup(..., SystemExit(42)) being a 42 return code on CPython, and then whatever exceptions are for separate implementations; that’s bound to cause some interesting bugs!

I do not understand your concern.

Why would my python script be prevented from using any allowed exit code?
By allowed I mean by POSIX, WIN32, etc.
It is often an important part of a tools API.

On Fedora man python does not list exit codes at all.

1 Like

Test cases that rely on a return code are my main concern, but you could also imagine some piece of code like this in production:

import subprocess
import sys


def invoke_isolated_script():
    try:
        subprocess.run([sys.executable, '...'], check=True)
    except subprocess.CalledProcessError as e:
        raise RuntimeError(f"Process exited with exit code: {e.returncode}") from e

If the invoked script happened to terminate using something like BaseExceptionGroup(..., SystemExit(0)), then that would fail on an implementation that doesn’t implement that.

I’m speculating a lot, though! We might be able to get away with it assuming we document the behavior with a CPython implementation detail note.


Something that’s not clear to me from the post or the issue is the actual use-case–raising a SystemExit from a finalizer doesn’t seem like something that’s common, or very useful. With finalizers, the common practice (that I’ve seen) is completely throwing out exceptions (either via an unraisable in C, or by just printing it with traceback), because there’s nothing you can do anyway, and nobody that could try/except it.

0 is success and the check=True will be happy.

If you run a script that has a API where the result code is important then any code running it will have to take that into account.

I fail to see an issue.

1 Like

Yes, but not on other implementations! There really is code out there that relies on CPython’s return code, and just adding more ways to be inconsistent isn’t too helpful to them. Worse, we’re technically changing how an exception propagates. (The example script would still have that exception printed on a separate implementation, which isn’t terrible, but not great for portability–pure-Python code shouldn’t have discrepancies like that, especially in a builtin.)

But again, I’m not against the whole idea; it’s just pretty demotivating to do it PEP-less. If there are convincing use-cases, then it’s probably worth pursuing. 3.14’s last alpha is close enough and this is small enough that I think a quick PEP could be drafted up in time, but that requires a lot of motivation from the author :wink:.

Do you mean on a return code that is not part of the logic of the script that is run?

Implementation of what exactly?

I should probably let some more discussion happen before clarifying things, but whatever -

I know I vaguely mentioned this in my original post, but I’ll make this explicit. This program has a different exit code on Python 3.8 vs Python 3.7:

raise KeyboardInterrupt()

No PEP was necessary. raise KeyboardInterrupt() has 36k results in a GitHub code search, raise BaseExceptionGroup has 230. As far as I know, nobody complained about the change to KI though I didn’t look that hard.

(“But those 230 have an outsized effect as BaseExceptionGroup is mostly for libraries!” True, however not all 230 can actually raise a KeyboardInterrupt or a SystemExit. Talking about examples from stdlib: 2 files are just test files, 1 is asyncio.TaskGroup – which special cases SystemExit and KeyboardInterrupt by discarding other exceptions (they use the heuristic of preferring first exception always in case of multiple SystemExit / KeyboardInterrupt). I’m not sure the reason for this special casing FWIW.)

I chose finalizers as an example because it’s the 3rd “real world use case” in PEP 654. I’m not writing a mini async library nor do I know APIs that require retrying that also can raise the relevant exceptions. For the record this was first brought to my attention due to KeyboardInterrupt, where the example is more “I press ctrl+c in concurrent code”.

… I’m probably missing some implied context. I assume you’re talking about my mention in my original issue about not printing SystemExit in an exception group? I actually somewhat agree. I’m not sure we should change the behavior. The only argument for the change is consistency… However I wouldn’t mind changing it for consistency’s sake either given the downside is less severe – what kind of person parses exception group’s printed stack trace in a production application on exit! I consider that choice completely orthogonal to whether a SystemExit in a BaseExceptionGroup should modify the exit code.

(nitpick: that isn’t about how an exception propagates)


I’m not entirely sure what people are talking about regarding being restricted from certain error codes (given they already don’t work in this case) but I assume that’s just me misunderstanding things/missing context.

From 3.8 onwards strace shows this as the final calls:

strace python3.8 -c 'raise KeyboardInterrupt()'
...
getpid()                                = 7986
kill(7986, SIGINT)                      = 0
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=7986, si_uid=1000} ---
+++ killed by SIGINT +++

Which is not what 3.7 does when it exits.
I assume that this is too make typing Ctrl-C and raising KeyboardInterrupt() to do the same thing. And seems to be the right thing to do.

Wait. You’re saying that the exit code from an uncaught KeyboardInterrupt exception isn’t consistent between Python versions? My reaction to that is “well, obviously”. If you want a consistent exit code, catch the exception and return the exception you wanted.

I’m not sure how any of this relates to your original post, but I don’t think it’s reasonable to want Python to guarantee the exit code for any uncaught exception other than SystemExit (which is explicitly defined as setting the program exit code).

1 Like

I’m mostly just making a point that changing the exit code from 1 for certain exceptions is fine and that I don’t have to go through the PEP process.

So my point is basically what you said here:

No PEP was necessary. raise KeyboardInterrupt() has 36k results in a GitHub code search, raise BaseExceptionGroup has 230. As far as I know, nobody complained about the change to KI though I didn’t look that hard.

You’re getting hung up on KeyboardInterrupt–I already said I don’t have much of a preference for that :smile:

But I’d also like to hear more about the use-case; is it really that useful to have raise BaseExceptionGroup(..., [KeyboardInterrupt()]) be the SIGINT return and not 1?

For SystemExit–it’s probably fine to do right now (it’s unlikely that it will break code), but my concern is that we don’t want users to rely on that once it’s implemented, because it would be an implementation detail.

Going back to your example use-case (e.g., raise BaseExceptionGroup(..., [SomeOtherError(), SystemExit(0)])), running that on something that checks the return value (for example, GitHub Actions) would work under CPython, but not under any other Python implementation. The contrasting case is KeyboardInterrupt(), where both would fail anyway–that’s why I’m not opposed to changing that.