Laravel: Fixing memory leaks on tests

And… well, any CLI operation on Windows

Photo by Jeremy Bezanger on Unsplash

If you have been creating a Laravel application and got to the point of testing if it actually works as expected, you know how disruptive can be when it doesn’t.

Errors on testing runs are normal, but in some cases, the testing framework does not throw an error but, worse, decides to kill himself with a message painted in blood.

That “memory exhaustion” is nothing more than the classic memory leak, byproduct of something lingering and persisting between tests cases. It’s a problem you may end up when you make an application with many test cases, as not only Laravel keeps some data around, your tests classes too.

There are many ways to ensure your tests come out as clean as the come in, which will surely help you fix memory leaks that will randomly pop out. In this article I will share some fixes that may work for your case, and the one that (weirdly and) ultimately did for mine.

Manual ol’ classic bug hunting

Believe it or not, one of the common memory leaks are static properties. On classic requests, PHP executes and destroys itself, so these not pose a problem because everything is thrown out of the window for the next request.

It becomes a memory leak when you reuse the PHP process to execute each Test Case, as static data remains and grows bigger. This is also true for Laravel Octane or anything similar.

A tool-less way to check if the application booting itself is the culprit, rather than the userland code, is to make a loop that creates, boots the application and flushes it, multiple times.

If this triggers the memory exhaustion, then you know a service provider or a service instance is not being flushed as it should.

Before testing the loop, in your project, go to your Service Providers section in config/app.php and start removing them until you find which service is growing out of hand. For the case of external packages, you will have to check the bootstrap/cache/packages.php file instead of removing them one by one.

Under Windows, this is the only way to check for memory leaks. If you’re on Linux or MacOS, there are some tools around which may help you finding what is using memory, and where is it. If it happens in one test specifically, Roave’s No-Leaks may help out to find it.

Cleaning your room

In a nutshell, a destructor on Test Classes are never called because there is a reference somewhere (probably inside PHPUnit itself) even after the Test Class completes. Practically, the reference is deleted once there are no more test to run, which is when PHPUnit exits.

If there is a reference to the object still alive, all properties defined inside the Test Class will remain, even that single boolean you have set.

Laravel makes a cleanup run of the application every time a Test Case finishes, including flushing every service before deleting the app instance, but your will have to set as null (or unset) your own properties.

Luckly, you can use beforeApplicationDestroyed() with a callback to remove those lingering properties in the class, like a giant array, or a model with a handful of relationships, just in the setUp() method.

Shoving the vacuum cleaner

If you’re really lazy, or you have a lot properties defined and you can’t track all of them everywhere, you may want to use this code based on one found in StackOverflow.

This code basically collects each property that is not internal, that probably you left behind, and unsets them. Most of the time won’t create any problem. You can call it after in the tearDown() method of your master TestCase.

Pushing the Container to the endless pit

Sometimes PHP may keep a Laravel application instance around in memory because of yes. It’s very rate, but some folks say it sometimes happen.

One way to forcefully remove the application instance is with the container static helper, after the application is flushed and dead.

The above basically sets the shared container instance, which is a singleton, as null when calling it without arguments. Without any container reference, anything that holds should be dead for good until the next Test Case run. Again, it’s rare, but there is it.

Taking the garbage out

You shouldn’t need to call the Garbage Collector on PHP, but you can anyway with gc_collect_cycles(). This functions calls the Garbage Collector which immediately frees up the memory of any lingering variable pointing to null or unset.

This may be a good way to immediately remove unused memory when, for example, your hand is forced to use a variable that sits during the tests and is again recreated on the next test.

Usually, PHP’s Garbage Collector is pretty intelligent on when to run, but you can hurry it, and sometimes it fixes whatever leak you had.

Buy four trucks, or eight

Having many test cases with such memory leaks make the whole testing slower as it progresses. Luckly, Laravel is compatible with Paratest. In other words, multiple PHPUnit processes will spawn for each Test Class, that will be executed in parallel.

This doesn’t fix your memory leak. Since a PHP process will die after a Test Class is done, there is no opportunity to the leak to become bigger as it where with hundreds of Test Cases. Again, not a silver bullet, but rather, a patch to a problem out of the testing scope that you can sort out later.

Buy a house while you’re at it

If you can’t find the memory leak, but you’re urged to get the test rolling anyway in a single process, let me introduce you to raising the memory limit.

If you don’t feel anything about disregarding any advice from the Internet, just pump up the memory limit of PHP directly with the -d argument. Yes, that lack of space is not a typo, you can glue any option name to the d.

PS> php -dmemory_limit=1024M ./vendor/phpunit/phpunit/phpunit

Why is this bad? Because you haven’t fixed the root of the problem. The larger the test suite, the more memory PHPUnit and Laravel will eat, and you can only guess if the limit is enough or not until the test run ends, when it reports how much memory it consumed.

OPcache to the rescue!?

This one was weird, but I found it nonetheless. Let’s start with how OPcache describes itself:

OPcache improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request.

In other words, once a script is read and parsed, is stored on memory. Each time PHP 8.0.12 requires that same script, it will read it from memory instead of asking the filesystem for it, again and again and again. In essence, is like an array for required files.

I figured out that PHP leaked some memory by constantly reading a harmless script that was executed used require. So, in theory, OPcache would stop the memory leak if that was the culprit. Again, that was weird to think about, but no matter what I did, all clues pointed to loading scripts continuously outside the Autoloader Class Map and piling them up in a place nobody could find.

I enabled OPcache on the CLI through the php.ini, and ran all tests again.

Amazing. Not only 1,000 tests were performed, but also it was relatively fast. I did another run directly on PHPUnit, and it reported a memory footprint of 40MB, which is very low compared over the 1GB reported once the pain ended.

Note, if you depend on artisan test, you should enable OPcache on the php.ini. instead of passing just -dopcache.enable_cli=1. This is because Laravel creates a new PHP process to handle the test, and hooks to it for showing the pretty tests information you see.

I turned the notch up to eleven to make test faster by forcing OPcache to not validate timestamps or checksums on cached files — changing or adding files mid-tests is very rare — and ran it in parallel. It took around 20 seconds. No more memory leaks, the problem was PHP all along.

Since I’m a noob for reporting things to PHP, I’ll leave this if someone stumbles upon the same problem on Windows. Maybe it will be fixed later by the guys at PHP.