Skip to content

Fix GH-10134: report internal call arguments to GC - #23362

Open
xhon-pelushi wants to merge 1 commit into
php:masterfrom
xhon-pelushi:fix/gh-10134-gc-internal-call-args
Open

Fix GH-10134: report internal call arguments to GC#23362
xhon-pelushi wants to merge 1 commit into
php:masterfrom
xhon-pelushi:fix/gh-10134-gc-internal-call-args

Conversation

@xhon-pelushi

Copy link
Copy Markdown

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:

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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-suspended generators in suspended Fiber do not participate in GC

1 participant