The core difference is about who holds responsibility for the reference during a function call:
1. Python-to-Python Calls (Reference Move)
When Python code calls a Python function through the bytecode interpreter:
- The caller's evaluation stack transfers ownership of the argument references to the callee
- The caller's stack slots are cleared immediately after the arguments are passed
- No additional
Py_INCREFis needed because the reference is moved, not copied - Once the call begins, the caller no longer has those references on its stack
2. C-to-Python Calls (Reference Duplicate/Hold)
When C code calls a Python function (including coroutine.send() and coroutine.throw()):
- The C caller retains a strong reference to the arguments
- The arguments are incremented (
Py_INCREF) before being passed to the Python callee - The C caller must keep these references until the call completes and returns
- This is necessary because C code operates outside the Python interpreter's normal reference transfer mechanism
Looking at the CPython source code in Objects/genobject.c, both send() and throw() explicitly increment the reference count of their arguments:
// In gen_throw:
Py_INCREF(typ);
Py_XINCREF(val);
Py_XINCREF(tb);These methods must hold onto their arguments until they return because:
-
They're C API entry points: When you call
coro.send(value)orcoro.throw(exc)from Python, you're crossing from Python bytecode into C extension code -
Resume semantics: The coroutine/generator resumes execution and may run for an arbitrary amount of time before yielding back. During this time:
- The
send()orthrow()method frame on the C stack needs to maintain valid references - If the Python code inside the coroutine were the only holder of these references, they could be prematurely collected
- The C function needs to handle error cases and cleanup, requiring stable references
- The
-
Error handling: As shown in the code, there's a
failed_throw:label that decrements the references if the operation fails before completing. The C code needs to maintain these references for proper cleanup.
This is why your test case behaves differently:
def other(a):
a = weakref.ref(a, lambda _: v.append(True))
while not v:
gc.collect() # Hangs in old PyPy
def main():
other(A()) # Regular Python-to-Python call-
CPython 3.11+ and your PyPy fix: The reference to
A()is moved ontoother's stack, not copied. Whenotherexecutesa = weakref.ref(a, ...), the last strong reference disappears and the weakref callback fires immediately. -
Old PyPy: The reference to
A()stayed on the caller's stack throughout the call, creating an extra reference that prevented immediate garbage collection.
If you tried to do the same thing with coroutine.send():
async def coro():
obj = yield
obj = weakref.ref(obj, callback)
# obj won't be collected here even in CPython 3.11
# because send() holds a C-level reference
c = coro()
c.send(None) # prime it
c.send(A()) # send() must hold this reference until it returnsThe reference to A() persists in send()'s C stack frame until the method returns, regardless of Python 3.11's optimizations. This is an inherent difference between the C API boundary and pure Python-to-Python calls.
Now let me look at issue #47 that this PR is trying to fix:Now let me look at the test file changes to understand the practical demonstration:Let me search for the specific test changes:Based on the information from the PRs and issues, I can now explain the relevance:
Why the PyPy Frame Stack Reference Fix is Relevant to
outcomePR #49The connection between your PyPy PR #5087 and the
outcomePR #49 is about reference lifetime management in async/concurrent contexts, particularly involvingcoroutine.send()andcoroutine.throw().The Core Problem in
outcomeThe
outcomelibrary is used heavily in Trio (an async framework) to pass results between coroutines. Here's the typical pattern:The issue is that even after
unwrap()is called, theOutcomeobject keeps holding references to:Error: The exception object (which includes the full traceback and all local variables)Value: The value object (which might be large, like megabytes of data)Why This Matters Specifically for Coroutines
This becomes particularly problematic with
coroutine.send()andcoroutine.throw(), as mentioned in issue #47:coroutine.send(value)scenario:Inside the coroutine:
coroutine.throw(exc)scenario:Inside the coroutine:
How Your PyPy Fix Connects
Your PyPy fix improves reference management for Python-to-Python calls by moving references instead of duplicating them. However,
coroutine.send()andcoroutine.throw()are C-to-Python calls that must hold duplicate references.This means:
When Trio calls
coro.send(outcome_obj):send()holds a reference tooutcome_objthroughout the entire execution untilsend()returnsvalue = outcome.unwrap()and stops usingoutcome, the C stack frame keeps it aliveThe practical impact:
Outcomeobjects after unwrappingOutcomeobject persists until the coroutine yields control back throughsend()orthrow()The Solution in PR #49
The
unwrap_and_destroy()method explicitly clears the internal reference:This way, even though the
Outcomeobject itself is kept alive bysend()/throw()'s C stack frame, the actual data (the large value or the traceback) is released immediately.Summary
Your PyPy fix addresses Python-to-Python call reference management, but
coroutine.send()andcoroutine.throw()are C-to-Python calls that inherently hold references longer. TheoutcomePR #49 works around this limitation by providing an explicit API to clear internal references, ensuring that even when the container object is kept alive by the C stack, the expensive payload is freed promptly. This is particularly critical in async frameworks where coroutines may be suspended for extended periods while holding ontoOutcomeobjects.