You may have memory leaking from PHP 7 and Symfony tests

Last week I spent a couple of hours with my buddy Grégoire on a surprising memory leak while running PHPUnit tests. We tried the best known ways of debugging such issues:

  • php-meminfo by Benoit Jacquemont, but our 200 megabytes of lost memory where not visible;
  • Blackfire, where we could see the memory usage increase but not really where it was going.

As we were attending the Forum PHP (a great PHP conference in Paris) I reached for help Romain Neutron, technical lead at Blackfire, and Nicolas Grekas, core contributor of Symfony.

What my tests are doing

I have a simple integration test called SpiderTest. It takes a page (the homepage basically) and clicks on all the local links to check their HTTP status. So this test uses the Symfony 4.3 KernelBrowser a lot, and runs ~50 fake HTTP requests.

As my list of links grew, memory became an issue, to the point that tests were running out of it:

PHPUnit 6.5.14 by Sebastian Bergmann and contributors.

Testing REDACTED PROJECT Suite
...............................................................  63 / 167 ( 37%)
...........................PHP Fatal error:  Allowed memory size of 268435456 bytes exhausted (tried to allocate 45056 bytes) in /home/app/app/vendor/twig/twig/src/Template.php on line 401
PHP Fatal error:  Allowed memory size of 268435456 bytes exhausted (tried to allocate 53248 bytes) in /home/app/app/vendor/twig/twig/src/Compiler.php on line 129
PHP Fatal error:  Allowed memory size of 268435456 bytes exhausted (tried to allocate 53248 bytes) in Unknown on line 0

I quickly identified the leaking test to a single one, the Spider, which code is shown below:

/**
 * @dataProvider provideAllPages
 */
public function testPage(\DOMElement $link)
{
    $path = $link->getAttribute('href');

    $client = static::createClient();

    $client->followRedirects(true);
    $client->request('GET', $path);

    $this->assertSame(200, $client->getResponse()->getStatusCode());
}

Here is what we could see in the Blackfire timeline:

/media/original/2019/blackfire-memory-leak.png

Each HttpKernel Client request call costs memory (the blue background is the memory) where it should just properly garbage collect everything.

There could be a lot of reasons for this memory leak so we tried a number of things:

  • disabling all logging and profiling;
  • running some parts of our code in loops (like Elastically calls, database queries…) to ensure there is no leak in it;
  • calling the garbage collector ourselves…

And none of these solutions worked.

An issue in PHP itself

When the KernelBrowser performs a request, the Symfony Kernel is shut down and (re)booted. This is done to simulate the real experience of an HTTP query received by Symfony: you always have a fresh container.

So for every query, a new container has to be loaded. Loading a container is done either by including existing files, or dumping them before requiring them. Either way, there is an include or a require happening.

Those files live in the var/cache directory, and you can have a look at them: it’s full of arrays, anonymous functions, class instantiations…

That’s where Nicolas great knowledge helped us a lot, there is a reported issue in PHP that may be related to our case: https://bugs.php.net/bug.php?id=76982. It says that when you require a file declaring a closure in a loop, the memory usage increases continuously, so it definitely hit hard on Symfony container loading!

The quick and dirty solutions

Since the bug is in PHP, and I can’t change the way a Symfony container is built, I found two user-land solutions to avoid leaks:

1. Running insulated queries

The BrowserKit Client has a insulated option that you can use to run your test requests in a new PHP process. As the request is run in a throwaway process, the memory of your main test is not impacted, but it will be slower.

$client = static::createClient();
$client->insulate(true);

2. Disabling reboot between queries

There is another method called disableReboot allowing to reuse the same Kernel and container on subsequent queries.

$client = static::createClient();
$client->disableReboot();

You have to be careful with this method, especially if your services are not stateless. But you can implement the kernel.reset tag to “clean” them between requests.

Final words

As suggested by Grégoire, I also tried a new option of Symfony 4.4: a container dumping strategy to write only one file with all the definitions, instead of multiple ones. Sadly that did not work, as the included file is still full of closures.

What I learned from this experience is that if two great tools can’t find the leak, that’s probably because it’s in PHP itself! I should have turned to the PHP bug tracker way earlier!

Thanks again to Nicolas, Romain and Grégoire for the help! The PHP community is a great place to work and I’m glad events like Forum PHP exist: they bring everyone together and allow for greater collaboration and constructives exchanges!

blog comments powered by Disqus