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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions