[Bug]: type-coverage cache lock bails out after 100ms and then writes unlocked, corrupting .temp/v3.php

Open
#1,766 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
72/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Quiet
Tech stack
php
Domain
testing-qa

Research direction

Start in src/Support/Cache.php and inspect withinLock(), then run the type-coverage command from the report under concurrent workers to reproduce the cache corruption. Trace the write guarded by the lock and verify that contention no longer produces an invalid .temp/v3.php file or a parse error.

Written by the indexing model from the issue text.

Description

What happened

pest --type-coverage fails intermittently in CI with a parse error inside the plugin's own cache file:

PHP Parse error: syntax error, unexpected single-quoted string " => "
 in vendor/pestphp/pest-plugin-type-coverage/.temp/v3.php on line 31
Script @php -d memory_limit=512M vendor/bin/pest --type-coverage --min=98 returned with error code 255

The generated .temp/v3.php contains one writer's complete output immediately followed by another's partial output:

28▕   ),
29▕ );e160161e88d10348' =>
30▕   array (
31▕     0 => '/home/runner/work/.../app/Mail/TaskCommentMail.php',

Line 29 is a finished var_export ();) with the tail of a different cache key appended to it. The next include $filePath then dies.

Root cause

src/Support/Cache.phpwithinLock() gives up on the lock and performs the write anyway:

$attempts = 0;
while (! flock($lock, LOCK_EX | LOCK_NB) && $attempts < 100) {
    usleep(1000);
    $attempts++;
}

if ($attempts >= 100) {
    fclose($lock);

    return $callback();   // <- runs the write with no lock held
}

So after ~100ms of contention the mutual exclusion is abandoned rather than enforced. The write it guards is:

$content = '<?php return '.var_export($cache, true).';';

if (file_put_contents($filePath, $content) !== false) {

file_put_contents without LOCK_EX truncates then writes non-atomically, so two callers past the bail-out interleave and produce exactly the corruption above.

Contention is high by design: src/Analyser.php fans the file list out through Pokio's async(), and Pokio\Environment::maxProcesses() sizes the pool at cores × 3. On a 12-core machine that is 36 workers, each writing the same file as it finishes:

foreach ($chunks as $files) {
    $promises[] = async(function () use ($cache, $files, $testCase, $onProcessedFile) {
Why it only shows up in CI

It is a timing race. A developer machine acquires the lock inside the 100ms budget; a loaded shared CI runner does not, so the bail-out fires. The same commit passes locally (Total: 99.0 %, clean) and fails on a 2-core GitHub Actions runner.

Suggested fix

Either would remove the corruption:

  1. Don't write unlocked. On exhausting the attempts, skip the cache update (or fail loudly) rather than proceeding — a missed cache entry is recoverable, a corrupt cache file is not.
  2. Add LOCK_EX to the write: file_put_contents($filePath, $content, LOCK_EX), so concurrent writers serialise even when the advisory lock was skipped.

Writing to a temp file and rename()ing over the target would also make the swap atomic.

Workaround

Pinning Pokio to a single worker removes the contention. maxProcesses = min(cores × 3, totalMemory / FORK_MEM_PER_PROC) clamped to >= 1, so a large per-process figure forces one:

- name: Type coverage
  env:
    FORK_MEM_PER_PROC: '999999999999'
  run: vendor/bin/pest --type-coverage --min=98

Verified: 36 workers by default, 1 with the variable set. Worth noting there was no measurable cost on our suite — 21s serial from a cold cache versus 39s parallel, so fork overhead exceeded the parallelism gain.

Versions
Package Version
pestphp/pest v4.7.5
pestphp/pest-plugin-type-coverage v4.0.4
nunomaduro/pokio v1.0.1
PHP 8.3.17

Filed here as issues are disabled on pestphp/pest-plugin-type-coverage.

Dominant language
PHP
Stars
11.7k
Forks
538
Avg merge
4d 11h
Merged PRs (30d)
8

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pestphp/pest

All issues in pestphp/pest

Similar issues

More PHP issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.