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:
|
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):
|
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:
|
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)]
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_GENis replaced by_SEND_ASYNC_GEN_TIER_TWOin tier 2. In tier 1, thePYGEN_RETURNcase jumps toEND_SENDwith(asend, null, retval)on the stack:cpython/Python/bytecodes.c
Lines 1807 to 1826 in 9d22a53
The tier 2 version assumes the
PYGEN_NEXTpath was recorded, and side exits withEXIT_IF(what == PYGEN_RETURN):cpython/Python/bytecodes.c
Lines 1833 to 1850 in 9d22a53
The problem is that this
EXIT_IFcomes after the send was already performed,vwas closed and the outputs were produced.In the generated code,
stack_pointerwas already decremented by one whenvwas closed, so the exit spills the wrong three values (one slot below the operands) and dropsretval:cpython/Python/executor_cases.c.h
Lines 9472 to 9481 in 9d22a53
On top of that, the exit target is the
SEND_ASYNC_GENinstruction itself (OPARG_REPLACEDonly redirects the target forFOR_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
This looks like the same class of issue as gh-155823, which was fixed for
_SEND_VIRTUAL_TIER_TWOonly (GH-155844). (SEND_ASYNC_GENwas introduced in GH-148963)I think the fix would be to exit to
END_SENDwith the outputs on the stack (similar to whatFOR_ITERdoes when exhausted), but I triedAT_END_EXIT_IF+ setting the exit target to theEND_SENDand 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 recordsSEND_ASYNC_GENon itsPYGEN_NEXTpath. The async generator needs to suspend twice before yielding for the trace to be recorded and then hit thePYGEN_RETURNexit.On a debug build (
./configure --with-pydebug --enable-experimental-jit=interpreter):On a release build (
./configure --enable-experimental-jit=interpreter):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)]