Repository navigation
fix(runner): replace chokidar with native fs.watch in the test watcher - #43716
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
🔴 CI failures are caused by this PRHi, I'm the Playwright bot. I checked the latest CI report and compared the old and new watchers. DetailsAt Caused by this PR — new test setup (2 failures)
Create those files in bounded batches while keeping the 11,000-file coverage. These errors do not establish that the replacement watcher itself exhausts file descriptors. Caused by this PR — VSCode directory watching (18 failures)
I reproduced the difference with the exact base ( The refreshed CI results database contains the new test only on this PR and has no VSCode results. Neither group is being classified as a pre-existing flake; the verdict rests on the CI errors and the watcher comparison. Triaged by the Playwright bot - agent run |
Dmitry Gozman (dgozman)
left a comment
There was a problem hiding this comment.
There are failures on the watch mode tests, let's fix them.
The VS Code extension passes the test dir when watching all tests. Also remove the test with 11000 files.
Test results for "tests 1"23597 passed, 707 skipped Merge workflow run. |
Test results for "MCP"5194 passed, 616 skipped Merge workflow run. |
Summary
FSWatcherinpackages/playwright/src/runner/fsWatcher.tsnow usesfs.watchinstead of chokidar.testDiris watched with one recursivefs.watchhandle.testDiris watched with one non-recursivefs.watchhandle.node_modules, project output folders and symlinked directories are not entered.testDirare watched through their parent directory, so atomic saves (write to a temp file, then rename) keep working.watchrequest are watched recursively. The VS Code extension passes thetestDirwhen watching all tests, and a folder when watching a folder.playwrightpackage no longer imports chokidar. chokidar stays inplaywright-corefor the server registry and in the build scripts.Fixes #43653
Why
UI mode calls
initialize({ watchTestDirs: true })so the test tree refreshes when test files are added, changed or removed. The runner watches every projecttestDirfrom the start, before the user enables the watch toggle. Since #42238 (1.63) we ship chokidar 4, which dropped FSEvents support. Chokidar 4 callsfs.watchonce per file and once per directory. On macOS a non-recursivefs.watchis a kqueue watch and needs one open descriptor per watched path. AtestDirwith more than about 10k files pushes the process pastOPEN_MAX(10240), andfork()for the next worker fails withspawn EBADF.Chokidar's
ignoredoption does not help here: it only prunes the tree, and every file that is not ignored still gets its own watch.chokidar vs fs.watch
What the chokidar README lists as its advantages over raw
fs.watch, and whether we use them:add/change/unlinkinstead ofrenamefs.watchon macOS uses FSEvents and reports filenames. Duplicates are already handled by our 250 ms batching and the consumer re-lists tests anyway.atomicoption for editors that write a temp file and renameawaitWriteFinishfor chunked writesserverRegistry.tsuses it, and it stays on chokidar.ignoredfs.watch({ recursive: true })is supported on Linux, macOS and Windows.Pros and cons of fs.watch
Pros:
testDirinstead of one descriptor per file. Fixes the issue.ReadDirectoryChangesWhandle pertestDirinstead of one handle per file and directory.fs.inotify.max_user_watches.playwrightpackage. The watcher is about the same size as before.Cons:
fs.watch({ recursive: true }). See below.testDirare not entered. This matches the loader..gitand other non-test folders insidetestDirarrive and are dropped by path in our code instead of being pruned up front. This was already the case with chokidar's per-file watches.filenamecan benullin rare cases. We then report the watched root, which makes the UI re-list tests.Measurements on macOS
Apple M5 Pro, macOS 26.7.1, Node.js 26.7.0. One
testDirwitha.test.tsand N files insrc/dir-K/folders of 100 files each. The oldFSWatcher(chokidar 4, as onmain) and the new one (fs.watch) run in a fresh process each, watching thetestDirthe way the runner does. Latency is the time fromwriteFileSyncto the runner callback and includes the 250 ms batching that both have, so raw event delivery is about 2 ms for chokidar and about 13 ms for FSEvents. Descriptors are counted in/dev/fd. Each cell is the median of 5 changes to a top-level file; changes to a file deep in the tree and new files were within 15 ms of these.Node raises the soft descriptor limit to the hard limit at startup, so what matters is the hard limit. The macOS default is
OPEN_MAX= 10240.The chokidar rows above 10,000 files were measured with the hard limit raised to 1,048,576. With the default hard limit of 10240, chokidar on 20,000 files prints
(libuv) kqueue(): Too many open filesand the process can no longer open stdout (Error: open EMFILE). This is the failure from #43653, where it surfaced asspawn EBADFfor the next worker. The new watcher stays at 12 descriptors at every size, because FSEvents does not use a descriptor per path.A change made right after
update()returned, before the watcher had time to settle, was reported by both watchers in every run, so the asynchronous start of the FSEvents stream did not drop events here.Linux is not covered by these numbers. There the new watcher uses one inotify watch per directory, see below.
Why Linux gets its own walker
Linux inotify has no "watch the subtree" flag. A watch covers one directory and reports events for its direct entries only. libuv only wraps what the kernel offers:
uv_fs_event_startinsrc/unix/linux.cignores theflagsargument, and the libuv docs state thatUV_FS_EVENT_RECURSIVEis supported only on macOS and Windows. Up to Node 18,fs.watchwithrecursive: truethrewERR_FEATURE_UNAVAILABLE_ON_PLATFORMon Linux.Node 19.1 made the call succeed by adding a JavaScript fallback,
lib/internal/fs/recursive_watch.js, which is unchanged from Node 20 through 26. It reads the tree withreaddirSyncand, for every entry, callsstatSyncand a non-recursivefs.watch. That is one inotify watch per file and one per directory, and nothing is skipped. Theignoreoption that prunes subtrees arrived in Node 26, and we support Node 20.So on Linux the Node fallback has the same cost as chokidar 4, and is worse when
testDircontainsnode_modules, which chokidar pruned. For this repository that would mean about 69,000 watches andstatcalls at startup, against afs.inotify.max_user_watchesbudget that is 8,192 on older kernels and 65,536 or 524,288 on recent distributions. Running out raisesENOSPCand the watch for that root is lost.An inotify watch on a directory already reports creation, modification, deletion and rename of its direct children with the child's name, and the runner only needs the path. So on Linux we read the tree once, watch each directory, skip
node_modules, output folders and symlinked directories, add a watch when an event names a new directory, and close the watches under a directory that disappeared. For this repository that is about 6,700 watches. Once Node 26 is the minimum, the walker can be replaced byfs.watch({ recursive: true, ignore }).Symlink support history
collectFilesinprojectUtils.ts) reads directories withwithFileTypesand keeps an entry only whenisFile()orisDirectory()is true. A symlink answers false to both, so symlinked test files and symlinked directories are skipped.Since the loader does not follow symlinks, the watcher not following them is consistent, not a regression. If file symlinks are ever supported by the loader, the dependency tracking already gives the watcher the real target paths of imported files.