From a58feecba6f9c6e2e8245b2644b8ff9997ebb510 Mon Sep 17 00:00:00 2001
From: Constantine Peresypkin <pconstantine@gmail.com>
Date: Sun, 26 Apr 2020 21:12:35 +0300
Subject: [PATCH] add `safe-edit` handling for Linux, fixes #591

will re-add the watcher on inode change in `fs.watch` mode on Linux
the idea was shamelessly stolen from @paco3346

bonus: fixed strange, pretty infrequent race in `fs.watchFile` test case `should emit matching dir events`
---
 lib/constants.js      |  3 ++
 lib/nodefs-handler.js | 98 +++++++++++++++++++++++++++++++------------
 test.js               | 22 ++++++++++
 3 files changed, 97 insertions(+), 26 deletions(-)

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);