[Bug]: On Windows, parallel runs that share the transform cache fail to load a test file with EPERM
I maintainer di solito rispondono entro 1 giorno
@microsoft-playwright-automation ci sta già lavorando.
Dal 24/10/2026.
- #43616 di @microsoft-playwright-automation — aperta
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 58/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- node.js, typescript
- Ambito
- testing
Direzione di ricerca
Start in packages/playwright/src/transform/compilationCache.ts, especially addToCache and clearOldCacheEntries, which the report identifies as the cache write and deletion paths. Reproduce on Windows with the supplied race.mjs or traced loop, using concurrent processes and a shared PWTEST_CACHE_DIR. Done means concurrent shards can load and list tests even when a cache write fails; the report suggests checking that failure remains limited to the cache entry.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Version
1.64.1 (also seen on 1.63.0)
Steps to reproduce
On Windows, in an empty folder:
npm init -y
npm pkg set type=module
npm i -D @playwright/[email protected]
Add playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: 'tests' });
Save race.mjs (below) in the same folder and run node race.mjs 300. It writes 30 small specs, then 300 times starts four playwright test --list --shard=N/4 at once, with PWTEST_CACHE_DIR pointing all four at one fresh folder: the same as four shards sharing the default %TEMP%\playwright-transform-cache in a checkout with nothing compiled yet, without touching the real cache. Then it repeats each round with a folder per shard. No browser is needed: --list only loads the test files.
race.mjs
// Starts four `playwright test --list` shards at once over one cold transform
// cache, ROUNDS times, then the same with a cache folder per shard, and
// counts the rounds in which a shard failed to load its tests.
//
// node race.mjs [rounds] [project dir]
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const rounds = Number(process.argv[2] ?? 100);
const project = path.resolve(process.argv[3] ?? '.');
const cli = path.join(project, 'node_modules', '@playwright', 'test', 'cli.js');
// Thirty small specs, written once.
const tests = path.join(project, 'tests');
fs.mkdirSync(tests, { recursive: true });
for (let i = 1; i <= 30; i++) {
const file = path.join(tests, `spec${String(i).padStart(2, '0')}.spec.ts`);
if (!fs.existsSync(file)) {
fs.writeFileSync(
file,
`import { test, expect } from '@playwright/test';\n\n` +
`test('spec ${i}', async () => {\n expect(${i}).toBe(${i});\n});\n`,
);
}
}
function shard(index, cacheDir) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [cli, 'test', '--list', `--shard=${index}/4`], {
cwd: project,
env: { ...process.env, PWTEST_CACHE_DIR: cacheDir },
});
let output = '';
child.stdout.on('data', (chunk) => (output += chunk));
child.stderr.on('data', (chunk) => (output += chunk));
child.on('close', (code) => resolve({ index, code, output }));
});
}
const failedRounds = { shared: 0, separate: 0 };
for (let round = 1; round <= rounds; round++) {
for (const mode of ['shared', 'separate']) {
// A fresh folder each round, so every shard starts with nothing compiled,
// as on a fresh checkout.
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-cache-race-'));
const results = await Promise.all(
[1, 2, 3, 4].map((i) => shard(i, mode === 'shared' ? base : path.join(base, `shard-${i}`))),
);
const failed = results.filter((r) => r.code !== 0);
if (failed.length > 0) failedRounds[mode]++;
for (const r of failed) {
const lines = r.output.split('\n').filter((line) => /Error|^\s+at /.test(line));
console.log(`round ${round}, ${mode} cache, shard ${r.index}/4 exited ${r.code}:\n${lines.slice(0, 4).join('\n')}`);
}
fs.rmSync(base, { recursive: true, force: true });
}
}
console.log(`one cache folder shared by the four shards: ${failedRounds.shared} of ${rounds} rounds had a shard fail`);
console.log(`a cache folder per shard: ${failedRounds.separate} of ${rounds} rounds had a shard fail`);
Expected behavior
All four shards load their test files and list their tests, whatever the timing.
Actual behavior
Now and then one shard fails before listing any test:
Error: EPERM: operation not permitted, open 'C:\Users\<user>\AppData\Local\Temp\pw-cache-race-TVKOn1\ce\cec621953a_3900095_spec26spec.js'
at eval (<anonymous>:1:1)
On our real project (about 35 test files, 1.63.0) it failed in 10 of 300 rounds with one shared folder and in none of 300 with a folder per shard. The standalone repro above: on 1.64.1, 21 of 300 rounds had a shard fail with one shared folder, every one with EPERM on a .js or .map file in the cache, and none of 300 with a folder per shard.
On our project some failures read UNKNOWN: unknown error, open '…' instead of EPERM. When the file that fails is playwright.config.ts, the stack shows the write that failed (1.63.0):
Error: UNKNOWN: unknown error, open 'C:\Users\<user>\AppData\Local\Temp\…\ef\ef0cd41691_1b5f0ff_playwrightconfig.js'
at Object.writeFileSync (node:fs:2482:20)
at writeCodeCache (…\node_modules\playwright\lib\common\index.js:118:21)
at addToCache (…\node_modules\playwright\lib\common\index.js:163:7)
We hit it because our test harness starts four shards of one project at once on one Windows machine.
Additional context
addToCache in packages/playwright/src/transform/compilationCache.ts first calls clearOldCacheEntries, which rmSyncs every cached file for the same source path, including the entry another process has just written or is writing. Then it writes the source map and the code with writeFileSync, uncaught. On Windows, opening a file while another process is deleting it fails, so the writer throws, and the test file fails to load although its compiled code is already in memory.
To check who held the file, we called addToCache for one file over and over in four processes at once over one cache folder, and timed every rmSync and writeFileSync on one clock (process.hrtime.bigint()): every failed write ran while another process was deleting that same file. With one process, or a folder per process, no write failed. Windows Defender's real-time protection was on throughout.
The traced loop: four processes calling `addToCache` for one file, on 1.64.1
node hammer-traced.cjs <path to node_modules/playwright> <cache dir> 5000 <start time> <trace file> in four processes at once, with one cache dir and one start time a few seconds ahead, then node line-up.mjs trace-*.json:
write of .map by pid 40136: EPERM after 270 us; meanwhile on that file: pid 40660 deleting, pid 29512 deleting, pid 31032 deleting
write of .js by pid 40136: EPERM after 710 us; meanwhile on that file: pid 40660 deleting, pid 31032 writing, pid 31032 deleting
write of .js by pid 40136: EPERM after 289 us; meanwhile on that file: pid 29512 deleting
write of .js by pid 40660: EPERM after 175 us; meanwhile on that file: pid 29512 deleting
...
17984 deletes and writes traced across 4 processes. 9 writes failed, 9 of them while another process was deleting that same file. (118 deletes failed too; Playwright ignores those.)
The same loop untraced, with no second process: 16297 calls in 20 s, none failed. Four processes with a cache dir each: 8513 calls in 5 s, none failed.
hammer-traced.cjs
// Calls Playwright's own addToCache for one file over and over, recording when
// each delete and write of the cached files began and ended, on the machine's
// one clock, so a failed write can be lined up against what the other
// processes were doing to that file at that moment.
//
// node hammer-traced.cjs <playwright package dir> <cache dir> <milliseconds> <start at, epoch ms> <trace file>
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const [playwrightDir, cacheDir, duration, startAt, traceFile] = process.argv.slice(2);
process.env.PWTEST_CACHE_DIR = cacheDir;
const writeFileSync = fs.writeFileSync;
const events = [];
for (const op of ['rmSync', 'writeFileSync']) {
const original = fs[op];
fs[op] = function (file, ...rest) {
const start = process.hrtime.bigint();
let result = 'ok';
try {
return original.call(this, file, ...rest);
} catch (error) {
result = error.code;
throw error;
} finally {
events.push([op, String(file), String(start), String(process.hrtime.bigint()), result]);
}
};
}
const { cc } = require(path.join(playwrightDir, 'lib', 'common', 'index.js'));
const code = 'x'.repeat(40_000);
const map = { version: 3, mappings: 'A'.repeat(40_000) };
const contentHash = crypto.createHash('sha1').update('content').digest('hex');
const { addToCache } = cc.getFromCompilationCache('C:\\nowhere\\journey.spec.ts', contentHash, undefined);
while (Date.now() < Number(startAt));
const end = Date.now() + Number(duration);
while (Date.now() < end) {
try {
addToCache(code, map);
} catch {}
}
writeFileSync(traceFile, JSON.stringify({ pid: process.pid, events }));
line-up.mjs
// For each write that failed in the traces (the one failure Playwright does
// not swallow), what the other processes were doing to that same file while
// it ran.
//
// node line-up.mjs <trace file>...
import fs from 'node:fs';
const traces = process.argv.slice(2).map((f) => JSON.parse(fs.readFileSync(f, 'utf8')));
const all = traces.flatMap((t) =>
t.events.map(([op, file, start, end, result]) => ({ pid: t.pid, op, file, start: BigInt(start), end: BigInt(end), result })),
);
const failedWrites = all.filter((e) => e.op === 'writeFileSync' && e.result !== 'ok');
const failedDeletes = all.filter((e) => e.op === 'rmSync' && e.result !== 'ok');
let duringDelete = 0;
for (const f of failedWrites) {
const overlapping = all.filter(
(e) => e.pid !== f.pid && e.file === f.file && e.start <= f.end && e.end >= f.start,
);
if (overlapping.some((e) => e.op === 'rmSync')) duringDelete++;
const ext = f.file.slice(f.file.lastIndexOf('.'));
console.log(
`write of ${ext} by pid ${f.pid}: ${f.result} after ${(f.end - f.start) / 1000n} us; meanwhile on that file: ` +
(overlapping.map((e) => `pid ${e.pid} ${e.op === 'rmSync' ? 'deleting' : 'writing'}`).join(', ') ||
'nothing from another process'),
);
}
console.log(
`${all.length} deletes and writes traced across ${traces.length} processes. ` +
`${failedWrites.length} writes failed, ${duringDelete} of them while another process was deleting that same file. ` +
`(${failedDeletes.length} deletes failed too; Playwright ignores those.)`,
);
#42876 fixed the read side of this race in 1.62: a torn entry fails its hash check and counts as a miss. The write side is unchanged in 1.64.1, and on Windows it is not benign: the writer gets EPERM or UNKNOWN, and the run fails.
A fix could let a failed cache write cost only the cache entry: catch errors from the writes in addToCache, since the transformed code is returned either way. clearOldCacheEntries could also skip the entry about to be written. Writing to a temporary file and renaming it into place was tried in #26353 and failed on Windows' rename (#26769), so catching the failure looks like the way that holds on every platform.
Workaround: give each concurrent Playwright process its own PWTEST_CACHE_DIR, as Playwright's own test fixtures do.
Environment
System:
OS: Windows 11 Pro 10.0.26300 (NTFS)
Binaries:
Node: 24.22.1
npm: 11.12.1
npmPackages:
@playwright/test: 1.64.1 => 1.64.1
- Lingua principale
- TypeScript
- Stelle
- 98k
- Fork
- 6.6k
- Merge medio
- 15h 59m
- PR unite (30g)
- 296
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di microsoft/playwright
-
[Bug]: `playwright install` hangs silently for ~8 minutes when PLAYWRIGHT_BROWSERS_PATH is not writableForse già presa @dcrousso l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
microsoft/playwright#43606 ·
I maintainer di solito rispondono entro 1 giorno
-
[Feature]: Set `this.name` in `TargetClosedError` constructorForse di nuovo libera @LeonxLJX l’ha presa 50 giorni fa e non c’è nessuna pull request aperta. ApertaP3-collecting-feedback
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 86/100
microsoft/playwright#42522 · 7 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: addLocatorHandler() appears to intercept its own action during expect().toPass() retriesForse già presa @dcrousso l’ha presa 1 giorno fa. Apertaneeds-triage-mac
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
microsoft/playwright#43607 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 42/100
microsoft/playwright#43602 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 42/100
microsoft/playwright#43599 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di microsoft/playwright
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
awesome-dsh-plugin/awesome-dsh-plugin#9184 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
farion1231/cc-switch#8829 ·
I maintainer di solito rispondono entro 1 giorno
-
[core-client] Remove unused hand-maintained transport helpers after the definition-table cutoverApertadoc-audit enhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100