Skip to content

KeyboardInterrupt not handled while evaluating a tight while loop condition on Python 3.13 #130279

Open

Description

@harshil21

Bug report

Bug description:

Consider the following infinite loop:

# test.py

class A:
    def __init__(self):
        self.shutdown_requested = False

    def start(self):
        pass

    def stop(self):
        self.shutdown_requested = True


def loop_1(a: A):
    try:
        while not a.shutdown_requested:
            pass

    except KeyboardInterrupt:
        print("in except block")

    finally:
        print("in finally block")
        a.stop()

def loop_2(a: A):
    try:
        while True:
            if a.shutdown_requested:
                break
    except KeyboardInterrupt:
        print("in except block")

    finally:
        print("in finally block")
        a.stop()


if __name__ == "__main__":
    a = A()
    a.start()
    loop_1(a)
    # loop_2(a)
^CTraceback (most recent call last):
  File "/home/harshil/test.py", line 40, in <module>
    loop_1(a)
    ~~~~~~^^^
  File "/home/harshil/test.py", line 14, in loop_1
    while not a.shutdown_requested:
              ^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt

If you run this program on Python 3.13 (3.13.1, and 3.13.2), pressing Ctrl+C does not execute the except and finally blocks in the case of loop_1(), but it works for loop_2().

If you run the same program on 3.14a5, or below 3.13, the issue does not happen, and the print statements are visible too.

CPython versions tested on:

3.12, 3.13, 3.13t, 3.14, 3.10

Operating systems tested on:

Linux

Linked PRs

Activity

  1. LH-and-FPGA commented on Feb 19, 2025

    @LH-and-FPGA

    Interestingly, if you add time.sleep(0.1) inside the while loop in loop_1, this strange phenomenon will not occur. (My environment is Windows 11, Python 3.13.1)

  2. wilmerwang commented on Feb 19, 2025

    @wilmerwang

    I think it is caused by busy wait. Maybe we can use global signal, multithreading or simulated clock interrupt.

  3. LH-and-FPGA commented on Feb 19, 2025

    @LH-and-FPGA

    But the second one also has a busy wait, why can it still work? That's strange.

  4. colesbury commented on Feb 19, 2025

    @colesbury
    Contributor

    cc @markshannon @iritkatriel:

    the next_instr is after the JUMP_BACKWARD in 3.13 when the eval breaker is checked, so it doesn't dispatch to the correct exception handler:

    TARGET(JUMP_BACKWARD) {
    _Py_CODEUNIT *this_instr = frame->instr_ptr = next_instr;
    (void)this_instr;
    next_instr += 2;
    INSTRUCTION_STATS(JUMP_BACKWARD);
    /* Skip 1 cache entry */
    CHECK_EVAL_BREAKER();

    int offset = INSTR_OFFSET()-1;

  5. self-assigned this
    on Feb 19, 2025
  6. markshannon commented on May 6, 2025

    @markshannon
    Member

    This is fixed on main, but still present in 3.13.

  7. PawelAndruszkiewicz commented on Mar 31, 2026

    @PawelAndruszkiewicz

    With Python 3.14.3, loop_1(a) works, but loop_2(a) doesn't.

    The following:

    x = 0
    try:
        while x >= 0:
            if x == 0:
                x = 1
                print('ready')
    except:
        print('in except block')
    finally:
        print("in finally block")
    

    doesn't work on either 3.13.12 or 3.14.3.

    Tested on Fedora43.

  8. t4lesss commented on Oct 15, 2026

    @t4lesss

    Still reproducible on 3.14.7 (Linux), and not only with while: same shape as loop_2 above, the loop body ends in an if.

    import os, signal, sys, threading
    
    def scan(items):
        try:
            for x in items:
                if x < 0:
                    raise ValueError(x)
        except KeyboardInterrupt:
            return "handled"
        return "done"
    
    threading.Timer(0.5, os.kill, (os.getpid(), signal.SIGINT)).start()
    print(sys.version.split()[0], scan(range(10**9)))

    On 3.14.7 this dies with a KeyboardInterrupt traceback pointing at if x < 0:, every run (5/5 here). Add any statement after the if (even a pass) and it prints handled.

    dis shows why: the back edge of the untaken branch gets its own block, outside the try's exception table ranges:

            L3:     POP_JUMP_IF_TRUE         3 (to L4)
                    NOT_TAKEN
                    JUMP_BACKWARD           12 (to L2)
       ...
    ExceptionTable:
      L1 to L3 -> L7 [0]
      L4 to L6 -> L7 [0]
    

    So when the eval breaker raises at that JUMP_BACKWARD, the lookup finds no handler and the exception leaves the frame. finally blocks are skipped the same way. Injecting with PyThreadState_SetAsyncExc from another thread, I got 300/300 escapes for this shape and none for the control.

    — Tales

  9. added 2 commits that reference this issue on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

3.13only security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions