[php-src] Issue #23301: Nested "yield from" yields a value twice when the middle generator ends with "yield from []"
[email protected] (kojiromike)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <Tys9bTLrAoR2twqFUsDVDJqtoosRBOyyG5HVWQ25wIM@main.internal.php.net> |
Issue: https://github.com/php/php-src/issues/23301
Author: kojiromike
### Description
Commit 0ccff767634b4c453f2971d256de86cc433c37fe ("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
<?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 0ccff7676 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:
```c
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)