Skip to content

Nested "yield from" yields a value twice when the middle generator ends with "yield from []" #23301

Description

@kojiromike

Description

Commit 0ccff76 ("Fix GH-15375: nested yield from skips items after valid()/next()", merged to PHP-8.4/PHP-8.5/master on 2026-08-12) introduces a regression: in a three-level yield from delegation chain, a value is yielded twice when the middle generator ends with yield from [] (delegation to a non-generator empty iterable).

This is not a synthetic pattern. Twig compiles every template's doDisplay() to end with yield from [];, and rendering any template containing a block/include goes through exactly this three-level chain, so every affected template duplicates all output emitted after the block.

The following code:

<?php
function inner(): iterable
{
    yield "B";
}

function mid(): iterable
{
    yield "A";
    yield from inner();
    yield "C";
    yield from [];
}

function top(): iterable
{
    yield from mid();
}

$out = '';
foreach (top() as $chunk) {
    $out .= $chunk;
}
var_dump($out);

Resulted in this output:

string(4) "ABCC"

But I expected this output instead:

string(3) "ABC"

The duplicated value is whatever the middle generator yielded last before the trailing yield from []; with two tail yields (yield "C"; yield "D"; yield from [];) the result is ABCDD.

Narrowing:

Shape Result
3 levels, middle ends yield from [] BROKEN
3 levels, no trailing empty delegate OK
3 levels, middle ends yield from <empty generator> OK
3 levels, inner is an array literal rather than a generator OK
2 levels (no outer delegation) OK

So it requires: an outer generator delegating to a middle generator, the middle delegating to an inner generator, and the middle then delegating to an empty non-generator iterable.

Bisected by reverting only the Zend/zend_generators.c hunks of 0ccff76 on top of master 5332ab0c291361abe3d12db21dd55e5dc7baa829 — that alone restores correct output, and also restores a full Twig render-test suite (22 tests) that fails 15 cases without it.

The relevant change is in zend_generator_resume(), where the DO_INIT re-advance guard now reads the flag from the delegating generator:

	if (UNEXPECTED(generator->execute_data && generator->execute_data->opline->opcode == ZEND_YIELD_FROM)) {
		delegator = generator;
		generator = zend_generator_get_current(orig_generator);
		goto try_again;
	}

When the middle generator sits on a ZEND_YIELD_FROM opline for an empty array, delegator becomes the middle generator, whose DO_INIT is clear, so the guard that previously stopped the re-advance (via orig_generator) no longer fires and the already-yielded value is presented again.

PHP Version

PHP 8.6.0-dev (built from master 5332ab0c291361abe3d12db21dd55e5dc7baa829)

Also present on the PHP-8.4 and PHP-8.5 branches, since the fix was merged to all three — i.e. this is heading for 8.4.26 and 8.5.11.

Operating System

Debian bookworm (also reproduced on GitHub Actions ubuntu-24.04 with the shivammathur/setup-php 8.6 nightly)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions