Skip to content

[DeepClone] Fix round-tripping objects whose __serialize() nests another object#629

Merged
nicolas-grekas merged 1 commit into
1.xfrom
deepclone-nested-serialize-objects
Jun 8, 2026
Merged

[DeepClone] Fix round-tripping objects whose __serialize() nests another object#629
nicolas-grekas merged 1 commit into
1.xfrom
deepclone-nested-serialize-objects

Conversation

@nicolas-grekas

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

Builds on #628 (which adds the states-loop guard this relies on).

A needsFullUnserialize object (a final internal class whose __unserialize() rejects an empty payload, so it has no empty-shell prototype) whose __serialize() nests another object carries an object-ref mask on its reconstruction state. Random\Randomizer, which wraps a Random\Engine\*, is the common case.

reconstruct() defers these to the states loop, but the eager-finalize pass that turns deferred objects into real instances (so the properties loop can resolve references to them) skipped any state with an object-ref mask. The placeholder stayed null, and as soon as another object referenced it the properties loop threw deepclone_from_array(): ... unknown object id. The extension does not have this problem: it instantiates every object shell up front via object_init_ex() and calls __unserialize() later.

Reproducer (works with the extension, threw with the polyfill):

$g = (object) ['r' => new \Random\Randomizer(new \Random\Engine\Mt19937(1234))];
deepclone_from_array(deepclone_to_array($g));

These objects are now finalized to a fixpoint after the refs pass, once the objects their mask references exist (a remaining cycle, which pure PHP cannot reconstruct, is still reported by the existing loops). Random\Randomizer now round-trips as an object property, top-level, in arrays and nested.

Known limitation: a shared object nested inside such a state is reconstructed as an independent copy, because pure PHP cannot inject a live shared object into an internal final class's __unserialize(). The extension preserves identity in that case. This only affects an object shared between a needsFullUnserialize object's serialized state and the rest of the graph.

…her object

A needsFullUnserialize object (a final internal class with __unserialize
that rejects an empty payload, e.g. Random\Randomizer) whose __serialize()
nests another object carries an object-ref mask on its state. Such an
object cannot be built as an early empty shell, and the eager-finalize
pass skipped masked states, leaving a null placeholder; when another
object referenced it, the properties loop threw "unknown object id"
before the states loop could reconstruct it.

These objects are now finalized to a fixpoint after the refs pass, once
the objects their mask references exist. Round-tripping Random\Randomizer
as an object property (or deeper) now works, matching the extension.

A shared object nested inside such a state is still reconstructed as an
independent copy (pure PHP cannot inject a live shared object into an
internal final class's __unserialize); the extension preserves identity
there.
@nicolas-grekas nicolas-grekas force-pushed the deepclone-nested-serialize-objects branch from b3cced5 to daa74de Compare June 8, 2026 20:10
@nicolas-grekas nicolas-grekas merged commit 2c26fff into 1.x Jun 8, 2026
27 of 40 checks passed
@nicolas-grekas nicolas-grekas deleted the deepclone-nested-serialize-objects branch June 8, 2026 20:24
nicolas-grekas added a commit that referenced this pull request Jun 10, 2026
… instances on PHP 8.2 (nicolas-grekas)

This PR was merged into the 1.x branch.

Discussion
----------

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

| 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:

```php
$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

Commits
-------

ae1778e [DeepClone] Fix round-tripping short-lived Random\Randomizer instances on PHP 8.2
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