Dev server crashes the whole process when the file watcher emits an error (uncaught 'error' event, e.g. EBUSY on Windows)
Describe the bug
createServer() subscribes to change, add and unlink on the chokidar watcher, but never to error:
// packages/vite/src/node/server/index.ts
const watcher = serverConfig.watch !== null ? chokidar.watch([...], resolvedWatchOptions) : createNoopWatcher(...)
...
watcher.on('change', ...)
watcher.on('add', ...)
watcher.on('unlink', ...)
chokidar reports per-path failures by emitting error on the FSWatcher. With no listener, Node's EventEmitter contract turns it into an uncaught exception and the dev server exits immediately. A single unwatchable file anywhere under the project root kills vite dev — no message, no recovery. The file does not have to be in the module graph: chokidar walks the whole root and, on Windows, installs an individual fs.watch on every file it finds.
Real-world trigger: an ASP.NET project where the Vite root also contains the .NET bin/ output. The backend writes a freshly compiled assembly and still holds the handle for a moment; chokidar reacts to the add event, calls fs.watch() on the locked file, gets EBUSY, and the server dies:
node:internal/fs/watchers:323
const error = new UVException({
^
Error: EBUSY: resource busy or locked, watch '.../Release/Some.Entities.dll'
at Object.watch (node:fs:2608:36)
at createFsWatchInstance (.../vite/dist/node/chunks/node.js:10735:16)
at setFsWatchListener (.../vite/dist/node/chunks/node.js:10777:14)
at NodeFsHandler._watchWithNodeFs (.../vite/dist/node/chunks/node.js:10892:20)
at NodeFsHandler._handleFile (.../vite/dist/node/chunks/node.js:10936:24)
Emitted 'error' event on FSWatcher instance at:
at FSWatcher._handleError (.../vite/dist/node/chunks/node.js:11912:146)
errno: -4082, syscall: 'watch', code: 'EBUSY'
EBUSY on a briefly locked file became much easier to hit on Node 24.16.0+, which bumped libuv to 1.52.x (uv_fs_event_start() now opens the target file with read-data access, see libuv#4948). The Vite-side defect is independent of that: any watcher error (EPERM, ENOSPC, an exhausted inotify budget) has always been fatal the same way.
Reproduction
https://stackblitz.com/edit/vitejs-vite-aadpixhz?file=repro.mjs
Steps to reproduce
- Open the reproduction link, add
repro.mjs, runnode repro.mjs: the process dies on an uncaughterrorevent instead of printingsurvived. - On Windows with Node 24.16.0+, the second script reproduces the same crash from a genuine chokidar failure, without emitting anything by hand.
The missing handler is platform-independent, so it shows up in a plain Vite starter (link above). Add repro.mjs at the root, then run node repro.mjs:
import { createServer } from 'vite'
const server = await createServer({ configFile: false, server: { port: 0 } })
console.log('listeners on "error":', server.watcher.listenerCount('error')) // 0
console.log('listeners on "change":', server.watcher.listenerCount('change')) // 1
// exactly what chokidar does in FSWatcher._handleError when a path cannot be watched
server.watcher.emit('error', Object.assign(new Error('watch failed'), { code: 'EBUSY' }))
console.log('survived') // never reached
await server.close()
listenerCount('error') === 0 is the defect itself; the emit() only shows the consequence.
The real-world trigger needs a Windows host — the EBUSY comes from uv_fs_event_start() on win32, so it cannot happen in WebContainer. Run with Node 24.16.0 or newer:
import { createServer } from 'vite'
import fs from 'node:fs'
import { spawn } from 'node:child_process'
const root = 'C:/tmp/fixture' // index.html + src/main.js + an empty sub/dir/
const target = root + '/sub/dir/locked.dll'
fs.rmSync(target, { force: true })
const server = await createServer({ root, configFile: false, server: { port: 0 } })
await new Promise(r => setTimeout(r, 2000))
// another process creates the file and holds it with no sharing, like a compiler would
spawn('powershell.exe', ['-NoProfile', '-Command',
`$f=[System.IO.File]::Open('${target}','Create','ReadWrite','None'); Start-Sleep -Seconds 8; $f.Close()`])
await new Promise(r => setTimeout(r, 12000))
console.log('survived') // never reached
Suggested fix
Attach an error handler and downgrade it to a warning, so one unwatchable path degrades HMR for that path instead of terminating the server:
watcher.on('error', (e) => {
config.logger.warn(colors.yellow(`file watcher error: ${e.message}`))
})
System Info
- Vite: reproduced on 8.3.0, 7.3.1 and 7.1.9 (identical watcher setup in all three)
- Node: 24.21.0 (libuv 1.52.1); the Windows trigger does not fire on 22.23.2 or 24.15.0 (libuv 1.51.0)
- OS: Windows 11 Enterprise 26200 x64
Used Package Manager
npm
Logs
No response
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Make sure this is a Vite issue and not a framework-specific issue. For example, if it's a Vue SFC related bug, it should likely be reported to vuejs/core instead.
- Check that this is a concrete bug. For Q&A open a GitHub Discussion or join our Discord Chat Server.
- The provided reproduction is a minimal reproducible example of the bug.
Source: vitejs/vite