← Alle Beiträge

Shopware unter PHP 8.3: „Maximum call stack size … reached. Infinite recursion?“ verstehen und beheben

Shopware unter PHP 8.3: „Maximum call stack size … reached. Infinite recursion?“ verstehen und beheben

In einem Kundenshop auf PHP 8.3 stand diese Meldung über 700 Mal im Log, ausschließlich im Log-Kanal eines Newsletter-Plugins, das Kunden- und Produktdaten an einen externen Marketingdienst synchronisiert. Kein Stacktrace, keine Datei, keine Zeilennummer. Nur dieser eine Satz, und am Ende ein Fragezeichen.

Die Fehlermeldung

Maximum call stack size of 8339456 bytes (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion?

Geworfen wird ein \Error, keine \Exception. Das ist wichtig, wenn du später danach suchst: Ein catch (\Exception $e) fängt ihn nicht, ein catch (\Throwable $e) schon.

Was dahintersteckt

Den Check gibt es seit PHP 8.3. Vorher ist ein PHP-Prozess, der seinen nativen Stack aufgebraucht hat, einfach abgestürzt: Segfault, im PHP-FPM-Log ein Worker, der mit Signal 11 beendet wurde, und sonst nichts. Das UPGRADING-Dokument von PHP 8.3 beschreibt den neuen Mechanismus so: Es wird ein Error geworfen, sobald der Call-Stack mehr als zend.max_allowed_stack_size - zend.reserved_stack_size Bytes belegt, um Segfaults durch Stack-Overflow zu verhindern und das Debuggen einfacher zu machen.

Heißt im Umkehrschluss: Wer diesen Fehler nach einem PHP-Update zum ersten Mal sieht, hatte das Problem sehr wahrscheinlich schon vorher. Es hat sich nur als stiller Absturz versteckt.

Die Zahl 8339456 ist kein Zufall. Beide Direktiven stehen per Default auf 0. Bei zend.max_allowed_stack_size heißt das: PHP ermittelt die Stackgröße, die das Betriebssystem dem Prozess erlaubt. Auf Linux sind das typischerweise 8 MiB (ulimit -s = 8192). zend.reserved_stack_size = 0 bedeutet einen automatisch gewählten Puffer, im PHP-Quelltext ZEND_ALLOCA_MAX_SIZE + 16*1024, also 32 KiB + 16 KiB. Rechnung:

8.388.608 Bytes (8 MiB)  −  49.152 Bytes (48 KiB)  =  8.339.456 Bytes

Siehst du eine andere Zahl im Log, läuft dein Prozess mit einem anderen Stack-Limit. Ein Report im php-src-Tracker zeigt zum Beispiel 8306688 Bytes.

Jetzt der Teil, der beim Debuggen am meisten hilft. Das PHP-Handbuch sagt ausdrücklich: Rekursion in reinem Userland-Code verbraucht keinen nativen Stack. Interne Funktionen und magische Methoden tun es. Eine Funktion, die sich selbst endlos aufruft, endet deshalb in „Allowed memory size exhausted“ und nicht in diesem Fehler. Damit der Stack voll läuft, muss in jeder Runde der Schleife PHP-interner C-Code dazwischenhängen. Das UPGRADING-Dokument nennt __toString, __clone, __sleep und __destruct. Dazu kommen Callbacks, die aus internen Funktionen heraus aufgerufen werden, etwa jsonSerialize() aus json_encode() oder eine Closure in array_map() und usort().

Muster, die dazu passen:

  • Ein Objektgraph mit Zirkelbezug (Entity A kennt B, B kennt A), der mit serialize() oder json_encode() in eine Queue-Message oder einen API-Request gepackt wird
  • Ein jsonSerialize() oder __toString(), das indirekt wieder sich selbst auslöst
  • Ein Subscriber auf ein Written-Event, der dieselbe Entity erneut schreibt und damit das Event wieder auslöst, wenn auf dem Weg eine der oben genannten Funktionen beteiligt ist

Den letzten Punkt solltest du nicht blind annehmen. Er passt zum Muster „Sync-Plugin hört auf Kundenänderungen“, aber ohne Trace ist das eine Vermutung.

Prüfen, ob es wirklich das ist

Zuerst die Grenzwerte, und zwar für den Prozess, in dem der Fehler auftritt. CLI und PHP-FPM können unterschiedliche Limits haben, weil FPM sein Stack-Limit vom Dienst erbt und nicht von deiner Shell.

# CLI (Worker, Scheduled Tasks, bin/console)
ulimit -s
php -i | grep -E 'zend\.(max_allowed|reserved)_stack_size'

# PHP-FPM: Limit des laufenden Master-Prozesses
grep -i 'stack' /proc/$(pgrep -o php-fpm)/limits

Dann den Ort finden. Such zuerst in den Shopware-Logs, ob irgendwo doch ein Trace mitgeschrieben wurde:

grep -rn "Maximum call stack size" var/log/ | head

Wenn ein Plugin den Error selbst fängt und nur die Nachricht loggt (wie im Fall oben), bekommst du dort nichts. Dann hilft nur, die Stelle zu reproduzieren und den Trace selbst auszugeben. Der Trace eines Stack-Overflows ist riesig, aber er wiederholt sich. Sortiert und gezählt sieht man die Schleife sofort:

# $e->getTraceAsString() in eine Datei geschrieben, dann:
sed -E 's/^#[0-9]+ //' trace.txt | sort | uniq -c | sort -rn | head -20

Die Frames mit den höchsten Zählern sind deine Schleife. Taucht dort eine Plugin-Klasse auf, weißt du, wo du weitermachen musst.

Ein Hinweis zur Einordnung: Läuft der Fehler bei dir im Kanal eines bestimmten Plugins auf, ist das ein starkes Indiz, aber noch kein Beweis. Das Plugin kann auch nur das letzte Glied sein, das einen Zirkelbezug aus einer anderen Erweiterung serialisiert.

Die Lösung

Die eigentliche Lösung ist, die Rekursion zu beenden. Je nach Ursache:

  • Zirkelbezug beim Serialisieren: nur die IDs oder die benötigten Felder in die Message oder den Request packen, nicht die ganze Entity mit allen geladenen Assoziationen. Einen ähnlichen Fall gab es im php-src-Tracker (Issue #20338): Serialisierung eines Objekts mit Rückverweis auf sein Elternobjekt. Das Issue wurde als „Invalid“ geschlossen, der Reporter hat die Serialisierung umgebaut.
  • Event-Schleife: im Subscriber prüfen, ob die Änderung von ihm selbst stammt, oder nur auf die Felder reagieren, die wirklich relevant sind.
  • Drittanbieter-Plugin: Plugin auf die aktuelle Version bringen und beim Hersteller mit dem gezählten Trace von oben melden. Wir haben für den Fall aus unseren Logs keinen bestätigten Fix eines Herstellers gesehen, deshalb hier keine Versionsnummer.

Naheliegend ist, einfach das Limit hochzudrehen. Das ist fast immer der falsche Weg.

zend.max_allowed_stack_size = -1 schaltet den Check ab. Damit bekommst du das Verhalten von PHP 8.2 zurück, also den Segfault. Ein höherer fester Wert hat laut Handbuch denselben Effekt, wenn er über dem liegt, was das Betriebssystem wirklich erlaubt. ini_set() geht übrigens nicht, beide Direktiven sind INI_SYSTEM und gehören in die php.ini oder die FPM-Pool-Konfiguration.

Sinnvoll ist mehr Stack nur, wenn die Rekursion endlich, aber tief ist, also kein Bug, sondern viel Struktur. Dann hebst du das Stack-Limit des Prozesses an (ulimit -s für die Shell, bei systemd LimitSTACK= in der Unit von PHP-FPM bzw. vom Worker) und lässt zend.max_allowed_stack_size auf 0, damit PHP den neuen Wert selbst erkennt. Bei einer echten Endlosschleife verschiebt das den Fehler nur nach hinten.

Wenn das nicht hilft

Tritt der Fehler innerhalb einer Fiber auf, gilt nicht zend.max_allowed_stack_size, sondern fiber.stack_size als Obergrenze. Mehr Prozess-Stack ändert dann nichts.

Und wenn die Zahl im Log deutlich kleiner als knapp 8 MiB ist, lohnt der Blick auf die Limits von Container oder Hoster. Manche Umgebungen setzen den Stack enger, als man erwartet, und dann reicht schon eine legitime, tiefe Verschachtelung.

Diesen Fehler sieht nur, wer in den Log-Kanal des betroffenen Plugins schaut, und dort ging er zwischen ganz normalen Sync-Meldungen unter. ShopSignal zählt Errors und Warnings jedes Shops pro Tag, durchsucht die Logs im Volltext und meldet sich über eigene Alert-Regeln, wenn die Zahl plötzlich steigt. Das PHP-Lebensende deiner Shops hat es dabei auch im Blick, damit das nächste PHP-Update geplant ist und nicht zufällig passiert.