current commit
260903.3 · fs: handle recursive watch setup races
'use strict';

const {
  Promise,
  SafeMap,
  SafeSet,
  StringPrototypeStartsWith,
  SymbolAsyncIterator,
} = primordials;

const { EventEmitter } = require('events');
const assert = require('internal/assert');
const {
  AbortError,
  codes: {
    ERR_INVALID_ARG_VALUE,
  },
} = require('internal/errors');
const { getValidatedPath } = require('internal/fs/utils');
const { createIgnoreMatcher, kFSWatchStart } = require('internal/fs/watchers');
const { kEmptyObject } = require('internal/util');
const { validateBoolean, validateAbortSignal, validateIgnoreOption } = require('internal/validators');
const {
  basename: pathBasename,
  join: pathJoin,
  relative: pathRelative,
  resolve: pathResolve,
  sep: pathSep,
} = require('path');

let internalSync;

function lazyLoadFsSync() {
  internalSync ??= require('fs');
  return internalSync;
}

let kResistStopPropagation;

const kIsAIX = process.platform === 'aix';

function isPathMissingForWatch(error, file) {
  if (error.code === 'ENOENT') {
    return true;
  }
  // AIX reports ENODEV when the path disappears before fs.watch() starts.
  // Check that it is actually gone so other ENODEV errors still propagate.
  if (!kIsAIX || error.code !== 'ENODEV') {
    return false;
  }
  try {
    return lazyLoadFsSync().statSync(file, { throwIfNoEntry: false }) === undefined;
  } catch {
    return false;
  }
}

// Inotify reports changes to a directory's entries, with their names, on the
// directory's own watch, so one watcher per directory is enough on Linux.
// kqueue and event ports only report that the directory itself changed, so
// elsewhere every file keeps a watcher of its own as well.
const kDirectoryWatchReportsEntries = process.platform === 'linux';

class FSWatcher extends EventEmitter {
  #options = null;
  #closed = false;
  // Every path below the root that has been reported (or existed at start).
  #entries = new SafeSet();
  // One fs.watch() per directory and symbolic link (and per file where the
  // directory watch does not report its entries).
  #watchers = new SafeMap();
  #symbolicLinks = new SafeSet();
  #rootPath = pathResolve();
  #ignoreMatcher = null;

  constructor(options = kEmptyObject) {
    super();

    assert(typeof options === 'object');

    const { persistent, recursive, signal, encoding, ignore } = options;
    let { throwIfNoEntry } = options;

    // TODO(anonrig): Add non-recursive support to non-native-watcher for IBMi & AIX support.
    if (recursive != null) {
      validateBoolean(recursive, 'options.recursive');
    }

    if (persistent != null) {
      validateBoolean(persistent, 'options.persistent');
    }

    if (signal != null) {
      validateAbortSignal(signal, 'options.signal');
    }

    if (throwIfNoEntry != null) {
      validateBoolean(throwIfNoEntry, 'options.throwIfNoEntry');
    } else {
      throwIfNoEntry = true;
    }

    if (encoding != null) {
      // This is required since on macOS and Windows it throws ERR_INVALID_ARG_VALUE
      if (typeof encoding !== 'string') {
        throw new ERR_INVALID_ARG_VALUE('options.encoding', encoding);
      }
    }

    validateIgnoreOption(ignore, 'options.ignore');
    this.#ignoreMatcher = createIgnoreMatcher(ignore);

    this.#options = { persistent, recursive, signal, encoding, throwIfNoEntry };
  }

  close() {
    if (this.#closed) {
      return;
    }

    this.#closed = true;

    for (const watcher of this.#watchers.values()) {
      watcher.close();
    }
    this.#watchers.clear();
    this.#entries.clear();
    this.#symbolicLinks.clear();
    this.emit('close');
  }

  #emit(eventType, file) {
    this.emit('change', eventType, pathRelative(this.#rootPath, file));
  }

  #forget(file) {
    const watcher = this.#watchers.get(file);
    if (watcher !== undefined) {
      watcher.close();
      this.#watchers.delete(file);
    }
    this.#entries.delete(file);
    this.#symbolicLinks.delete(file);

    const childPrefix = file + pathSep;
    for (const entry of this.#entries) {
      if (StringPrototypeStartsWith(entry, childPrefix)) {
        this.#entries.delete(entry);
        this.#symbolicLinks.delete(entry);
        const childWatcher = this.#watchers.get(entry);
        if (childWatcher !== undefined) {
          childWatcher.close();
          this.#watchers.delete(entry);
        }
      }
    }
  }

  // Report an entry that vanished between being listed and being watched so
  // the caller can discard its stale bookkeeping.
  #watch(file, onChange) {
    if (this.#closed) {
      return false;
    }
    if (this.#watchers.has(file)) {
      return true;
    }
    const { watch } = lazyLoadFsSync();
    let watcher;
    try {
      watcher = watch(file, { persistent: this.#options.persistent }, onChange);
    } catch (err) {
      if (isPathMissingForWatch(err, file) &&
          (file !== this.#rootPath || !this.#options.throwIfNoEntry)) {
        return false;
      }
      throw err;
    }
    this.#watchers.set(file, watcher);
    return true;
  }

  // Arms one watcher for `folder`, then registers entries that are not known
  // yet (emitting 'rename' for them unless this is the initial scan);
  // #addEntry() descends into subdirectories.
  #scanFolder(folder, initial) {
    if (!this.#watch(folder, (eventType, filename) => this.#onFolderEvent(folder, filename))) {
      this.#forget(folder);
      return;
    }

    const { readdirSync } = lazyLoadFsSync();
    let entries;
    try {
      entries = readdirSync(folder, { withFileTypes: true });
    } catch (error) {
      if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
        if (folder === this.#rootPath && initial &&
            (error.code !== 'ENOENT' || this.#options.throwIfNoEntry)) {
          throw error;
        }
        if (!initial && !this.#closed) {
          this.#emit('rename', folder);
        }
        this.#forget(folder);
      } else {
        this.emit('error', error);
      }
      return;
    }

    for (const entry of entries) {
      if (this.#closed) {
        break;
      }
      const file = pathJoin(folder, entry.name);
      if (!this.#entries.has(file) && !this.#ignoreMatcher?.(pathRelative(this.#rootPath, file))) {
        this.#addEntry(file, entry, initial);
      }
    }
  }

  // `entry` is the Dirent or the lstat() Stats of `file`.
  #addEntry(file, entry, initial) {
    this.#entries.add(file);
    if (!initial) {
      this.#emit('rename', file);
    }
    if (entry.isSymbolicLink()) {
      // The link target is watched so that changes behind the link surface
      // as a 'rename' of the link, as they always have on this code path.
      this.#symbolicLinks.add(file);
      if (!this.#watch(file, () => this.#emit('rename', file))) {
        this.#forget(file);
      }
    } else if (entry.isDirectory()) {
      this.#scanFolder(file, initial);
    } else if (!kDirectoryWatchReportsEntries) {
      if (!this.#watch(file, () => this.#onEntryEvent(file))) {
        this.#forget(file);
      }
    }
  }

  #onFolderEvent(folder, filename) {
    if (this.#closed) {
      return;
    }
    const { lstatSync, statSync } = lazyLoadFsSync();
    if (!kDirectoryWatchReportsEntries || filename == null) {
      // All that is known is that something about `folder` changed.
      if (statSync(folder, { throwIfNoEntry: false }) === undefined) {
        this.#emit('rename', folder);
        this.#forget(folder);
      } else {
        this.#scanFolder(folder, false);
      }
      return;
    }
    // Events about the watched directory itself are reported under its own
    // name; those take the "unknown entry" path and are resolved by the parent.
    const file = pathJoin(folder, filename);

    if (!this.#entries.has(file)) {
      if (this.#ignoreMatcher?.(pathRelative(this.#rootPath, file))) {
        return;
      }
      const entry = lstatSync(file, { throwIfNoEntry: false });
      if (entry !== undefined) {
        this.#addEntry(file, entry, false);
      } else if (folder === this.#rootPath && statSync(folder, { throwIfNoEntry: false }) === undefined) {
        this.#emit('rename', folder);
        this.#forget(folder);
      }
      return;
    }

    this.#onEntryEvent(file);
  }

  // Something happened to a known entry: work out what from its current state.
  #onEntryEvent(file) {
    if (this.#closed) {
      return;
    }
    const { statSync } = lazyLoadFsSync();
    const stats = statSync(file, { throwIfNoEntry: false });
    if (stats === undefined) {
      this.#emit('rename', file);
      this.#forget(file);
    } else if (this.#symbolicLinks.has(file)) {
      this.#emit('rename', file);
    } else if (stats.isDirectory()) {
      this.#scanFolder(file, false);
    } else {
      this.#emit('change', file);
    }
  }

  #watchRootFile(file) {
    const { statSync } = lazyLoadFsSync();
    this.#entries.add(file);
    if (!this.#watch(file, () => {
      if (this.#closed) {
        return;
      }
      if (statSync(file, { throwIfNoEntry: false }) === undefined) {
        this.#emit('rename', file);
        this.#forget(file);
      } else {
        this.emit('change', 'change', pathBasename(file));
      }
    })) {
      this.#forget(file);
    }
  }

  [kFSWatchStart](filename) {
    filename = pathResolve(getValidatedPath(filename));

    try {
      const file = lazyLoadFsSync().statSync(filename);

      this.#rootPath = filename;
      this.#closed = false;

      if (file.isDirectory()) {
        this.#scanFolder(filename, true);
      } else {
        this.#watchRootFile(filename);
      }
    } catch (error) {
      if (this.#options.throwIfNoEntry || error.code !== 'ENOENT') {
        error.filename = filename;
        this.close();
        throw error;
      }
    }

  }

  ref() {
    for (const watcher of this.#watchers.values()) {
      watcher.ref();
    }
  }

  unref() {
    for (const watcher of this.#watchers.values()) {
      watcher.unref();
    }
  }

  [SymbolAsyncIterator]() {
    const { signal } = this.#options;
    const promiseExecutor = signal == null ?
      (resolve) => {
        this.once('change', (eventType, filename) => {
          resolve({ __proto__: null, value: { eventType, filename } });
        });
      } : (resolve, reject) => {
        const onAbort = () => {
          this.close();
          reject(new AbortError(undefined, { cause: signal.reason }));
        };
        if (signal.aborted) return onAbort();
        kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
        signal.addEventListener('abort', onAbort, { __proto__: null, once: true, [kResistStopPropagation]: true });
        this.once('change', (eventType, filename) => {
          signal.removeEventListener('abort', onAbort);
          resolve({ __proto__: null, value: { eventType, filename } });
        });
      };
    return {
      next: () => (this.#closed ?
        { __proto__: null, done: true } :
        new Promise(promiseExecutor)),
      return: () => {
        this.close();
        return { __proto__: null, done: true };
      },
      [SymbolAsyncIterator]() { return this; },
    };
  }
}

module.exports = {
  FSWatcher,
  kFSWatchStart,
};