Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

[Bug]: On Windows, parallel runs that share the transform cache fail to load a test file with EPERM

Aperta
#43,601 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub

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

needs-triage-win
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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di microsoft/playwright

Tutte le issue di microsoft/playwright

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.