frankenphp: give PHP threads a stack it can compile Twig on #30

Merged
geusebio merged 3 commits from fix/frankenphp-zts-stack-size into main 2026-08-04 12:49:08 +00:00
Owner

Ordinary Twig-heavy pages 500 on the FrankenPHP images with Maximum call stack size of 83936 bytes ... reached during compilation.

Cause

FrankenPHP creates the thread that serves a request with
pthread_create(&thread, NULL, &php_thread, ...) — NULL attributes, so musl
picks the stack size. musl takes that from the main executable's PT_GNU_STACK
header, and /usr/bin/frankenphp ships with it at zero:

$ readelf -lW /usr/bin/frankenphp | grep GNU_STACK
  GNU_STACK  0x000000 0x0 0x0 0x000000 0x000000 RW 0x10

so every PHP thread gets musl's 128KB fallback, where php-fpm's main thread gets
8MB. PHP 8.3+ auto-detects zend.max_allowed_stack_size from the real stack, and
the arithmetic lands exactly on the reported number: 131072 - 47136 = 83936.
Nothing app-specific about it — it is the first page load of a stock Symfony app.

Fix

Rewrite that header field to 8MB at build time. This is the same thing upstream's
-Wl,-z,stack-size=... build flag does; we install frankenphp prebuilt from
pkg.henderkes.com, so a small helper (elf-stack-size) writes the field
afterwards instead. 8MB is both what php-fpm gets and musl's ceiling — asking for
32MB still measures 8341472 usable, so it clamps.

Why not zend.max_allowed_stack_size = -1

It clears the symptom by removing the guard, not by giving PHP a stack. Confirmed
on a built image with -1 in place: a runaway recursion no longer raises a PHP
Error, it takes the worker thread down and the request dies with
curl: (52) Empty reply from server. With the stack sized instead, the guard
stays armed and just reports the larger limit.

Verified in a built image (arm64, PHP 8.5)

before after
1000-deep generated expression (Twig-shaped) HTTP 500, compile error HTTP 200
runaway recursion Error, limit 83936 bytes Error, limit 8341472 bytes
reference matthewbaggett/php:8.5 (php-fpm) limit 8339456 bytes — parity

Deeper than that, both images fail the same clean way php-fpm does
(Parse error: memory exhausted from the parser), so no crash band is opened up.

Test coverage

  • etc/validate.d/frankenphp.sh asserts the header at build time on every PHP
    version — building with the patch step removed now fails the build.
  • New frankenphp-85-stack-healthchecks service asserts both directions at
    runtime: that a deeply nested template compiles and that infinite recursion
    is still caught. It is wired into the test-frankenphp group (CI cannot run
    these images; run ./test test-frankenphp by hand).

./test and ./test test-frankenphp both pass locally.

Closes #25

Ordinary Twig-heavy pages 500 on the FrankenPHP images with `Maximum call stack size of 83936 bytes ... reached during compilation`. ## Cause FrankenPHP creates the thread that serves a request with `pthread_create(&thread, NULL, &php_thread, ...)` — NULL attributes, so musl picks the stack size. musl takes that from the main executable's `PT_GNU_STACK` header, and `/usr/bin/frankenphp` ships with it at zero: ``` $ readelf -lW /usr/bin/frankenphp | grep GNU_STACK GNU_STACK 0x000000 0x0 0x0 0x000000 0x000000 RW 0x10 ``` so every PHP thread gets musl's 128KB fallback, where php-fpm's main thread gets 8MB. PHP 8.3+ auto-detects `zend.max_allowed_stack_size` from the real stack, and the arithmetic lands exactly on the reported number: 131072 - 47136 = **83936**. Nothing app-specific about it — it is the first page load of a stock Symfony app. ## Fix Rewrite that header field to 8MB at build time. This is the same thing upstream's `-Wl,-z,stack-size=...` build flag does; we install frankenphp prebuilt from pkg.henderkes.com, so a small helper (`elf-stack-size`) writes the field afterwards instead. 8MB is both what php-fpm gets and musl's ceiling — asking for 32MB still measures 8341472 usable, so it clamps. ## Why not `zend.max_allowed_stack_size = -1` It clears the symptom by removing the guard, not by giving PHP a stack. Confirmed on a built image with `-1` in place: a runaway recursion no longer raises a PHP `Error`, it takes the worker thread down and the request dies with `curl: (52) Empty reply from server`. With the stack sized instead, the guard stays armed and just reports the larger limit. ## Verified in a built image (arm64, PHP 8.5) | | before | after | |---|---|---| | 1000-deep generated expression (Twig-shaped) | HTTP 500, compile error | HTTP 200 | | runaway recursion | `Error`, limit 83936 bytes | `Error`, limit 8341472 bytes | | reference `matthewbaggett/php:8.5` (php-fpm) | limit 8339456 bytes | — parity | Deeper than that, both images fail the same clean way php-fpm does (`Parse error: memory exhausted` from the parser), so no crash band is opened up. ## Test coverage - `etc/validate.d/frankenphp.sh` asserts the header at build time on every PHP version — building with the patch step removed now fails the build. - New `frankenphp-85-stack-healthchecks` service asserts both directions at runtime: that a deeply nested template compiles *and* that infinite recursion is still caught. It is wired into the `test-frankenphp` group (CI cannot run these images; run `./test test-frankenphp` by hand). `./test` and `./test test-frankenphp` both pass locally. Closes #25
FrankenPHP creates the thread that serves a request with pthread_create(...,
NULL, ...), so musl picks the stack size: 128KB, where php-fpm's main thread
gets 8MB. PHP 8.3+ auto-detects zend.max_allowed_stack_size from that, arrives
at 83936 usable bytes, and then refuses to compile anything as deeply nested as
a Twig-generated template - a stock Symfony app 500s on its first page load
with "Maximum call stack size ... reached during compilation".

musl takes its default from the main executable's PT_GNU_STACK header, which is
what upstream's `-Wl,-z,stack-size=...` build flag writes. We install frankenphp
prebuilt from pkg.henderkes.com, so write the field afterwards instead: a new
elf-stack-size helper rewrites it to 8MB, both the size php-fpm gets and the
most musl will hand out. Verified in a built image - a 1000-deep expression goes
from 500 to 200, and the detected limit from 83936 to 8341472 bytes.

Deliberately not zend.max_allowed_stack_size=-1: that clears the same symptom by
disabling the guard, so runaway recursion segfaults the worker (confirmed: the
request dies with an empty reply) instead of raising a PHP Error. With the stack
sized instead, the guard stays armed and reports the new limit.

Covered both ways: validate.d fails the build if the header ever stops being
patched, and frankenphp-85-stack-healthchecks asserts at runtime that a deeply
nested template compiles *and* that infinite recursion is still caught.

Closes #25

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge remote-tracking branch 'pulls/Grey.ooo/main' into fix/frankenphp-zts-stack-size
Some checks failed
Build / Build (push) Has been cancelled
Build / Run container tests (push) Has been cancelled
4788119bab
Merge remote-tracking branch 'pulls/Grey.ooo/main' into fix/frankenphp-zts-stack-size
Some checks failed
Build / Run container tests (push) Successful in 20m22s
Build / Build (push) Has been cancelled
182601b671
# Conflicts:
#	fs/php-frankenphp/etc/validate.d/frankenphp.sh
#	test-healthchecks.yml
#	test.yml
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Grey.ooo/docker!30
No description provided.