A fiber can be suspended below an internal function, leaving that frame on
its 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() returned early for internal frames without
reporting those arguments, so the cycle collector never saw them. Any cycle
running through such an argument was uncollectable and only broken at request
shutdown.
The reported case is Fiber::suspend() reached through Generator::send($fiber),
where $fiber sits in the argument slot of the internal Generator::send frame,
but the defect is not specific to generators: the same happens for any
internal call a fiber is suspended inside, e.g. array_map().
Entered calls are removed from the caller's EX(call) chain, so these arguments
are not also reported by zend_unfinished_calls_gc() and no reference is
counted twice.
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:
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:
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:
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
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.