Skip to content

Commit 833f093

Browse files
fix(coverage): fail fast when coverage.reportsDirectory conflicts between concurrent runs (#10466)
Co-authored-by: Ari Perkkiö <[email protected]>
1 parent c409014 commit 833f093

2 files changed

Lines changed: 226 additions & 7 deletions

File tree

packages/vitest/src/node/coverage.ts

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import type { CoverageModuleLoader, CoverageOptions, CoverageProvider, ReportCon
55
import type { SerializedCoverageConfig } from '../runtime/config'
66
import type { AfterSuiteRunMeta } from '../types/general'
77
import type { TestProject } from './project'
8+
import { createHash } from 'node:crypto'
89
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
910
import module from 'node:module'
11+
import { tmpdir } from 'node:os'
1012
import path from 'node:path'
1113
import { fileURLToPath } from 'node:url'
1214
import { cleanUrl, slash } from '@vitest/utils/helpers'
@@ -89,6 +91,7 @@ export class BaseCoverageProvider {
8991
coverageFiles: CoverageFiles = new Map()
9092
pendingPromises: Promise<void>[] = []
9193
coverageFilesDirectory!: string
94+
reportsDirectoryLock!: ReportsDirectoryLock
9295
roots: string[] = []
9396
changedFiles?: string[]
9497

@@ -140,6 +143,7 @@ export class BaseCoverageProvider {
140143
this.options.reportsDirectory,
141144
tempDirectory,
142145
)
146+
this.reportsDirectoryLock = new ReportsDirectoryLock(resolve(this.options.reportsDirectory))
143147

144148
// If --project filter is set pick only roots of resolved projects
145149
this.roots = ctx.config.project?.length
@@ -245,6 +249,8 @@ export class BaseCoverageProvider {
245249
}
246250

247251
async clean(clean = true): Promise<void> {
252+
await this.reportsDirectoryLock.acquire()
253+
248254
if (clean && existsSync(this.options.reportsDirectory)) {
249255
await fs.rm(this.options.reportsDirectory, {
250256
recursive: true,
@@ -354,12 +360,17 @@ export class BaseCoverageProvider {
354360
}
355361

356362
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 })
359366

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()
363374
}
364375
}
365376

@@ -952,3 +963,112 @@ function resolveMergeConfig(mod: any): any {
952963
}
953964
}
954965
}
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'
1073+
}
1074+
}

test/coverage-test/test/temporary-files.unit.test.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
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'
36
import { BaseCoverageProvider } from 'vitest/node'
47

58
test('missing coverage temp directory throws an actionable error', async () => {
@@ -17,3 +20,99 @@ test('missing coverage temp directory throws an actionable error', async () => {
1720
`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.`,
1821
)
1922
})
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+
}

0 commit comments

Comments
 (0)