diff --git a/lib/constants.js b/lib/constants.js index f12924f9..d618dc81 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,6 +12,8 @@ exports.EV_UNLINK = 'unlink'; exports.EV_UNLINK_DIR = 'unlinkDir'; exports.EV_RAW = 'raw'; exports.EV_ERROR = 'error'; +exports.EV_RENAME = 'rename'; +exports.EV_INODE_CHANGE = 'ino_change'; exports.STR_DATA = 'data'; exports.STR_END = 'end'; @@ -58,3 +60,4 @@ exports.IDENTITY_FN = val => val; exports.isWindows = platform === 'win32'; exports.isMacos = platform === 'darwin'; +exports.isLinux = platform === 'linux'; diff --git a/lib/nodefs-handler.js b/lib/nodefs-handler.js index 2a61c9f4..89863680 100644 --- a/lib/nodefs-handler.js +++ b/lib/nodefs-handler.js @@ -6,6 +6,7 @@ const { promisify } = require('util'); const isBinaryPath = require('is-binary-path'); const { isWindows, + isLinux, EMPTY_FN, EMPTY_STR, KEY_LISTENERS, @@ -16,6 +17,8 @@ const { EV_ADD, EV_ADD_DIR, EV_ERROR, + EV_RENAME, + EV_INODE_CHANGE, STR_DATA, STR_END, BRACE_START, @@ -69,6 +72,14 @@ const delFromSet = (main, prop, item) => { const isEmptySet = (val) => val instanceof Set ? val.size === 0 : !val; +// will return inode number on linux (if file exists) or undefined +function inode (path) { + if (!isLinux) return; + try { + return fs.statSync(path).ino; + } catch (e) {} +} + /** * @typedef {String} Path */ @@ -102,7 +113,14 @@ const FsWatchInstances = new Map(); * @returns {fs.FSWatcher} new fsevents instance */ function createFsWatchInstance(path, options, listener, errHandler, emitRaw) { + const originalInode = inode(path) + const handleEvent = (rawEvent, evPath) => { + // linux-specific: if inode returns not-null we need to emit inode change event + const newInode = inode(path) + if (newInode && rawEvent === EV_RENAME && newInode !== originalInode) { + emitRaw(EV_INODE_CHANGE, evPath, {watchedPath: path}); + } listener(path); emitRaw(rawEvent, evPath, {watchedPath: path}); @@ -162,34 +180,62 @@ const setFsWatchListener = (path, fullPath, options, handlers) => { addAndConvert(cont, KEY_ERR, errHandler); addAndConvert(cont, KEY_RAW, rawEmitter); } else { - watcher = createFsWatchInstance( - path, - options, - fsWatchBroadcast.bind(null, fullPath, KEY_LISTENERS), - errHandler, // no need to use broadcast here - fsWatchBroadcast.bind(null, fullPath, KEY_RAW) - ); - if (!watcher) return; - watcher.on(EV_ERROR, async (error) => { + const createWatcher = (path, options) => { + const watcher = createFsWatchInstance( + path, + options, + fsWatchBroadcast.bind(null, fullPath, KEY_LISTENERS), + errHandler, // no need to use broadcast here + fsWatchBroadcast.bind(null, fullPath, KEY_RAW) + ); + if (!watcher) return; const broadcastErr = fsWatchBroadcast.bind(null, fullPath, KEY_ERR); - cont.watcherUnusable = true; // documented since Node 10.4.1 - // Workaround for https://github.com/joyent/node/issues/4337 - if (isWindows && error.code === 'EPERM') { - try { - const fd = await open(path, 'r'); - await close(fd); + watcher.on(EV_ERROR, async (error) => { + cont.watcherUnusable = true; // documented since Node 10.4.1 + // Workaround for https://github.com/joyent/node/issues/4337 + if (isWindows && error.code === 'EPERM') { + try { + const fd = await open(path, 'r'); + await close(fd); + broadcastErr(error); + } catch (err) {} + } else { broadcastErr(error); - } catch (err) {} - } else { - broadcastErr(error); - } - }); - cont = { - listeners: listener, - errHandlers: errHandler, - rawEmitters: rawEmitter, - watcher - }; + } + }); + return watcher; + } + + watcher = createWatcher(path, options); + if (!watcher) return; + + if (isLinux) { + const recreateRawEventHandler = (event) => { + if (event === EV_INODE_CHANGE) { + // need to recreate watcher on inode change + const cont = FsWatchInstances.get(fullPath); + cont.watcher.close(); + const watcher = createWatcher(path, options); + // watcher may be undefined if file is already gone + if (watcher) { + cont.watcher = watcher + } + } + }; + cont = { + listeners: new Set([listener]), + errHandlers: new Set([errHandler]), + rawEmitters: new Set([rawEmitter, recreateRawEventHandler]), + watcher + }; + } else { + cont = { + listeners: listener, + errHandlers: errHandler, + rawEmitters: rawEmitter, + watcher + }; + } FsWatchInstances.set(fullPath, cont); } // const index = cont.listeners.indexOf(listener); diff --git a/test.js b/test.js index ec6093e7..1d40f6bc 100644 --- a/test.js +++ b/test.js @@ -560,6 +560,27 @@ const runTests = (baseopts) => { spy.should.have.always.been.calledWith(EV_ADD, testPath); }); + it('should detect safe-edit', async () => { + const testPath = getFixturePath('change.txt'); + const safePath = getFixturePath('tmp.txt'); + await write(testPath, Date.now()); + const watcher = chokidar_watch(testPath, options); + const spy = await aspy(watcher, EV_ALL); + + await delay(); + await write(safePath, Date.now()); + await fs_rename(safePath, testPath); + await delay(100); + await write(safePath, Date.now()); + await fs_rename(safePath, testPath); + await delay(100); + await write(safePath, Date.now()); + await fs_rename(safePath, testPath); + await waitFor([spy]); + spy.withArgs(EV_ADD, testPath).should.have.been.calledOnce; + spy.withArgs(EV_CHANGE, testPath).should.have.been.calledThrice; + }); + // PR 682 is failing. describe.skip('Skipping gh-682: should detect unlink', () => { it('should detect unlink while watching a non-existent second file in another directory', async () => { @@ -967,6 +988,7 @@ const runTests = (baseopts) => { const watcher = chokidar_watch(watchPaths, options); const spy = await aspy(watcher, EV_ALL); + await waitFor([spy.withArgs(EV_ADD_DIR)]); spy.should.have.been.calledWith(EV_ADD_DIR, getFixturePath('subdir')); spy.withArgs(EV_ADD_DIR).should.have.been.calledOnce; fs.mkdirSync(deepDir, PERM_ARR);