Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ 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` 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).

## 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.
Expand Down
45 changes: 29 additions & 16 deletions frankenphp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
20 changes: 20 additions & 0 deletions testdata/out-of-memory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

require_once __DIR__.'/_executor.php';

return function () {
if (isset($_GET['hard_limit'])) {
var_export(ini_get('memory_hard_limit_percent') !== false);

return;
}

if (isset($_GET['exhaust'])) {
$a = [];
while (true) {
$a[] = str_repeat('x', 1 << 20);
}
}

echo 'ok';
};
Loading