Skip to content

Commit

Permalink
add safe-edit handling for Linux, fixes paulmillr#591
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
pkit committed Apr 26, 2020
1 parent 1926fc7 commit a58feec
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 26 deletions.
3 changes: 3 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,3 +60,4 @@ exports.IDENTITY_FN = val => val;

exports.isWindows = platform === 'win32';
exports.isMacos = platform === 'darwin';
exports.isLinux = platform === 'linux';
98 changes: 72 additions & 26 deletions lib/nodefs-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { promisify } = require('util');
const isBinaryPath = require('is-binary-path');
const {
isWindows,
isLinux,
EMPTY_FN,
EMPTY_STR,
KEY_LISTENERS,
Expand All @@ -16,6 +17,8 @@ const {
EV_ADD,
EV_ADD_DIR,
EV_ERROR,
EV_RENAME,
EV_INODE_CHANGE,
STR_DATA,
STR_END,
BRACE_START,
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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});

Expand Down Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit a58feec

Please sign in to comment.