diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 3fef387b6add..2c82b5815f7e 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -3,8 +3,8 @@ import type { RuntimeRPC } from '../../types/rpc' import type { TestProject } from '../project' import type { ResolveSnapshotPathHandlerContext } from '../types/config' import { mkdirSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' -import { join } from 'pathe' +import { rename, stat, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'pathe' import { hash } from '../hash' const created = new Set() @@ -65,7 +65,11 @@ export function createMethodsRPC(project: TestProject, options: MethodsOptions = } promises.set( tmp, - writeFile(tmp, code, 'utf-8').finally(() => promises.delete(tmp)), + + atomicWriteFile(tmp, code) + // Fallback to non-atomic write for windows case where file already exists: + .catch(() => writeFile(tmp, code, 'utf-8')) + .finally(() => promises.delete(tmp)), ) await promises.get(tmp) Object.assign(result, { id: tmp }) @@ -146,3 +150,35 @@ function handleRollupError(e: unknown): never { } throw e } + +/** + * Performs an atomic write operation using the write-then-rename pattern. + * + * Why we need this: + * - Ensures file integrity by never leaving partially written files on disk + * - Prevents other processes from reading incomplete data during writes + * - Particularly important for test files where incomplete writes could cause test failures + * + * The implementation writes to a temporary file first, then renames it to the target path. + * This rename operation is atomic on most filesystems (including POSIX-compliant ones), + * guaranteeing that other processes will only ever see the complete file. + * + * Added in https://github.com/vitest-dev/vitest/pull/7531 + */ +async function atomicWriteFile(realFilePath: string, data: string): Promise { + const dir = dirname(realFilePath) + const tmpFilePath = join(dir, `.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`) + + try { + await writeFile(tmpFilePath, data, 'utf-8') + await rename(tmpFilePath, realFilePath) + } + finally { + try { + if (await stat(tmpFilePath)) { + await unlink(tmpFilePath) + } + } + catch {} + } +}