[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(coverage): fail fast when `coverage.reportsDirectory` conflicts between concurrent runs (#10466)

Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>

authored by

João Guilherme do Amaral Vequiato
Ari Perkkiö
and committed by
GitHub
(Jun 23, 2026, 12:59 PM +0300) 833f0936 c4090147

+226 -7
+101 -2
test/coverage-test/test/temporary-files.unit.test.ts
··· 1 - import { resolve } from 'node:path' 2 - import { expect, test } from 'vitest' 1 + import { spawn } from 'node:child_process' 2 + import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' 3 + import { tmpdir } from 'node:os' 4 + import { join, resolve, sep } from 'node:path' 5 + import { assert, expect, onTestFinished, test } from 'vitest' 3 6 import { BaseCoverageProvider } from 'vitest/node' 4 7 5 8 test('missing coverage temp directory throws an actionable error', async () => { ··· 17 20 `Something removed the coverage directory "${provider.coverageFilesDirectory}" Vitest created earlier. Make sure you are not running multiple Vitests with the same "coverage.reportsDirectory" at the same time.`, 18 21 ) 19 22 }) 23 + 24 + test('clean() acquires the reportsDirectory lock and cleanAfterRun() releases it', async () => { 25 + const { provider, lockFile } = createProvider() 26 + 27 + await provider.clean(true) 28 + 29 + expect(existsSync(provider.coverageFilesDirectory)).toBe(true) 30 + expect(existsSync(lockFile)).toBe(true) 31 + expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(process.pid) 32 + 33 + await provider.cleanAfterRun() 34 + 35 + expect(existsSync(lockFile)).toBe(false) 36 + }) 37 + 38 + test('clean() is re-entrant for the same process (e.g. watch mode reruns)', async () => { 39 + const { provider } = createProvider() 40 + 41 + await provider.clean(true) 42 + 43 + // Should not throw lockfile acquiring errors 44 + await expect(provider.clean(true)).resolves.toBeUndefined() 45 + 46 + await provider.cleanAfterRun() 47 + }) 48 + 49 + test('clean() throws an actionable error when another live process holds the lock', async () => { 50 + const { provider, reportsDirectory, lockFile } = createProvider() 51 + 52 + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) 53 + onTestFinished(() => void child.kill()) 54 + await new Promise(resolve => child.once('spawn', resolve)) 55 + 56 + const childPid = child.pid 57 + assert(childPid != null, 'child process did not start') 58 + 59 + writeFileSync(lockFile, JSON.stringify({ pid: childPid, reportsDirectory })) 60 + 61 + await expect(provider.clean(true)).rejects.toThrow( 62 + `The coverage report directory "${reportsDirectory.replaceAll(sep, '/')}" is already in use by ` 63 + + `another Vitest process (pid ${childPid}). Running coverage for multiple ` 64 + + `Vitest processes in the same directory at the same time is not supported, because they would ` 65 + + `delete each other's reports.\nGive each run its own "coverage.reportsDirectory" ` 66 + + `(e.g. --coverage.reportsDirectory=coverage-${process.pid}) or run them sequentially.`, 67 + ) 68 + 69 + expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(childPid) 70 + }) 71 + 72 + test('clean() reclaims an empty / non-JSON lock file without crashing', async () => { 73 + const { provider, lockFile } = createProvider() 74 + 75 + writeFileSync(lockFile, 'not-valid-json{') 76 + 77 + await expect(provider.clean(true)).resolves.toBeUndefined() 78 + 79 + expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(process.pid) 80 + 81 + await provider.cleanAfterRun() 82 + }) 83 + 84 + test('clean() reclaims a stale lock left by a process that no longer exists', async () => { 85 + const { provider, reportsDirectory, lockFile } = createProvider() 86 + 87 + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }) 88 + await new Promise(resolve => child.once('exit', resolve)) 89 + 90 + const deadPid = child.pid 91 + assert(deadPid != null, 'child process did not start') 92 + 93 + writeFileSync(lockFile, JSON.stringify({ pid: deadPid, reportsDirectory })) 94 + 95 + await expect(provider.clean(true)).resolves.toBeUndefined() 96 + 97 + expect(JSON.parse(readFileSync(lockFile, 'utf-8')).pid).toBe(process.pid) 98 + 99 + await provider.cleanAfterRun() 100 + }) 101 + 102 + function createProvider() { 103 + const reportsDirectory = mkdtempSync(join(tmpdir(), 'vitest-coverage-reports-')) 104 + onTestFinished(() => rmSync(reportsDirectory, { recursive: true, force: true })) 105 + 106 + const provider = new BaseCoverageProvider() 107 + provider._initialize({ 108 + logger: { warn: () => {} }, 109 + config: { root: process.cwd() }, 110 + _coverageOptions: { reportsDirectory }, 111 + } as any) 112 + 113 + // eslint-disable-next-line dot-notation -- Accessing private property 114 + const lockFile = provider['reportsDirectoryLock'].lockFile 115 + onTestFinished(() => rmSync(lockFile, { force: true })) 116 + 117 + return { provider, reportsDirectory, lockFile } 118 + }
+125 -5
packages/vitest/src/node/coverage.ts
··· 5 5 import type { SerializedCoverageConfig } from '../runtime/config' 6 6 import type { AfterSuiteRunMeta } from '../types/general' 7 7 import type { TestProject } from './project' 8 + import { createHash } from 'node:crypto' 8 9 import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs' 9 10 import module from 'node:module' 11 + import { tmpdir } from 'node:os' 10 12 import path from 'node:path' 11 13 import { fileURLToPath } from 'node:url' 12 14 import { cleanUrl, slash } from '@vitest/utils/helpers' ··· 89 91 coverageFiles: CoverageFiles = new Map() 90 92 pendingPromises: Promise<void>[] = [] 91 93 coverageFilesDirectory!: string 94 + reportsDirectoryLock!: ReportsDirectoryLock 92 95 roots: string[] = [] 93 96 changedFiles?: string[] 94 97 ··· 140 143 this.options.reportsDirectory, 141 144 tempDirectory, 142 145 ) 146 + this.reportsDirectoryLock = new ReportsDirectoryLock(resolve(this.options.reportsDirectory)) 143 147 144 148 // If --project filter is set pick only roots of resolved projects 145 149 this.roots = ctx.config.project?.length ··· 245 249 } 246 250 247 251 async clean(clean = true): Promise<void> { 252 + await this.reportsDirectoryLock.acquire() 253 + 248 254 if (clean && existsSync(this.options.reportsDirectory)) { 249 255 await fs.rm(this.options.reportsDirectory, { 250 256 recursive: true, ··· 354 360 } 355 361 356 362 async cleanAfterRun(): Promise<void> { 357 - this.coverageFiles = new Map() 358 - await fs.rm(this.coverageFilesDirectory, { recursive: true }) 363 + try { 364 + this.coverageFiles = new Map() 365 + await fs.rm(this.coverageFilesDirectory, { recursive: true }) 359 366 360 - // Remove empty reports directory, e.g. when only text-reporter is used 361 - if (readdirSync(this.options.reportsDirectory).length === 0) { 362 - await fs.rm(this.options.reportsDirectory, { recursive: true }) 367 + // Remove empty reports directory, e.g. when only text-reporter is used 368 + if (readdirSync(this.options.reportsDirectory).length === 0) { 369 + await fs.rm(this.options.reportsDirectory, { recursive: true }) 370 + } 371 + } 372 + finally { 373 + await this.reportsDirectoryLock.release() 363 374 } 364 375 } 365 376 ··· 950 961 return config 951 962 } 952 963 } 964 + } 965 + } 966 + 967 + /** 968 + * Cross-process lock on a coverage `reportsDirectory`. 969 + * 970 + * Two `vitest run --coverage` runs pointed at the same reports directory delete 971 + * each other's reports, so the second one to start fails fast instead. The lock 972 + * file lives in the OS temp directory so it survives the cleanup it guards, and a 973 + * lock left behind by a process that no longer exists is reclaimed on the next run. 974 + */ 975 + interface LockOwner { 976 + pid: number 977 + reportsDirectory: string 978 + } 979 + 980 + class ReportsDirectoryLock { 981 + readonly lockFile: string 982 + 983 + constructor(private readonly reportsDirectory: string) { 984 + const hash = createHash('sha256') 985 + .update(reportsDirectory) 986 + .digest('hex') 987 + .slice(0, 16) 988 + 989 + this.lockFile = resolve(tmpdir(), `vitest-coverage-${hash}.lock`) 990 + } 991 + 992 + async acquire(): Promise<void> { 993 + if (await this.tryWrite()) { 994 + return 995 + } 996 + 997 + const owner = await this.readOwner() 998 + 999 + // We already hold the lock for this directory (e.g. watch-mode reruns). 1000 + if (owner?.pid === process.pid) { 1001 + return 1002 + } 1003 + 1004 + // Another running Vitest owns this directory. 1005 + if (owner && isProcessAlive(owner.pid)) { 1006 + throw this.inUseError(owner) 1007 + } 1008 + 1009 + // The lock was left behind by a process that no longer exists. Reclaim it. 1010 + await fs.rm(this.lockFile, { force: true }) 1011 + 1012 + if (!(await this.tryWrite())) { 1013 + throw this.inUseError(await this.readOwner()) 1014 + } 1015 + } 1016 + 1017 + async release(): Promise<void> { 1018 + const owner = await this.readOwner() 1019 + 1020 + if (owner?.pid === process.pid) { 1021 + await fs.rm(this.lockFile, { force: true }) 1022 + } 1023 + } 1024 + 1025 + private async tryWrite(): Promise<boolean> { 1026 + const payload = JSON.stringify({ pid: process.pid, reportsDirectory: this.reportsDirectory }) 1027 + 1028 + try { 1029 + // `wx` fails with EEXIST if the file already exists, so only one process wins. 1030 + await fs.writeFile(this.lockFile, payload, { flag: 'wx' }) 1031 + return true 1032 + } 1033 + catch (error) { 1034 + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { 1035 + return false 1036 + } 1037 + throw error 1038 + } 1039 + } 1040 + 1041 + private async readOwner(): Promise<LockOwner | null> { 1042 + try { 1043 + const owner = JSON.parse(await fs.readFile(this.lockFile, 'utf-8')) 1044 + return typeof owner?.pid === 'number' ? owner : null 1045 + } 1046 + catch { 1047 + return null 1048 + } 1049 + } 1050 + 1051 + private inUseError(owner: LockOwner | null): Error { 1052 + return new Error( 1053 + `The coverage report directory "${this.reportsDirectory}" is already in use by ` 1054 + + `another Vitest process${owner ? ` (pid ${owner.pid})` : ''}. Running coverage for multiple ` 1055 + + `Vitest processes in the same directory at the same time is not supported, because they would ` 1056 + + `delete each other's reports.\nGive each run its own "coverage.reportsDirectory" ` 1057 + + `(e.g. --coverage.reportsDirectory=coverage-${process.pid}) or run them sequentially.`, 1058 + ) 1059 + } 1060 + } 1061 + 1062 + function isProcessAlive(pid: number): boolean { 1063 + try { 1064 + // Sending signal 0 checks if the process exists without actually killing it: 1065 + // https://nodejs.org/api/process.html#processkillpid-signal 1066 + process.kill(pid, 0) 1067 + return true 1068 + } 1069 + catch (error) { 1070 + // ESRCH means the process is gone. Treat anything else (e.g. EPERM) as alive 1071 + // so we never reclaim a lock from a process that is still running. 1072 + return (error as NodeJS.ErrnoException).code !== 'ESRCH' 953 1073 } 954 1074 }