Skip to content

with lock: can skip __exit__ when a signal handler raises right after __enter__ #148874

Open

Description

@colesbury

Bug report

A with X: statement can return without calling X.__exit__ when a Python signal handler raises between __enter__ returning and the body starting.

This leaks whatever resource __enter__ acquired (e.g. a threading.Lock stays locked forever).

Cause

In 3.14, the following changed how with X: compiles:

Before 3.14:

LOAD X
BEFORE_WITH        # single op: calls enter, sets up exit on stack
POP_TOP            # <- exception table starts here
... body ...

BEFORE_WITH has no periodic/eval-breaker check, so there was no window for a signal handler to fire between __enter__ returning and the with-statement's exception handler being established.

From 3.14 onward:

LOAD X
COPY 1
LOAD_SPECIAL exit
SWAP 2 / SWAP 3
LOAD_SPECIAL enter
CALL 0             # ends with _CHECK_PERIODIC_AT_END
POP_TOP            # <- exception table starts here
... body ...

CALL includes _CHECK_PERIODIC_AT_END, which runs the Python signal handler. If that handler raises, JUMP_TO_LABEL(error) fires with frame->instr_ptr still pointing at CALL (set before the micro-ops run).

Reproducer (via Claude Code)

test_with_signal_leak.py
import ctypes, ctypes.util, signal, sys, threading

# signal.pthread_kill / os.kill both call PyErr_CheckSignals after the syscall,
# which perturbs timing enough to suppress the race. Use libc.pthread_kill
# directly.
_pthread_kill = ctypes.CDLL(ctypes.util.find_library("c")).pthread_kill
_pthread_kill.argtypes = [ctypes.c_ulong, ctypes.c_int]
_pthread_kill.restype = ctypes.c_int

_MAIN_TID = threading.get_ident()

def _handler(signum, frame):
    raise RuntimeError("signal")

def _send():
    _pthread_kill(_MAIN_TID, signal.SIGUSR1)

def run_trial(lock, iterations=200):
    t = threading.Thread(target=_send)
    t.start()
    try:
        for _ in range(iterations):
            with lock:
                pass
    except BaseException:
        pass
    try:
        t.join()
    except BaseException:
        pass
    if lock.locked():
        lock.release()
        return True
    return False

signal.signal(signal.SIGUSR1, _handler)
lock = threading.Lock()
leaked = 0
for _ in range(2000):
    try:
        if run_trial(lock):
            leaked += 1
    except BaseException:
        if lock.locked():
            lock.release()
print(f"leaked={leaked}/2000")

cc @markshannon

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    3.14bugs and security fixes
    3.15bugs and security fixes
    on Apr 22, 2026
  2. markshannon commented on Apr 23, 2026

    @markshannon
    Member

    I see a three possible fixes for this:

    1. Revert to the old START_WITH instruction
    2. Add a new CALL_NO_CHECK instruction to replace the CALL in this case
    3. Add a flag to the CALL instruction to tell it whether to do the periodic check

    I don't like 1 as we would loose all interpreter specialization and JIT optimizations that we get for free by using LOAD_SPECIAL and CALL

    2 is OK, but adds another instruction but we can't afford to specialize it, and optimizing it in the JIT would be awkward.

    3 is clearly the best for this case, but potentially slows down all other calls which outnumber the calls to __enter__ considerably. Slowing down all other calls would be bad, but I think we can avoid the slowdown:

    • Moving the check inside the the eval_breaker check, so we have no extra branch on the fast path.
    • There is a shift to remove the flag, but that's a single ALU operation with no branching or memory access.

    Overall, I think 3 is the best, and will allow us to skip the eval_breaker check for other calls where we don't want to allow interrupts.

  3. colesbury commented on Apr 23, 2026

    @colesbury
    ContributorAuthor

    I think it'd be good to have an option that we can backport to 3.14 without changing the bytecode:

    • Would it be possible to do the periodic check before the call (instead of after)?
    • Maybe we could add logic to _Py_HandlePending to handle this case in 3.14?
  4. markshannon commented on Apr 23, 2026

    @markshannon
    Member

    For 3.14 we could check whether the previous instruction was LOAD_SPECIAL. There is no harm in skipping the eval_breaker on the very rare false positive

  5. added 2 commits that reference this issue on May 28, 2026
  6. added a commit that references this issue on Jul 1, 2026
  7. added a commit that references this issue on Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.14bugs and security fixes3.15bugs and 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