From 82e249245b179633daf41c518a62520cea3e77a4 Mon Sep 17 00:00:00 2001 From: marcopiraccini Date: Sun, 3 May 2026 10:09:14 +0200 Subject: [PATCH 1/2] fs: prevent spurious recursive watch events on prefix siblings Signed-off-by: marcopiraccini --- lib/internal/fs/recursive_watch.js | 5 +- .../test-fs-watch-recursive-prefix-sibling.js | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-fs-watch-recursive-prefix-sibling.js diff --git a/lib/internal/fs/recursive_watch.js b/lib/internal/fs/recursive_watch.js index ad12afc9a8f755..69dddbcf49bbf3 100644 --- a/lib/internal/fs/recursive_watch.js +++ b/lib/internal/fs/recursive_watch.js @@ -25,6 +25,7 @@ const { join: pathJoin, relative: pathRelative, resolve: pathResolve, + sep: pathSep, } = require('path'); let internalSync; @@ -106,8 +107,10 @@ class FSWatcher extends EventEmitter { #unwatchFiles(file) { this.#symbolicFiles.delete(file); + const childPrefix = file + pathSep; for (const filename of this.#files.keys()) { - if (StringPrototypeStartsWith(filename, file)) { + if (filename === file || + StringPrototypeStartsWith(filename, childPrefix)) { this.#files.delete(filename); this.#watchers.get(filename)?.close(); this.#watchers.delete(filename); diff --git a/test/parallel/test-fs-watch-recursive-prefix-sibling.js b/test/parallel/test-fs-watch-recursive-prefix-sibling.js new file mode 100644 index 00000000000000..471850ca5e243e --- /dev/null +++ b/test/parallel/test-fs-watch-recursive-prefix-sibling.js @@ -0,0 +1,62 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/58868 + +const common = require('../common'); + +if (common.isIBMi) { + common.skip('IBMi does not support `fs.watch()`'); +} + +if (common.isAIX) { + common.skip('folder watch capability is limited in AIX.'); +} + +// macOS and Windows use the native recursive watcher and are unaffected. +if (common.isMacOS || common.isWindows) { + common.skip('regression specific to the JS-based recursive watcher'); +} + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { setTimeout } = require('timers/promises'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +(async () => { + const root = fs.mkdtempSync(path.join(tmpdir.path, 'watch-prefix-')); + + // Sibling names that share the prefix `foo` with the entry to delete. + fs.mkdirSync(path.join(root, 'foo_bar')); + fs.writeFileSync(path.join(root, 'foo_bar', 'file.txt'), ''); + fs.mkdirSync(path.join(root, 'foo_bar', 'somedir')); + fs.mkdirSync(path.join(root, 'foo')); + fs.writeFileSync(path.join(root, 'foo_'), ''); + + const events = []; + const watcher = fs.watch(root, { recursive: true }, (eventType, filename) => { + events.push({ eventType, filename }); + }); + + // Allow the watcher to fully attach to existing entries. + await setTimeout(common.platformTimeout(200)); + + fs.rmdirSync(path.join(root, 'foo')); + + // Wait long enough to capture any spurious follow-up events. + await setTimeout(common.platformTimeout(500)); + + watcher.close(); + + const isSibling = (f) => + f === 'foo_' || f === 'foo_bar' || + f.startsWith('foo_bar' + path.sep); + const spurious = events.filter((e) => isSibling(e.filename)); + assert.deepStrictEqual( + spurious, + [], + `unexpected events for prefix-sibling entries: ${JSON.stringify(spurious)}`, + ); +})().then(common.mustCall()); From 7b92f8416f17ed72eb94ea0536cfcc673cb7195c Mon Sep 17 00:00:00 2001 From: marcopiraccini Date: Sun, 3 May 2026 13:05:38 +0200 Subject: [PATCH 2/2] more coverage Signed-off-by: marcopiraccini --- .../test-fs-watch-recursive-prefix-sibling.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-fs-watch-recursive-prefix-sibling.js b/test/parallel/test-fs-watch-recursive-prefix-sibling.js index 471850ca5e243e..2169aa230d2141 100644 --- a/test/parallel/test-fs-watch-recursive-prefix-sibling.js +++ b/test/parallel/test-fs-watch-recursive-prefix-sibling.js @@ -28,13 +28,20 @@ tmpdir.refresh(); (async () => { const root = fs.mkdtempSync(path.join(tmpdir.path, 'watch-prefix-')); - // Sibling names that share the prefix `foo` with the entry to delete. + // Sibling names that share the prefix `foo` with the entries to delete. fs.mkdirSync(path.join(root, 'foo_bar')); fs.writeFileSync(path.join(root, 'foo_bar', 'file.txt'), ''); fs.mkdirSync(path.join(root, 'foo_bar', 'somedir')); - fs.mkdirSync(path.join(root, 'foo')); fs.writeFileSync(path.join(root, 'foo_'), ''); + // `foo` (empty) exercises the exact-match branch of `#unwatchFiles`. + fs.mkdirSync(path.join(root, 'foo')); + + // `foo2` has descendants and exercises the `file + sep` prefix branch. + fs.mkdirSync(path.join(root, 'foo2')); + fs.writeFileSync(path.join(root, 'foo2', 'inside.txt'), ''); + fs.mkdirSync(path.join(root, 'foo2', 'sub')); + const events = []; const watcher = fs.watch(root, { recursive: true }, (eventType, filename) => { events.push({ eventType, filename }); @@ -44,6 +51,7 @@ tmpdir.refresh(); await setTimeout(common.platformTimeout(200)); fs.rmdirSync(path.join(root, 'foo')); + fs.rmSync(path.join(root, 'foo2'), { recursive: true }); // Wait long enough to capture any spurious follow-up events. await setTimeout(common.platformTimeout(500));