Short description
Page::isChanged() recurses without end (→ Allowed memory size exhausted) when Page reference fields form a cycle and both directions are loaded as objects.
The no-$what sweep in Page::isChanged() iterates every Wire value in $this->data and calls ->isChanged() on it, so changes on owned value-objects (Pageimages, repeater/table rows) bubble up. That assumes the graph is a tree — but a Page reference is a shared object that can point back. When both directions are loaded, A::isChanged() → B::isChanged() → A::isChanged() → … forever.
Steps to reproduce
Two pages that reference each other via a Page field (pageA.ref = pageB, pageB.ref = pageA), both directions loaded:
$a = $pages->get($idA);
$b = $pages->get($idB);
$a->refToB; // load B into A's data
$b->refToA; // load A into B's data
$a->isChanged(); // never returns — OOM
In practice it surfaces on $pages->save() of such a page once both sides have been loaded in the same request (e.g. code that walks a bidirectional relationship graph and saves the members). The stack trace is an unbounded run of Page::isChanged() frames at the foreach($data as $value) line.
Expected behavior
isChanged() returns a bool.
Actual behavior
Infinite recursion → Fatal error: Allowed memory size of N bytes exhausted in wire/core/Page/Page.php.
Note it only manifests when every page in the cycle is otherwise clean — if any had a tracked change, parent::isChanged() short-circuits to true at the top of the method and the recursion terminates. So the failing case is the all-clean cycle, which crashes instead of returning false.
Setup / environment
- ProcessWire 3.0.270 (code is long-standing; also present on current
dev)
- PHP 7.1+
- Any site with two pages that reference each other via Page fields
Fix
PR: processwire/processwire#337 — adds a re-entrancy guard (a method-local static keyed by spl_object_hash()) so a page already on the current isChanged() call stack returns false (a reference cycle contributes no change signal). No behavior change for any non-cyclic case; verified against a 3.0.270 site with a real cycle (unpatched: never returns; patched: returns false in ~0.2 s).
Short description
Page::isChanged()recurses without end (→Allowed memory size exhausted) when Page reference fields form a cycle and both directions are loaded as objects.The no-
$whatsweep inPage::isChanged()iterates everyWirevalue in$this->dataand calls->isChanged()on it, so changes on owned value-objects (Pageimages, repeater/table rows) bubble up. That assumes the graph is a tree — but a Page reference is a shared object that can point back. When both directions are loaded,A::isChanged()→B::isChanged()→A::isChanged()→ … forever.Steps to reproduce
Two pages that reference each other via a Page field (
pageA.ref = pageB,pageB.ref = pageA), both directions loaded:In practice it surfaces on
$pages->save()of such a page once both sides have been loaded in the same request (e.g. code that walks a bidirectional relationship graph and saves the members). The stack trace is an unbounded run ofPage::isChanged()frames at theforeach($data as $value)line.Expected behavior
isChanged()returns a bool.Actual behavior
Infinite recursion →
Fatal error: Allowed memory size of N bytes exhausted in wire/core/Page/Page.php.Note it only manifests when every page in the cycle is otherwise clean — if any had a tracked change,
parent::isChanged()short-circuits totrueat the top of the method and the recursion terminates. So the failing case is the all-clean cycle, which crashes instead of returningfalse.Setup / environment
dev)Fix
PR: processwire/processwire#337 — adds a re-entrancy guard (a method-local
statickeyed byspl_object_hash()) so a page already on the currentisChanged()call stack returnsfalse(a reference cycle contributes no change signal). No behavior change for any non-cyclic case; verified against a 3.0.270 site with a real cycle (unpatched: never returns; patched: returnsfalsein ~0.2 s).