Skip to content

Commit 4a092d4

Browse files
Make lazy hydration pre-8.4-clean, defer closure-bearing state replays, hand out a Closure initializer
Three follow-ups to the lazy-ghost feature: - Guard the ghost-only helpers (eligibility check, slot-index build, const-expr gate) behind PHP_VERSION_ID >= 80400; they were dead code on 8.2/8.3 and -Werror builds rejected them. Also narrow the slot count pass to closure-bearing ids and drop a redundant parameter and a dead branch left over from the broader eligibility design. - Closure-bearing __wakeup/__unserialize nodes become ghosts too: their hook runs at the end of their own initialization instead of in the global children-first phase-9 sequence. Per-entry validation stays in phase 9; the deferred state is recorded before any user code can trigger an initializer, only from entries the eager path would actually call (first one wins), so mid-call touches on malformed payloads cannot run hooks the eager path would not have run. - The initializer stored on ghosts is now a Closure over the context's private hydrate() method (formerly a public __invoke), so ReflectionClass::getLazyInitializer() returns a plain Closure, shared by all ghosts of one call.
1 parent 6eea3d3 commit 4a092d4

9 files changed

Lines changed: 521 additions & 101 deletions

CHANGELOG.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose
13-
payload slots carry a named-closure or const-expr-closure marker as
13+
payload slots or replayed `__unserialize` state carry a named-closure or
14+
const-expr-closure marker as
1415
native lazy ghosts: all object identities (back-references, shared `&`
1516
references, `===`) exist when the call returns, but those nodes' property
1617
hydration, closure resolution included, is deferred until the engine
1718
first touches each of them. Resolving closures (fake-closure creation,
1819
attribute-args re-evaluation) is the measurably expensive part of
1920
hydration, so deferral is restricted to the nodes that carry them; plain
2021
value slots hydrate eagerly as before (copy-on-write makes them cheaper
21-
to hydrate than to ghost), as do internal classes, `stdClass`,
22-
zero-declared-property classes and `__wakeup`/`__unserialize` nodes, all
23-
mixing freely with lazy ones. On PHP 8.2/8.3 everything keeps hydrating
24-
eagerly. Structural validation and `$allowed_classes` enforcement
25-
(including the const-expr-closure gate) remain eager; only value-level
26-
resolution errors (e.g. a stale const-expr closure line, a named-closure
27-
target that no longer exists) surface at first access instead of inside
22+
to hydrate than to ghost), as do internal classes, `stdClass` and
23+
zero-declared-property classes, all mixing freely with lazy ones.
24+
Closure-bearing `__wakeup`/`__unserialize` nodes defer too: their hook
25+
runs at the end of their own initialization instead of in the global
26+
children-first replay sequence, while per-entry validation stays inside
27+
the call. On PHP 8.2/8.3 everything keeps hydrating eagerly. Structural
28+
validation and `$allowed_classes` enforcement (including the
29+
const-expr-closure gate) remain eager; only value-level resolution errors
30+
(e.g. a stale const-expr closure line, a named-closure target that no
31+
longer exists) surface at first access instead of inside
2832
`deepclone_from_array()`, where the engine reverts the ghost and keeps it
2933
retryable. The shared hydration state lives in the new internal-only
30-
`DeepClone\HydrationContext` class, which
31-
`ReflectionClass::getLazyInitializer()` exposes as the initializer;
32-
abandoned half-hydrated graphs are reclaimed by the cycle collector. One
34+
`DeepClone\HydrationContext` class;
35+
`ReflectionClass::getLazyInitializer()` returns a Closure bound to it.
36+
Abandoned half-hydrated graphs are reclaimed by the cycle collector. One
3337
documented deferral residue: type sources for shared `&` references bound
3438
to typed properties are registered per node as it hydrates, so a write
3539
through such a reference is only checked against the already-hydrated

README.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,10 @@ function deepclone_hydrate(object|string $object_or_class, array $vars = [], int
9292
`deepclone_from_array()` creates the object nodes that are expensive to
9393
hydrate as
9494
[native lazy ghosts](https://www.php.net/manual/en/language.oop5.lazy-objects.php):
95-
nodes whose payload slots carry a named-closure or (PHP 8.5)
96-
const-expr-closure marker, since resolving those (fake-closure creation,
97-
attribute-args re-evaluation) is where hydration time actually goes. Every
95+
nodes whose payload slots or replayed `__unserialize` state carry a
96+
named-closure or (PHP 8.5) const-expr-closure marker, since resolving those
97+
(fake-closure creation, attribute-args re-evaluation) is where hydration
98+
time actually goes. Every
9899
object identity exists when the call returns (back-references, shared `&`
99100
references and `===` behave exactly as for eager nodes), but a ghost's
100101
property hydration, closure resolution included, is deferred until the
@@ -109,12 +110,17 @@ $clone = deepclone_from_array($payload);
109110
All other nodes hydrate eagerly: nodes without closure markers (plain value
110111
slots are cheaper to hydrate than to ghost, since copy-on-write makes them
111112
refcount bumps), internal classes (and classes inheriting one, `stdClass`
112-
descendants excepted), `stdClass` itself and other classes without declared
113-
properties, and nodes replaying `__wakeup`/`__unserialize` (their call
114-
order is observable and preserved). A graph without closure markers is
115-
hydrated fully eagerly and carries zero lazy-mode overhead, and on PHP
116-
older than 8.4 (no native lazy objects) everything hydrates eagerly.
117-
Mixing lazy and eager nodes in one graph is the normal mode of operation.
113+
descendants excepted), and `stdClass` itself and other classes without
114+
declared properties. A graph without closure markers is hydrated fully
115+
eagerly and carries zero lazy-mode overhead, and on PHP older than 8.4 (no
116+
native lazy objects) everything hydrates eagerly. Mixing lazy and eager
117+
nodes in one graph is the normal mode of operation.
118+
119+
Closure-bearing nodes that replay `__wakeup`/`__unserialize` are deferred
120+
too: their hook runs at the end of their own initialization instead of in
121+
the global, children-first replay sequence (each entry is still validated
122+
inside the call; only the hook calls move). State-replaying nodes without
123+
closure markers keep their eager, ordered replay.
118124

119125
Semantics of deferred nodes (the usual native lazy-object rules):
120126

@@ -134,9 +140,10 @@ Semantics of deferred nodes (the usual native lazy-object rules):
134140
written value violates a pending node's property type, that node's first
135141
touch throws instead (eager mode rejects such a write at the assignment).
136142
- The payload and every object of the graph stay pinned in memory until the
137-
last ghost initializes or dies; a shared `DeepClone\HydrationContext`
138-
object, also returned by `ReflectionClass::getLazyInitializer()`, holds
139-
them. Abandoned graphs are reclaimed by the cycle collector.
143+
last ghost initializes or dies: `ReflectionClass::getLazyInitializer()`
144+
returns a `Closure` bound to a shared internal
145+
`DeepClone\HydrationContext` object that holds them. Abandoned graphs are
146+
reclaimed by the cycle collector.
140147

141148
Cost model, measured on 20k-node graphs (PHP 8.4, release build): creating
142149
ghosts instead of resolving closures cuts `deepclone_from_array()` time by

0 commit comments

Comments
 (0)