Skip to content

[DeepClone] Fix round-tripping short-lived Random\Randomizer instances on PHP 8.2#632

Merged
nicolas-grekas merged 1 commit into
1.xfrom
deepclone-randomizer-indirects
Jun 10, 2026
Merged

[DeepClone] Fix round-tripping short-lived Random\Randomizer instances on PHP 8.2#632
nicolas-grekas merged 1 commit into
1.xfrom
deepclone-randomizer-indirects

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Jun 10, 2026

Copy link
Copy Markdown
Member
Q A
Branch? 1.x
Bug fix? yes
New feature? no
Deprecations? no
Issues -
License MIT

This is what has been segfaulting the PHP 8.2 CI jobs since the Randomizer round-trip tests landed in #629 (and what #630 inherits).

Minimal reproducer, no polyfill involved, on PHP 8.2.31:

$state = (new Random\Randomizer(new Random\Engine\Mt19937(7)))->__serialize();
serialize($state); // SIGSEGV

Before PHP 8.3, Random\Randomizer::__serialize() returns the object's raw property table with only the table itself addref'd. Its engine entry is an IS_INDIRECT slot pointing into the object, so once the Randomizer is released the retained state dangles. php-src fixed this for 8.3 by duplicating the table (php/php-src@c5fa7696e64), but 8.2 was already security-only and the fix was never backported; 8.2.31 still crashes.

deepclone_to_array() retains exactly such a state in its payload, so round-tripping a Randomizer that does not outlive the payload segfaulted on 8.2. There is no copy-based userland fix: plain array reads copy IS_INDIRECT slots verbatim instead of resolving them (gettype() reports unknown type), which is also how the contamination is detected. The serializer is the one userland-reachable code path that resolves these slots, so when a __serialize() result is contaminated (checked while the source object is still alive, and only on PHP < 8.3), it is passed through a serialize()/unserialize() round trip to materialize real values.

No new tests: the #629 Randomizer tests are the regression tests; they segfault on PHP 8.2 before this change and pass after (suite verified green on 8.1, 8.2, 8.3 and 8.5). The nested engine becomes a fresh copy rather than a shared instance on the affected versions, matching the already-documented identity behavior of nested __serialize() states.

Companion extension fix (the with-extensions 8.2 CI legs run the tagged ext via PIE and stay red until it ships in a release): symfony/php-ext-deepclone#23

Perf note: Random\Randomizer is special-cased instead of scanned for, after auditing PHP 8.2 core for the pattern: of the internal classes whose __serialize() retains the un-duplicated property table (Randomizer, HashContext, the Random engines), only Randomizer declares a property, and only declared properties create IS_INDIRECT slots (GMP uses the legacy serialize handler, consumed while the object is alive; the date classes already duplicate). Measured on 8.2: no overhead for any other class; Randomizer itself pays one serialize round trip (~190 us for a Mt19937 state), the price of not crashing. On PHP 8.3+ the version guard makes the branch dead code. The trade-off versus scanning every internal state: a hypothetical third-party extension class combining the same pattern with declared properties would keep crashing, as it does today.

Companion extension fix (the with-extensions 8.2 CI legs run the tagged ext via PIE, fixed in v0.7.2): symfony/php-ext-deepclone#23

…s on PHP 8.2

Before PHP 8.3, Random\Randomizer::__serialize() returns the object's raw
property table with only the table itself addref'd. Its "engine" entry is
an IS_INDIRECT slot pointing into the object, so when the Randomizer is
released before the returned state, the slot dangles: array reads copy it
verbatim (gettype() reports "unknown type") and serializing the retained
state crashes the engine. php-src fixed this for 8.3 by duplicating the
table (c5fa7696e64), but 8.2 was already security-only and still ships
the dangling behavior.

deepclone_to_array() retains exactly such a state in its payload, so
round-tripping a Randomizer that does not outlive the payload segfaulted
on PHP 8.2, which is how the test suite has been crashing the 8.2 CI jobs.
Pass the state through a serialize() round trip while the source object is
still alive: the serializer is the one userland-reachable code path that
resolves IS_INDIRECT slots.

Randomizer is the only affected class: of the internal classes whose
__serialize() retains the un-duplicated property table (Randomizer,
HashContext, the Random engines), it is the only one declaring a property,
and only declared properties create IS_INDIRECT slots. Everything else
therefore pays nothing, and the version guard makes the branch dead code
on PHP 8.3 and up.
@nicolas-grekas nicolas-grekas force-pushed the deepclone-randomizer-indirects branch from 91a9867 to ae1778e Compare June 10, 2026 19:52
@nicolas-grekas nicolas-grekas merged commit e85dd72 into 1.x Jun 10, 2026
40 checks passed
@nicolas-grekas nicolas-grekas deleted the deepclone-randomizer-indirects branch June 10, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant