[GIT-PULLS] [php-src] PR #23362: Fix GH-10134: report internal call arguments to GC
[email protected] (xhon-pelushi)
| Newsgroups | php.git-pulls |
|---|---|
| Message-ID | <5wLgywreTG9tFjhUhET2yWtUHDTrNaBl0E5BdidfMvQ@main.internal.php.net> |
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.