Skip to content

_SEND_ASYNC_GEN_TIER_TWO side exit corrupts the stack when an async generator returns #158804

Description

@KowalskiThomas

Crash report

With the tier 2 optimizer enabled, awaiting agen.asend(...) on an async generator that suspends before yielding can crash the interpreter.

SEND_ASYNC_GEN is replaced by _SEND_ASYNC_GEN_TIER_TWO in tier 2. In tier 1, the PYGEN_RETURN case jumps to END_SEND with (asend, null, retval) on the stack:

cpython/Python/bytecodes.c

Lines 1807 to 1826 in 9d22a53

replaced op(_SEND_ASYNC_GEN, (iter, null_in, v -- asend, null_out, retval)) {
PyObject *iter_o = PyStackRef_AsPyObjectBorrow(iter);
assert(PyAsyncGenASend_CheckExact(iter_o));
PyObject *val = PyStackRef_AsPyObjectBorrow(v);
PyObject *retval_o;
PySendResult what = _PyAsyncGenASend_Send(iter_o, val, &retval_o);
if (what == PYGEN_ERROR) {
ERROR_NO_POP();
}
PyStackRef_CLOSE(v);
asend = iter;
DEAD(iter);
null_out = null_in;
DEAD(null_in);
retval = PyStackRef_FromPyObjectSteal(retval_o);
if (what == PYGEN_RETURN) {
JUMPBY(oparg);
}
}

The tier 2 version assumes the PYGEN_NEXT path was recorded, and side exits with EXIT_IF(what == PYGEN_RETURN):

cpython/Python/bytecodes.c

Lines 1833 to 1850 in 9d22a53

tier2 op(_SEND_ASYNC_GEN_TIER_TWO, (iter, null_in, v -- asend, null_out, retval)) {
PyObject *iter_o = PyStackRef_AsPyObjectBorrow(iter);
assert(PyAsyncGenASend_CheckExact(iter_o));
PyObject *val = PyStackRef_AsPyObjectBorrow(v);
PyObject *retval_o;
PySendResult what = _PyAsyncGenASend_Send(iter_o, val, &retval_o);
if (what == PYGEN_ERROR) {
ERROR_NO_POP();
}
PyStackRef_CLOSE(v);
asend = iter;
DEAD(iter);
null_out = null_in;
DEAD(null_in);
retval = PyStackRef_FromPyObjectSteal(retval_o);
EXIT_IF(what == PYGEN_RETURN);
}

The problem is that this EXIT_IF comes after the send was already performed, v was closed and the outputs were produced.
In the generated code, stack_pointer was already decremented by one when v was closed, so the exit spills the wrong three values (one slot below the operands) and drops retval:

if (what == PYGEN_RETURN) {
UOP_STAT_INC(uopcode, miss);
_tos_cache2 = stack_pointer[-1];
_tos_cache1 = stack_pointer[-2];
_tos_cache0 = stack_pointer[-3];
SET_CURRENT_CACHED_VALUES(3);
stack_pointer += -3;
ASSERT_WITHIN_STACK_BOUNDS(__FILE__, __LINE__);
JUMP_TO_JUMP_TARGET();
}

On top of that, the exit target is the SEND_ASYNC_GEN instruction itself (OPARG_REPLACED only redirects the target for FOR_ITER-like uops), so tier 1 would re-execute the send -- which is wrong even with a correct stack, since the async generator already advanced:

cpython/Python/optimizer.c

Lines 956 to 983 in 9d22a53

case OPARG_REPLACED:
uop = _PyUOp_Replacements[uop];
assert(uop != 0);
uint32_t next_inst = target + 1 + _PyOpcode_Caches[_PyOpcode_Deopt[opcode]];
if (uop == _TIER2_RESUME_CHECK) {
if (this_instr[-1].op.code == LOAD_SPECIAL) {
// Don't check eval breaker immediately after LOAD_SPECIAL
uop = _NOP;
}
else {
target = next_inst;
}
}
else {
int extended_arg = orig_oparg > 255;
uint32_t jump_target = next_inst + orig_oparg + extended_arg;
/* Jump must be to an "END" either END_FOR or END_SEND */
assert((
_Py_GetBaseCodeUnit(old_code, jump_target).op.code == END_FOR &&
_Py_GetBaseCodeUnit(old_code, jump_target+1).op.code == POP_ITER
)
||
_Py_GetBaseCodeUnit(old_code, jump_target).op.code == END_SEND
);
if (is_for_iter_test[uop]) {
target = jump_target + 1;
}
}

This looks like the same class of issue as gh-155823, which was fixed for _SEND_VIRTUAL_TIER_TWO only (GH-155844). (SEND_ASYNC_GEN was introduced in GH-148963)

I think the fix would be to exit to END_SEND with the outputs on the stack (similar to what FOR_ITER does when exhausted), but I tried AT_END_EXIT_IF + setting the exit target to the END_SEND and the cases generator still seems to get the stack wrong for this op (it reads the cache before storing the outputs), so this may need either a cases generator fix or splitting the check into a separate uop.

Reproducer

The coroutine chain needs to be driven from Python frames (hence types.coroutine + for) so that the tracer follows it and records SEND_ASYNC_GEN on its PYGEN_NEXT path. The async generator needs to suspend twice before yielding for the trace to be recorded and then hit the PYGEN_RETURN exit.

import types

@types.coroutine
def suspend():
    yield

async def agen():
    await suspend()
    await suspend()
    yield 1

@types.coroutine
def wrapper():
    yield from agen().asend(None)

for _ in range(10000):
    for _ in wrapper():
        pass

On a debug build (./configure --with-pydebug --enable-experimental-jit=interpreter):

Stack underflow (depth = -1) at Python/executor_cases.c.h:9479
zsh: abort      ./python.exe repro.py

On a release build (./configure --enable-experimental-jit=interpreter):

Fatal Python error: Segmentation fault

Current thread 0x00000001f048a180 (most recent call first):
  File "repro.py", line 14 in wrapper
  File "repro.py", line 17 in <module>

With PYTHON_JIT=0, the script runs fine.

CPython versions tested on:

CPython main branch

Operating systems tested on:

macOS

Output from running 'python -VV' on the command line:

Python 3.16.0a0 (remotes/upstream/HEAD:9d22a5334bd, Oct 4 2026, 15:17:47) [Clang 21.0.0 (clang-2100.1.1.101)]

Activity

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

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-JITtype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions