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)]

No activity

Activity on this issue will appear here.

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