Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

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

Closed
#43,601 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@RuhaaBhalerao is already working on this.

Since Oct 23, 2026.

  • #43616 by @microsoft-playwright-automation — closed without merging

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
58/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
node.js, typescript
Domain
testing

Research direction

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.

Written by the indexing model from the issue text.

Description

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
Dominant language
TypeScript
Stars
98.5k
Forks
6.7k
Avg merge
15h 43m
Merged PRs (30d)
279

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/playwright

All issues in microsoft/playwright

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.