#23470·Vite

Dev server crashes the whole process when the file watcher emits an error (uncaught 'error' event, e.g. EBUSY on Windows)

Author: MaxenceMouchardCreated Sep 11, 2026Updated Sep 14, 2026
Labelspending triage

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

  1. Open the reproduction link, add repro.mjs, run node repro.mjs: the process dies on an uncaught error event instead of printing survived.
  2. 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