chokidar: Deleting an empty directory triggers `error` instead of `unlinkDir`

When deleting an empty directory, I get an unexpected error event. If the same directory has any kind of content (other than empty directories), the expected unlinkDir event is triggered without an error.

  • OS: Windows 10 Pro
  • node: 4.3.1
  • npm: 3.7.3
  • I’m using glob-watcher (3.0.0), which gives me a chokidar (1.6.1) instance.

The console output:

events.js:141
      throw er; // Unhandled 'error' event
      ^

Error: watch null EPERM
    at exports._errnoException (util.js:870:11)
    at FSEvent.FSWatcher._handle.onchange (fs.js:1217:21)

The directory is deleted by selecting it in Windows Explorer and pressing delete.

Is this a bug? or the expected behavior I need to account for with the error event?

About this issue

  • Original URL
  • State: open
  • Created 8 years ago
  • Reactions: 7
  • Comments: 31

Most upvoted comments

Unexpectedly, this problem has existed for three years.

I notice that just adding this:

    watcher.on('error', error => {
        // Ignore EPERM errors in windows, which happen if you delete watched folders...
        if (error.code === 'EPERM' && require('os').platform() === 'win32') return 
    })

Makes the watch work as expected… Does anyone have any insight into this? Isn’t this kind of a big issue? I feel like there is a fix and I just don’t know about it.

I haven’t looked at this issue in almost 7 years, but I still get notifications about it now and then. Curious, I decided to have a look today. Here are a few thoughts from me. Take them or leave them as you will, and with a heavy grain of salt as I have not worked on this since my previous comment.

I believe that it is not something Chokidar can reasonably solve since it happens in the Node.js API.

Curious? Expand the details for more.

I would like to remind new visitors about this comment: https://github.com/paulmillr/chokidar/issues/566#issuecomment-294010960

Specifically:

  • The exact same error occurs when you remove Chokidar entirely from the picture
    var fs = require('fs');
    fs.watch('local_packages/New folder', { persistent: true}, function(event, filename){
        console.log(arguments);
    });
    

That snippet is the entire file. Name it example.js and execute it in the command line with node example.js. Mess with the file system using the windows file explorer or by executing another node file.

You can’t make it any smaller than that.

Who knows, maybe it is specific to sending the directory to the windows recycling bin. Could node solve it or at least identify it separately from other errors? Is it a problem with the Windows file system API that Node itself uses? Is it an issue with V8? I don’t know.


Back then, it was Node.js 4.x. We are now up to 20.x. The problem may still exist, but something has almost certainly changed in all this time.

If you are curious, why not have a look at Node’s code? https://github.com/nodejs/node/blob/v20.5.0/lib/internal/fs/watchers.js#L304-L378

Comfortable with C++? Try checking out src instead of lib: https://github.com/nodejs/node/blob/v20.5.0/src/ I don’t know the language well myself, but I’d guess you are interested in the node_*.cc and node_*.h files


If I were still working on this, I would open an issue in Node’s repo. I could reference this issue for context (not required). It could be significantly slimed down to the 4 lines of script and an explanation of when it occurs, when it doesn’t occur, and what the expected behavior is.

If I learn something new, I’d come back here to share or even open a pull request if I had the time available to actively contribute.

I may be wrong, I just have not had the time or need to pursue this any further. Hopefully the next person has more luck or at least finds some of this helpful.

I’ve been able to do a little more digging. Here is what I have found.

First my environment has changed a little bit. I’ve upgraded to Node v6.10.2 and NPM 4.5.0. The only effect this has had is that I can’t duplicate the odd behavior I previously noted by console logging both error and err. It is now consistently EPERM.

The following scenarios do not trigger an EPERM error.

  • Deleting a directory with SHIFT + DELETE (skips the recycle bin, deletes the file/directory permanently)
  • Using the command line rmdir "Directory Name" (same as SHIFT + DELETE?)
  • Using cut (CTRL + X) and paste (CTRL + V) to move the directory, even if it is out of the watched ancestor directory

Additional Notes:

This may be more of a core Node.js issue that needs to be fixed with C (C++?) rather than JavaScript.

For Chokidar’s part, I haven’t been able to figure out a 100% reliable work around that meets the following criteria:

  • Only ignores the error when we are 100% certain that it is an empty directory that has been moved to the Recycle Bin.
  • Correctly cleans up the internals of Chokidar
  • Correctly emits the unlinkDir event

I was stopped at the second bullet. My progress was hacky at best (manually calling watcher.unwatch() and watcher._remove()).

It didn’t work quite as I had hoped because the internals of Chokidar relies on an error which is not occurring in this unique situation https://github.com/paulmillr/chokidar/blob/1.6.1/index.js#L473-L475

My code is a mess and was starting to get out of hand fast, but if your curious, here it is:

var fs = require('fs');
var path = require('path');
var globWatcher = require('glob-watcher');
var watcher = globWatcher('local_packages/**/*', {
	// ignorePermissionErrors: true
});

function createChokidarErrorHandler(chokidarInstance, options){
    options = options || {};
    var errorHandlerIsFunction = typeof options.errorHandler === 'function';
    var errorHandlerThis = options.errorHandlerThisArg || null;
    return function handleWatcherError(error){
        if(process.platform === 'win32' && error.code === 'EPERM'){
            var watcherPath = error.watcherPath;
            fs.stat(watcherPath, function(statsError, stats){
                if(!statsError && stats.isDirectory() === true){
                    fs.readdir(watcherPath, function(readdirError, filesAndFolders){
                        if(!readdirError && filesAndFolders.length === 0){
                            chokidarInstance.unwatch(watcherPath);

                            //Does unwatch do everything we want? No. `unlinkDir` still isn't triggered.
                            var watcherRemove = chokidarInstance._remove.bind(chokidarInstance);
                            watcherRemove(path.dirname(watcherPath), path.basename(watcherPath));
                        } else if(errorHandlerIsFunction){
                            options.errorHandler.call(errorHandlerThis, error);
                        } else {
                            throw error;
                        }
                    });
                } else if(errorHandlerIsFunction){
                    options.errorHandler.call(errorHandlerThis, error);
                } else {
                    throw error;
                }
            });
        } else if(errorHandlerIsFunction){
            options.errorHandler.call(errorHandlerThis, error);
        } else {
            throw error;
        }
    }
}

var fsErrorHandler = createChokidarErrorHandler(watcher);

watcher
    // .on('add', path => console.log(`File ${path} has been added`))
    // .on('change', path => console.log(`File ${path} has been changed`))
    // .on('unlink', path => console.log(`File ${path} has been removed`))
    // .on('addDir', path => console.log(`Directory ${path} has been added`))
    .on('unlinkDir', path => console.log(`Directory ${path} has been removed`))
    .on('ready', () => console.log('Initial scan complete. Ready for changes'))
    .on('raw', (event, path, details) => {
        console.log('Raw event info:', event, '::', path, '::', details);
    })
    .on('error', fsErrorHandler)
;

The same code in three places made me cringe, but since two of the the functions are async, I couldn’t kick the else logic into a single place down the line. I could probably try moving part of it to a separate function and reuse that to clean it up a bit, but my code just didn’t make it far enough to make taking that step worth it. Using the sync versions of the function is an option, but I don’t know yet if this kind of thing should be sync or not.

To support this I had to make a small change to chokidar’s code. Chokidar knows the path that we working on even if the error itself has null.

      watcher.on('error', function(error) {
++      error.watcherPath = path;
++      error.watcherFullPath = fullPath;
        // Workaround for https://github.com/joyent/node/issues/4337
        if (process.platform === 'win32' && error.code === 'EPERM') {
          fs.open(path, 'r', function(err, fd) {
            if (fd) fs.close(fd);
            if (!err) broadcastErr(error);
          });
        } else {
          broadcastErr(error);
        }
      });

An alternative recommendation I’ve heard is to use (from another repo pointing to https://github.com/joyent/node/issues/4337) Watchman, which I haven’t explored yet. For now, I want to stick with glob-watcher/chokidar.

Still happening on Windows. The real problem is folders not being deleted because they are locked “by another process”.

Is this seriously still an issue? Doesn’t chokidar claim they power VSCode? VSCode doesn’t crash when you delete an empty folder on windows… what is the way around this bug that has existed over a year?

And if you add a noop error event listener, you still do not get an unlinkDir event? Or you just aren’t seeing it because the lack of an error listener causes node to throw?