From 0845aeab9d5c436d87dd9539bb54a251f121dfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 21 Oct 2026 17:58:59 +0200 Subject: [PATCH 1/3] test: adapt out-of-memory test to the PHP hard memory limit PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 exit the process when an allocation exceeds twice memory_limit, and no longer fail request startup on OOM. Stay under the hard limit and cover worker mode too. --- frankenphp_test.go | 45 ++++++++++++++++++++++++-------------- testdata/out-of-memory.php | 20 +++++++++++++++++ 2 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 testdata/out-of-memory.php diff --git a/frankenphp_test.go b/frankenphp_test.go index a5ed8c7c9c..39463615d7 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -1669,31 +1669,44 @@ func TestValidateReportsDeclarationErrors(t *testing.T) { ), "max_threads") } -func TestUnhealthyThreadIsRestarted(t *testing.T) { +func TestOutOfMemory_module(t *testing.T) { testOutOfMemory(t, &testOptions{}) } +func TestOutOfMemory_worker(t *testing.T) { + testOutOfMemory(t, &testOptions{workerScript: "out-of-memory.php", nbWorkers: 1}) +} +func testOutOfMemory(t *testing.T, opts *testOptions) { if os.Getenv("USE_ZEND_ALLOC") == "0" { t.Skip("memory_limit is not enforced without the Zend allocator") } var buf fmt.Stringer - opts := &testOptions{ - nbParallelRequests: 1, - initOpts: []frankenphp.Option{frankenphp.WithNumThreads(1)}, - phpIni: map[string]string{"memory_limit": "8M"}, - } + opts.nbParallelRequests = 1 + opts.initOpts = []frankenphp.Option{frankenphp.WithNumThreads(2)} + opts.phpIni = map[string]string{"memory_limit": "8M"} opts.logger, buf = newTestLogger(t) runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { - // Warm-up request, so that the failing request below is not the thread's first: + // Also a warm-up request, so that the failing request below is not the thread's first: // ext/date initializes DATEG(last_errors) only in RINIT, yet request shutdown // frees it even when startup failed before RINIT ran. A new ZTS thread then - // frees garbage (observed with PHP 8.5.12; same GINIT in PHP 8.2 to 8.5). - testGet("http://example.com/hello.php", handler, t) - - // fails request startup: $_GET is populated before the script runs - testGet("http://example.com/hello.php?q="+strings.Repeat("a", 16<<20), handler, t) - - body, _ := testGet("http://example.com/hello.php", handler, t) - assert.Equal(t, "Hello from PHP", body) - assert.Contains(t, buf.String(), "Restarting unhealthy thread") + // frees garbage (same GINIT in PHP 8.2 to 8.5). + body, _ := testGet("http://example.com/out-of-memory.php?hard_limit", handler, t) + hardLimit := body == "true" + + body, _ = testGet("http://example.com/out-of-memory.php?exhaust", handler, t) + assert.Contains(t, body, "Allowed memory size of 8388608 bytes exhausted") + body, _ = testGet("http://example.com/out-of-memory.php", handler, t) + assert.Equal(t, "ok", body) + + // $_GET is populated before the script runs; stay under the hard limit (twice memory_limit), past which PHP exits + body, _ = testGet("http://example.com/out-of-memory.php?q="+strings.Repeat("a", 5<<20), handler, t) + if hardLimit { + assert.Contains(t, body, "Allowed memory size of 8388608 bytes exhausted") + } + body, _ = testGet("http://example.com/out-of-memory.php", handler, t) + assert.Equal(t, "ok", body) + if !hardLimit && opts.workerScript == "" { + // Older PHP versions fail the request startup instead + assert.Contains(t, buf.String(), "Restarting unhealthy thread") + } }, opts) } diff --git a/testdata/out-of-memory.php b/testdata/out-of-memory.php new file mode 100644 index 0000000000..15be0f2c5e --- /dev/null +++ b/testdata/out-of-memory.php @@ -0,0 +1,20 @@ + Date: Wed, 21 Oct 2026 17:58:59 +0200 Subject: [PATCH 2/3] docs: document that exceeding the PHP hard memory limit stops the server --- docs/known-issues.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/known-issues.md b/docs/known-issues.md index 7a0de5ae19..61c94f1d23 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -25,6 +25,16 @@ The following extensions have known bugs and unexpected behaviors when used with | [blackfire](https://blackfire.io/) | [FrankenPHP support is in beta and not yet feature-complete](https://docs.blackfire.io/php/integrations/frankenphp). | | [imagick](https://github.com/Imagick/imagick) | ImageMagick's OpenMP threads conflict with FrankenPHP's threads, causing instability and crashes. Mitigate by disabling ImageMagick threading via `\Imagick::setResourceLimit(\Imagick::RESOURCETYPE_THREAD, 1)` or by compiling ImageMagick with `--disable-openmp`. OpenMP is already disabled in the static binaries and the official `apt`/`apk`/`rpm` packages, so only the Docker images and Homebrew installations are affected. | +## Exceeding the PHP hard memory limit stops the whole server + +Since PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12, exceeding `memory_limit` still ends the request with the usual "Allowed memory size exhausted" fatal error, +but an allocation that would exceed the hard memory limit makes PHP exit the process immediately. +The hard limit defaults to twice `memory_limit` (or `memory_limit` plus 1 GiB above 1 GiB) and can be changed with the `memory_hard_limit_percent` INI setting. + +PHP-FPM only loses one child process. +FrankenPHP serves all requests from threads of a single process, so every in-flight request is lost and the server stops until it is restarted. +Run FrankenPHP under a supervisor that restarts it, such as a Docker restart policy or systemd's `Restart=` (already set in the `deb` and `rpm` packages), and keep `memory_limit` high enough for the largest legitimate payloads your app handles. + ## get_browser The [get_browser()](https://www.php.net/manual/function.get-browser.php) function seems to perform badly after a while. A workaround is to cache (e.g. with [APCu](https://www.php.net/manual/book.apcu.php)) the results per user agent, as they are static. From cfa19eafb2469a148e762773a1c8b05a69b9b7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 21 Oct 2026 18:19:10 +0200 Subject: [PATCH 3/3] docs: explain how scripts reach the PHP hard memory limit --- docs/known-issues.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/known-issues.md b/docs/known-issues.md index 61c94f1d23..4620d098a1 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -27,13 +27,15 @@ The following extensions have known bugs and unexpected behaviors when used with ## Exceeding the PHP hard memory limit stops the whole server -Since PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12, exceeding `memory_limit` still ends the request with the usual "Allowed memory size exhausted" fatal error, -but an allocation that would exceed the hard memory limit makes PHP exit the process immediately. +Since PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12, exceeding `memory_limit` no longer stops the script immediately: the "Allowed memory size exhausted" fatal error is raised at the next function call or loop iteration. +If the script allocates past the hard memory limit before that, PHP exits the process. The hard limit defaults to twice `memory_limit` (or `memory_limit` plus 1 GiB above 1 GiB) and can be changed with the `memory_hard_limit_percent` INI setting. +Code that keeps running past `memory_limit` can reach it, for instance a single allocation larger than `memory_limit` (such as `array_merge()` on large arrays) or consecutive string concatenations. + PHP-FPM only loses one child process. FrankenPHP serves all requests from threads of a single process, so every in-flight request is lost and the server stops until it is restarted. -Run FrankenPHP under a supervisor that restarts it, such as a Docker restart policy or systemd's `Restart=` (already set in the `deb` and `rpm` packages), and keep `memory_limit` high enough for the largest legitimate payloads your app handles. +Run FrankenPHP under a supervisor that restarts it, such as a Docker restart policy or systemd's `Restart=` (already set in the `deb` and `rpm` packages). ## get_browser