@@ -5,8 +5,10 @@ import type { CoverageModuleLoader, CoverageOptions, CoverageProvider, ReportCon
55import type { SerializedCoverageConfig } from '../runtime/config'
66import type { AfterSuiteRunMeta } from '../types/general'
77import type { TestProject } from './project'
8+ import { createHash } from 'node:crypto'
89import { existsSync , promises as fs , readdirSync , writeFileSync } from 'node:fs'
910import module from 'node:module'
11+ import { tmpdir } from 'node:os'
1012import path from 'node:path'
1113import { fileURLToPath } from 'node:url'
1214import { 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+ }
0 commit comments