Skip to content
LaravelBlog

Parallel test runs no longer race over compiled view directories

LaravelBlogBot
LaravelBlogBot

Contributed by joshdaugherty

Part of the framework v13.35.0 release

Parallel test runs no longer race over compiled view directories

Parallel test runs that collide with each other no longer crash the framework's view-path bookkeeping. PR #61786 fixes a check-then-act race in the TestViews trait, where two simultaneous parallel runs of the same project could fail with mkdir(): File exists during set-up or an UnexpectedValueException during tear-down. The change is narrowly scoped to the two ParallelTesting callbacks in src/Illuminate/Testing/Concerns/TestViews.php: set-up now creates the directory in a way that tolerates it already existing, and tear-down treats a directory that vanished as already cleaned up. There is no API change, no configuration change, and no behavioral difference for a single parallel run — if you are upgrading from v13.34.0, pulling this patch simply removes a source of flaky parallel test failures.

The race: two runs, same token, check-then-act

The TestViews trait registers setUpProcess and tearDownProcess callbacks with ParallelTesting so each parallel worker gets its own compiled view directory, named after ParallelTesting::token() — the worker index. Because the token is only the worker index, two parallel runs of the same project running at the same time resolve the same directory paths. That happens easily in practice: a local run while a CI job works in the same checkout, or two CI jobs sharing a workspace.

# Terminal 1 and Terminal 2 (or CI) on the same project:
php artisan test --parallel
php artisan test --parallel

Both callbacks used check-then-act logic, so the run that loses the timing fails:

  • Set-up: File::ensureDirectoryExists() checks isDirectory(), then calls mkdir() without warning suppression. If the other run creates the directory in between, you get ErrorException: mkdir(): File exists.
  • Tear-down: File::deleteDirectory() checks isDirectory(), then constructs a FilesystemIterator. If the other run deletes the directory in between, the iterator throws UnexpectedValueException.

Neither failure indicates a real problem — each is the framework fighting itself over work the other run already did.

What changed

Only the two callbacks in the trait were touched:

// Before (v13.34.0)
ParallelTesting::setUpProcess(function () {
    if ($path = $this->parallelSafeCompiledViewPath()) {
        File::ensureDirectoryExists($path); // check, then mkdir()
    }
});

ParallelTesting::tearDownProcess(function () {
    if ($path = $this->parallelSafeCompiledViewPath()) {
        File::deleteDirectory($path); // check, then FilesystemIterator
    }
});

// After
ParallelTesting::setUpProcess(function () {
    if ($path = $this->parallelSafeCompiledViewPath()) {
        File::makeDirectory($path, 0755, true, true); // recursive, force
    }
});

ParallelTesting::tearDownProcess(function () {
    if ($path = $this->parallelSafeCompiledViewPath()) {
        try {
            File::deleteDirectory($path);
        } catch (UnexpectedValueException) {
            // A concurrent parallel run removed the directory first,
            // which is the state we want...
        }
    }
});

Passing force: true to makeDirectory() removes the check-then-act window on set-up entirely: whether the directory exists or not, the call succeeds. On tear-down, UnexpectedValueException is precisely what FilesystemIterator throws when the directory disappeared mid-flight, so catching it converts "another run already did my job" into success.

Proof by deterministic test

Races are notoriously hard to test because you cannot rely on timing. The two new tests in TestViewsTest remove luck from the equation: each replaces the container's files binding with a Filesystem whose isDirectory() answers as it would have just before the other run acted, while the real directory sits in the other run's state. A custom error handler (withWarningsAsExceptions) turns the unsuppressed mkdir() warning into an ErrorException, so the old code cannot quietly get away with it.

Against the 13.x implementation, the set-up test fails with ErrorException: mkdir(): File exists and the tear-down test fails with UnexpectedValueException from FilesystemIterator. With this patch, both pass, and the full tests/Testing suite is green (414 tests, verified on PHP 8.4 / Windows 11).

Why it matters

These failures looked like random, environment-specific breakage: the same command that failed seconds ago passes on retry, and nothing in your application code is wrong. Because the window is a fraction of a second, the problem surfaces more often on faster CI runners with heavier parallelism, where overlapping runs and matrix jobs against shared workspaces are common. Making the callbacks idempotent means parallel testing infrastructure can no longer be the flaky part of your pipeline.

Upgrade impact

Practically none, which is what you want from a fix at this layer:

  • No migrations, config, or environment changes; token() returns what it always did, and compiled views go to the same per-worker directories.
  • A single parallel run behaves exactly as it did in v13.34.0.
  • If you registered your own setUpProcess/tearDownProcess callbacks, they are untouched — but if you copied the old check-then-act pattern elsewhere, this PR is a good pattern to mirror: create idempotently, tolerate disappearance.
  • If you fork or pin TestViews, rebase onto this change.

Takeaways

  • PR #61786 fixes concurrent parallel test runs crashing over the same token-based compiled view directories.
  • Set-up now uses File::makeDirectory($path, 0755, true, true) instead of a check-then-act ensureDirectoryExists().
  • Tear-down catches UnexpectedValueException and treats a vanished directory as success.
  • Behavior for a single parallel run is unchanged; upgrading from v13.34.0 requires no action beyond pulling the patch.
  • Two new deterministic tests cover both halves of the race; they fail on the old code and pass with the fix.
  • Changelog category: Fix (id 3).

Sources

More from this release

Related Articles