From 784945580c6ef5c8b4f5cb497574cfd93ece9706 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Wed, 22 Jul 2026 19:19:32 +0000 Subject: [PATCH] module: cache negative stat results in the CJS loader The CommonJS loader keeps a per-require-tree `statCache` to avoid re-stat-ing the same path while resolving a module tree, but it only caches successful stats. Negative results (e.g. -ENOENT) fall through and are re-probed every time the same missing path is looked up again within the same top-level require. These misses are extremely common during resolution and recur across sibling and descendant modules: `tryExtensions` probes .js/.json/.node in order (every extension before the real one is a miss), and bare specifiers walk the node_modules chain upward through many non-existent ancestor directories. None of these negatives were cached, so they were re-stat-ed repeatedly within a single resolution pass. Cache negative stat results alongside positive ones. The staleness window is identical and already accepted for positive results: the cache is tree-scoped, created when a top-level require begins (requireDepth === 0) and cleared when it completes, so a stale entry can only survive the duration of one top-level require. Signed-off-by: Maxime David --- lib/internal/modules/cjs/loader.js | 12 +++++-- .../test-module-negative-stat-cache.js | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-module-negative-stat-cache.js diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index bb466d0b68d5cb..abddb349d0c6bc 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -279,8 +279,16 @@ function stat(filename) { if (result !== undefined) { return result; } } const result = internalFsBinding.internalModuleStat(filename); - if (statCache !== null && result >= 0) { - // Only set cache when `internalModuleStat(filename)` succeeds. + if (statCache !== null) { + // Cache both successful results (0 = file, 1 = directory) and negative + // results (libuv error codes, e.g. -ENOENT). Negative results are common + // and repeated during resolution: `tryExtensions` probes several + // non-existent extensions, and bare specifiers walk the `node_modules` + // chain upward stat-ing many parent directories that do not exist. The + // cache is scoped to a single top-level `require` tree (created and torn + // down around `requireDepth === 0`), so caching a negative result carries + // the same bounded staleness window that caching a positive one already + // does. statCache.set(filename, result); } return result; diff --git a/test/parallel/test-module-negative-stat-cache.js b/test/parallel/test-module-negative-stat-cache.js new file mode 100644 index 00000000000000..24a02812766cc7 --- /dev/null +++ b/test/parallel/test-module-negative-stat-cache.js @@ -0,0 +1,34 @@ +'use strict'; +require('../common'); + +// This tests that the CommonJS loader's per-require-tree stat cache also caches +// negative (not-found) results, so a path that is missing when first probed is +// not re-stat-ed for the rest of the require tree. + +const assert = require('assert'); +const fs = require('fs'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); + +// A module path that does not exist yet. +const generated = tmpdir.resolve('generated.js'); + +// First probe: the file does not exist -> negative stat, cached. +assert.throws( + () => require(generated), + { code: 'MODULE_NOT_FOUND' }, + 'expected the module to be missing before it is created', +); + +// Create the file mid-traversal, in the same require tree. +fs.writeFileSync(generated, 'module.exports = 1;'); + +// Second probe, still in the same tree: the negative result is cached, so the +// loader must serve the cached miss instead of re-stat-ing and observing the +// freshly-created file. +assert.throws( + () => require(generated), + { code: 'MODULE_NOT_FOUND' }, + 'a negative stat result must be cached for the rest of the require tree', +);