Skip to content

Page::isChanged() infinite recursion (memory exhausted) on Page-reference cycles #2301

Description

@adrianbj

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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions