`exit()` may silently break your parallel tests
I recently upgraded our app to use Pest 5 and leverage it's new TIA engine. What should have been a straightforward performance win turned into a multi-hour debugging session, uncovering a chain of legacy issues that silently broke our test suite.
The symptom
Running our parallel test suite, we'd see a WorkerCrashedException bubble up from ParaTest. The exception told us a worker process had died, but the message was effectively empty. No indication of which test was running, what went wrong, or where to look.
I tried --display-errors, --display-warnings, --display-notices, and every other diagnostic flag. Nothing changed. The worker crashed, the parent reported the crash, and we were left with no leads.
How the parallel runner detects crashes
To understand why there was no output, I first had to understand how the parallel runner manages its workers. Pest's parallel runner (built on ParaTest) spawns separate PHP processes that each pick up test files from a queue. The parent coordinates through the filesystem; workers write results to status files, and the parent polls those files to track progress.
The critical detection happens in the main assignment loop:
while (count($this->pending) > 0) {
foreach ($this->workers as $worker) {
if (!$worker->isRunning()) {
throw $worker->getWorkerCrashedException();
}
if (!$worker->isFree()) {
continue;
}
// assign next test...
}
usleep(10_000);
}
When a worker process dies unexpectedly, isRunning() returns false and the parent throws WorkerCrashedException. The exception captures whatever was in the worker's stdout at the time of death. The problem with that is the worker's stdout only contains what the test framework had time to write before the process terminated. If the process terminated abruptly, that's typically nothing.
This is the difference between a test that fails and a worker that dies. A failing test writes its result, the framework records it, and the parent collects it normally. A dying worker just ceases to be - the parent knows it's gone but has no idea why.
The culprit
Deep in part of our (refector-in-progress) legacy quote engine was the following method:
private function getBaseMotors()
{
$rate_row = $this->getRatesTable()->firstWhere([
'category' => 'Motor Vehicles',
'tier' => $this->row,
'age' => $this->column,
]);
if (!empty($rate_row)) {
return $rate_row->rate;
} else {
exit('Error: Cannot find base rate in database');
}
}
exit(). Not an exception. Not a return. A hard process termination.
In a single-process test runner, exit() kills the entire PHPUnit process and whilst you lose the run, you'll typically see the error string in your terminal. It's ugly, but you know where to look.
In parallel mode, exit() kills only the worker process. The string 'Error: Cannot find base rate in database' is written to the worker's stdout, but the parent doesn't read worker stdout for diagnostic messages. It reads status files. The worker dies, the status file is incomplete, and the parent throws WorkerCrashedException with whatever it can scrape from the process, which is nothing useful in my experience.
The error message we needed was right there in the exit() call. It just went to a file descriptor that nothing was reading.
Why verbose flags didn't help
This cost the most time. Pest's --display-errors and --display-warnings flags control how the test framework reports results. They work by configuring PHPUnit's result printer to show additional detail for tests that reach a terminal state, whether errored, failed, or otherwise.
But exit() bypasses all of that. The test doesn't complete. The framework doesn't record a result. There's no error to display because, from PHPUnit's perspective, nothing happened as the process simply stopped. The verbose flags are asking the framework to show more detail about something it never saw.
This is what makes exit() in a parallel context so grim. It doesn't just hide the error message, it removes it entirely. The framework can't report what it doesn't know about, so the parent process can only report that the worker is gone.
The fix
The immediate fix was straightforward: replace exit() with throw new RuntimeException(...).
if (!empty($rate_row)) {
return $rate_row->rate;
} else {
exit('Error: Cannot find base rate in database');
throw new RuntimeException('Cannot find base rate in database.');
}
An exception is part of the normal test lifecycle. PHPUnit catches it, records it as an error, writes the result to the status file, and the parent collects it with a full stack trace. The worker doesn't die - it finishes the test (as an error), reports back, and picks up the next one.
With the exceptions in place, the previously silent failures became immediately visible: full stack traces, the exact method and line number, the specific rate lookup that was failing. Problems that had been invisible for the entire lifetime of the parallel runner were now visible in seconds.
This also uncovered a chain of secondary issues that exit() had been masking. Implicit null returns in edge-case code paths, database storage engine mismatch that broke test transaction rollback. Artefacts of a legacy codebase, but straightforward to address once the errors themselves were visible.
Lessons
Don't use exit(). It's a process-level operation masquerading as error handling. In a single-process world, it's rude. In a multi-process world, it's silent sabotage. The worker dies, the parent gets an empty crash report, and you're left adding verbose flags to a framework that never saw the error happen.
Even in the era of AI digging aorund for issues, it still took a few hours to get to the bottom of this one.
Verbose flags only show what the framework knows about. When debugging a silent WorkerCrashedException, the instinct is to add more output flags. But if the worker process terminates outside the framework's control, no amount of --display-errors will help. If verbose output gives you nothing, the problem is below the framework, and now we know that something is killing the process directly.
Parallel runners change the failure mode. A test suite that works fine sequentially can fail in parallel for reasons unrelated to concurrency. exit(), die(), segfaults, or any other hard process termination behaves fundamentally differently when the process being killed is a worker, not the runner itself.
The same exit() call that produced a visible error in single-process mode becomes a silent, undiagnosable crash in parallel.
Search for exit() before going parallel. If you're moving a legacy codebase to parallel test execution, grep -rn 'exit(' app/ before your first run. Every hit is a potential silent crash that will waste your time in ways no debugger can help with.
Ultimately the exit paths showed us other issues with our test scaffolding that were subsequently addressed, but getting to the root cause was a task in and of itself.
Written by Michael Dyrynda
Principal Engineer, Laravel enthusiast, and open source contributor. I write about web development, PHP, and the problems I solve along the way.