Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-cache-race-condition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@srsholmes/vitest-react-native': patch
---

Fix race condition in transform cache under parallel Vitest workers (#23).

- Atomic cache writes via tmp+rename so concurrent readers never see partial bytes.
- Cache key now includes a content hash of `setup.ts`, so editing the plugin (e.g. adding a new mock) invalidates the cache automatically.
- Removed the destructive end-of-setup cache wipe that ran in every worker — the new content-hashed key makes it unnecessary, and removing it eliminates the worst cross-worker `unlink`-vs-read race.
37 changes: 37 additions & 0 deletions packages/vitest-react-native/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import crypto from 'node:crypto';
import fs from 'fs';

// Single-syscall read — returns null on ENOENT. Collapses the old
// existsSync+readFileSync TOCTOU into one atomic operation, so a concurrent
// unlink between the check and the read can no longer surface as a thrown
// ENOENT.
export const readFromCache = (cachePath: string): string | null => {
try {
return fs.readFileSync(cachePath, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw e;
}
};

// Atomic write via tmp+rename. POSIX rename is atomic on the same filesystem,
// so concurrent readers either see the old contents (or ENOENT) or the new
// full contents — never a partial write. The unique tmp suffix (pid + random
// bytes) prevents two workers from clobbering each other's tmp file when both
// are writing the same cache key.
export const writeToCache = (cachePath: string, code: string): void => {
const tmp = `${cachePath}.${process.pid}.${crypto
.randomBytes(4)
.toString('hex')}.tmp`;
fs.writeFileSync(tmp, code);
try {
fs.renameSync(tmp, cachePath);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw e;
}
};
66 changes: 34 additions & 32 deletions packages/vitest-react-native/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,37 +67,62 @@ g.performance = globalThis.performance || { now: Date.now };
import { addHook } from 'pirates';
import removeTypes from 'flow-remove-types';
import * as esbuild from 'esbuild';
import crypto from 'node:crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'node:url';
import { readFromCache, writeToCache } from './cache.js';

const require = createRequire(import.meta.url);

// ============================================================================
// STEP 3: Set up cache directory for transformed files
// ============================================================================

let reactNativeVersion = '0.83.0';
let pluginVersion = '0.2.0';
let reactNativeVersion = 'unknown';
let pluginVersion = 'unknown';

try {
const reactNativePkg = require('react-native/package.json');
reactNativeVersion = reactNativePkg.version;
} catch {
// Use default version
// Keep "unknown" fallback — only affects the cache dir name.
}

try {
const pluginPkg = require('./package.json');
// setup.ts lives at <pkg>/src/setup.ts in development and at <pkg>/dist/setup.{js,cjs}
// when installed. Both are exactly one directory below the package root, so walking
// up once and reading package.json works in both contexts.
const here = path.dirname(fileURLToPath(import.meta.url));
const pluginPkg = JSON.parse(
fs.readFileSync(path.join(here, '..', 'package.json'), 'utf-8'),
);
pluginVersion = pluginPkg.version;
} catch {
// Use default version
// Keep "unknown" fallback — only affects the cache dir name.
}

// Hash this file's bytes into the cache key so any edit to setup.ts (e.g. adding
// a new mock) invalidates the cache automatically — no destructive end-of-file
// wipe needed, which removes a major race window when many workers run in
// parallel.
let setupHash = 'nohash';
try {
const bundlePath = fileURLToPath(import.meta.url);
setupHash = crypto
.createHash('sha1')
.update(fs.readFileSync(bundlePath))
.digest('hex')
.slice(0, 12);
} catch {
// keep nohash fallback
}

const tmpDir = os.tmpdir();
const cacheDirBase = path.join(tmpDir, 'vrn');
const version = `${reactNativeVersion}_${pluginVersion}`;
const version = `${reactNativeVersion}_${pluginVersion}_${setupHash}`;
const cacheDir = path.join(cacheDirBase, version);

if (!fs.existsSync(cacheDir)) {
Expand Down Expand Up @@ -151,10 +176,6 @@ const transformCode = (code: string): string => {

const normalize = (p: string): string => p.replace(/\\/g, '/');

const cacheExists = (cachePath: string): boolean => fs.existsSync(cachePath);
const readFromCache = (cachePath: string): string => fs.readFileSync(cachePath, 'utf-8');
const writeToCache = (cachePath: string, code: string): void => fs.writeFileSync(cachePath, code);

// Process binary files (images) as base64
addHook(
(code) => {
Expand All @@ -175,9 +196,8 @@ const processReactNative = (code: string, filename: string): string => {
const cacheName = normalize(path.relative(root, filename)).replace(/\//g, '_');
const cachePath = path.join(cacheDir, cacheName);

if (cacheExists(cachePath)) {
return readFromCache(cachePath);
}
const cached = readFromCache(cachePath);
if (cached !== null) return cached;

const mock = getMocked(filename);
if (mock) {
Expand Down Expand Up @@ -1792,25 +1812,7 @@ mock(
);

// ============================================================================
// STEP 9: Clear cache to ensure fresh mocks are used
// ============================================================================

// Clear the cache directory to force re-transformation with new mocks
try {
const files = fs.readdirSync(cacheDir);
files.forEach((file) => {
try {
fs.unlinkSync(path.join(cacheDir, file));
} catch {
/* ignore */
}
});
} catch {
/* ignore */
}

// ============================================================================
// STEP 10: Configure React Native Testing Library
// STEP 9: Configure React Native Testing Library
// ============================================================================

// Configure RNTL to recognize our mocked host components
Expand Down
97 changes: 97 additions & 0 deletions test/__tests__/Cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import { readFromCache, writeToCache } from '../../packages/vitest-react-native/src/cache.js';

let tmpRoot: string;

beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vrn-cache-test-'));
});

afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});

describe('cache primitives', () => {
it('readFromCache returns null when the file does not exist', () => {
const target = path.join(tmpRoot, 'missing.js');
expect(readFromCache(target)).toBeNull();
});

it('writeToCache + readFromCache round-trips exact bytes', () => {
const target = path.join(tmpRoot, 'round-trip.js');
const payload = 'module.exports = { hello: "world" };\n';
writeToCache(target, payload);
expect(readFromCache(target)).toBe(payload);
});

it('writeToCache leaves no .tmp file behind on success', () => {
const target = path.join(tmpRoot, 'no-leftover.js');
writeToCache(target, 'ok');
const leftovers = fs
.readdirSync(tmpRoot)
.filter((name) => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});

it('readFromCache rethrows non-ENOENT errors', () => {
// EISDIR — read a directory as if it were a file.
expect(() => readFromCache(tmpRoot)).toThrow();
});

it('many concurrent writes to distinct paths all succeed without partial reads', async () => {
const writers = Array.from({ length: 32 }, (_, i) => i);
const payload = (i: number): string => `// payload ${i}\n${'x'.repeat(8 * 1024)}\n// end ${i}\n`;

await Promise.all(
writers.map(
(i) =>
new Promise<void>((resolve) => {
writeToCache(path.join(tmpRoot, `w-${i}.js`), payload(i));
resolve();
}),
),
);

for (const i of writers) {
const got = readFromCache(path.join(tmpRoot, `w-${i}.js`));
expect(got).toBe(payload(i));
}

const leftovers = fs
.readdirSync(tmpRoot)
.filter((name) => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});

it('many concurrent writes to the same path always produce one valid full payload', async () => {
// The race we are protecting against: two workers transform the same
// module simultaneously. Both writes must complete with the file ending
// up as one of the valid full payloads (not partial, not interleaved).
const target = path.join(tmpRoot, 'contended.js');
const payloads = Array.from({ length: 16 }, (_, i) =>
`// variant ${i}\n${'y'.repeat(4 * 1024)}\n// end ${i}\n`,
);

await Promise.all(
payloads.map(
(p) =>
new Promise<void>((resolve) => {
writeToCache(target, p);
resolve();
}),
),
);

const final = readFromCache(target);
expect(payloads).toContain(final);

const leftovers = fs
.readdirSync(tmpRoot)
.filter((name) => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});
});
Loading