[php-src] PR #23362: Fix GH-10134: report internal call arguments to GC
| From: | xhon-pelushi | Date: | Wed, 19 Aug 2026 03:01:56 +0000 |
| Subject: | [php-src] PR #23362: Fix GH-10134: report internal call arguments to GC | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-37990@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23362
Author: xhon-pelushi
Fixes GH-10134.
## The bug
A fiber can be suspended *below* an internal function. That leaves the internal frame parked on the
fiber's stack with the arguments that were pushed for it still live — the frame owns them and
only releases them when it returns.
zend_unfinished_execution_gc_ex() returns early for internal frames without reporting
those arguments:
```c
if (!ZEND_USER_CODE(EX(func)->common.type)) {
ZEND_ASSERT(...);
return NULL; /* arguments never reported */
}
```
So the cycle collector never sees them, and any cycle running through such an argument is
uncollectable — it is only broken at request shutdown.
## Not specific to generators
GH-10134 reports this as a generator problem, because the reporter hit it through
Fiber::suspend() inside a generator resumed by Generator::send($fiber) —
$fiber lands in the argument slot of the internal Generator::send frame.
But nothing about it is generator-specific. With no generator anywhere, a fiber suspended inside
array_map() whose array argument holds the fiber behaves identically (second test added
here).
## How it was diagnosed
zend_fiber_object_gc() is not at fault — it correctly walks the suspended
fiber's frames, finds the generator frame, and calls zend_generator_frame_gc() on
it. Instrumenting the collector's root processing showed the actual failure:
```
[root-before] Fiber rc=2 PURPLE
reported obj Fiber rc=2 <- reported once
[root-after-mark] Fiber rc=1 GREY
[root-after-mark] Generator rc=3 <- restored from 0
```
The Fiber's refcount is 2 but the mark phase discovers only one of those references, so it
lands at 1 instead of 0, gc_scan concludes it is externally referenced, and
gc_scan_black restores every refcount it had decremented. Dumping the frames shows
where the second reference lives:
```
frame ... INTERNAL func=send nargs=1
ARG[0] type=8 Fiber 0x... <- never reported
```
## On double counting
This was the main risk. It does not happen: on ZEND_DO_ICALL /
ZEND_DO_FCALL the VM does EX(call) = call->prev_execute_data, so an
*entered* frame is no longer in its caller's EX(call) chain and is therefore not
also scanned by zend_unfinished_calls_gc(), which only walks calls that are still being
built. Those pending frames are siblings rather than ancestors, so they never show up in the
prev_execute_data walk either.
zend_unfinished_execution_gc_ex() has two callers.
zend_generator_frame_gc() always passes generator->execute_data, which
is user code, so only the fiber path is affected in practice.
## Testing
- Both new tests **fail on unmodified master and pass with this change** — I ran them against a
pristine build to confirm they are real regression guards.
- The issue's verbatim script now prints the expected order (Generator dtor,
Fiber dtor, Shutdown) instead of Shutdown first, and
gc_collect_cycles() returns 1 instead of 0.
- **No premature frees**, which is the thing to worry about when handing GC additional edges: an
object passed as an internal-frame argument that is *also* still referenced from live scope survives
gc_collect_cycles() and remains usable. Checked explicitly.
- Full test suite on this build (--disable-all --enable-debug): **13386 passed, 0
failed, 0 warned** (7 expected-fail, the rest skipped for disabled extensions).
- Zend/tests/fibers and Zend/tests/gc under valgrind memcheck: clean.
Built and tested on Linux x86_64, --disable-all --enable-debug, so the extension-heavy
parts of the suite were skipped; a normal CI run would cover those.