Skip to content

[Bug]: UI mode fails with spawn EBADF on macOS when the test directory contains more than ~10k files #43653

Closed

Description

@chekrd

Version

1.64.1 (also reproduced on 1.63.0)

Steps to reproduce

  1. Create a project with this playwright.config.ts:

    import { defineConfig } from '@playwright/test';
    
    export default defineConfig({
      testDir: './',
      testMatch: '**/*.spec.ts',
    });
  2. Add one trivial test, example.spec.ts:

    import { expect, test } from '@playwright/test';
    
    test('passes', () => {
      expect(1 + 1).toBe(2);
    });
  3. Put 11,000 plain files under testDir:

    mkdir src && for i in $(seq 0 10999); do echo "export const v$i = $i;" > src/file-$i.ts; done
  4. Run npx playwright test --ui and click Run all.

Expected behavior

The test runs and passes, as it does with npx playwright test without --ui.

Actual behavior

No test runs (0/0). The output pane shows:

Error: spawn EBADF
Error: spawn EBADF

The full stack, from a real project on 1.63.0:

Error: spawn EBADF
    at ChildProcess.spawn (node:internal/child_process:486:11)
    at spawn (node:child_process:860:9)
    at Object.fork (node:child_process:177:10)
    at WorkerHost.startRunner (node_modules/playwright/lib/runner/index.js:1915:49)
    at WorkerHost.start (node_modules/playwright/lib/runner/index.js:5473:23)
    at Dispatcher._runJobInWorker (node_modules/playwright/lib/runner/index.js:5600:20) {
  errno: -9,
  code: 'EBADF',
  syscall: 'spawn'
}

With 9,000 files instead of 11,000, the same project passes in UI mode.

Additional context

Cause. In UI mode, FSWatcher watches the project testDir (plus test dependencies) with the bundled chokidar. On macOS this keeps one open file descriptor per watched file. In our real project the UI-mode process held 10,658 open descriptors, about 10,600 of them regular files under testDir.

When a process holds more than macOS OPEN_MAX (10240) descriptors, the new stdio pipes for the worker get fd numbers above that limit. fork() with piped stdio then fails with EBADF. Raising ulimit -n doesn't help; ours is 1048576.

The same failure without Playwright:

// ebadf.cjs — node ebadf.cjs x 10200 → "ok"; node ebadf.cjs x 10300 → "EBADF"
const fs = require('fs');
const { fork } = require('child_process');

if (process.argv[2] === 'child') process.exit(0);

const n = Number(process.argv[3]);
for (let i = 0; i < n; i++) fs.openSync(__filename, 'r');
try {
  const child = fork(__filename, ['child'], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
  child.on('exit', () => console.log(n, 'ok'));
} catch (e) {
  console.log(n, e.code);
}

So any project with more than ~10k files under testDir cannot run tests in UI mode on macOS. Non-UI runs are not affected because they don't start the watcher.

Possible fixes.

  • Use a watcher that does not hold one descriptor per file on macOS, such as FSEvents or recursive fs.watch.
  • Watch only the test files and their dependencies, not every file under testDir.
  • Let users exclude paths from the UI-mode watcher.

Workaround. Narrow testDir so it contains fewer than ~10k files.

Environment

System:
  OS: macOS 27.0.1 (26A434), arm64
Binaries:
  Node: 26.12.1
npmPackages:
  @playwright/test: 1.64.1 (also 1.63.0)

Activity

  1. github-actions commented on Oct 27, 2026

    @github-actions
  2. github-actions commented on Oct 27, 2026

    @github-actions
    Contributor

    Hi, I'm the Playwright bot and I reproduced this on macOS.

    Reproduced on current main, Playwright 1.65.0-next at c409f8c83ec04485e829809beceb722d9cf8ff38, with macOS 15.7.9 and Node.js 24.20.0. UI mode fails with spawn EBADF at 11,000 ordinary files; the same UI test passes at 9,000 files. An ordinary, non-UI run with 11,000 files also passes.

    The cause matches your report: the bundled chokidar 4.0.3 watches individual files throughout testDir, including files that are not tests or dependencies. This can prevent Node from creating a worker with piped stdio.

    Narrowing testDir remains a workaround. I also verified that CHOKIDAR_USEPOLLING=1 npx playwright test --ui avoids this failure, but polling can increase CPU and filesystem activity; this is a chokidar setting, not a Playwright configuration option.

    Reproduction and checks

    I extended the existing should watch files test in tests/playwright-test/ui-mode-test-watch.spec.ts, adding this entry to its runUITest({...}) file map:

    ...Object.fromEntries(Array.from({ length: 11000 }, (_, i) =>
      [`src/file-${i}.ts`, `export const value = ${i};`])),

    The existing test runs a test, edits it, and checks that watching reruns it successfully.

    Check Result
    UI test with 11,000 non-test files, default watching Fails; spawn EBADF in WorkerHost.startRunner on the initial attempt and first retry; later retries fail to update the test list
    Same UI test with 9,000 files Passes
    Same UI test with 11,000 files and CHOKIDAR_USEPOLLING=1 Passes
    Ordinary CLI run, one passing test and 11,000 non-test files Passes
    Separate chokidar probe, 9,000 files, then fork with piped stdio Child exits successfully
    Same probe with 11,000 files Fork throws EBADF
    Native recursive fs.watch probe with 11,000 files Child exits successfully, ordinary file edits detected; edits inside a symlinked directory not detected

    Commands for the existing UI test:

    npm run ttest -- ui-mode-test-watch.spec.ts --grep 'should watch files$' --workers=1 --retries=0
    CI= npm run ttest -- ui-mode-test-watch.spec.ts --grep 'should watch files$' --workers=1
    CHOKIDAR_USEPOLLING=1 CI= npm run ttest -- ui-mode-test-watch.spec.ts --grep 'should watch files$' --workers=1

    The first command still made three retries because this spec sets retries in test.describe.configure. The controls cleared CI to disable those retries. UI checks used Chromium, headless. No Firefox, WebKit, Linux, Windows, or published-version checks were needed to establish this live macOS process-spawning failure, and none were run.

    Fix direction

    No fix is proposed in this run. Native recursive directory watching avoids the descriptor problem, but a direct replacement loses chokidar's symlink behavior in the probe above. A replacement needs to preserve dependency watching, discovery of new tests, ignored directories, and file replacement/deletion behavior. Enabling polling by default instead changes filesystem/CPU costs for every macOS UI session. This needs a watcher strategy decision rather than a guard around the failed spawn.

    Triaged on macOS by the Playwright bot - agent run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions