Skip to content

ESM resolver treats an unreadable package.json as absent: exports silently vanish and the wrong file can load #65220

Description

@taras

Version

v26.5.1 (behavior present from v22.x through main; see the source reference below)

Platform

Reproduced on darwin arm64; the code path is platform-independent.

Subsystem

module, esm

What steps will reproduce the bug?

mkdir -p repro/node_modules/dep/lib && cd repro
printf '{"name":"dep","exports":{".":"./lib/real.js","./sub":"./lib/sub.js"}}' > node_modules/dep/package.json
printf 'export const which = "real";' > node_modules/dep/lib/real.js
printf 'export const which = "sub";'  > node_modules/dep/lib/sub.js
printf 'export const which = "decoy index.js";' > node_modules/dep/index.js
printf '{"type":"module"}' > package.json
cat > main.mjs <<'EOF'
const sub = await import("dep/sub").then(m => m.which, e => `${e.code}`);
const root = await import("dep").then(m => m.which, e => `${e.code}`);
console.log("dep/sub ->", sub);
console.log("dep     ->", root);
EOF

node main.mjs
# dep/sub -> sub
# dep     -> real

chmod 000 node_modules/dep/package.json   # any read failure; EACCES stands in for EMFILE/EIO/ENOMEM
node main.mjs
# dep/sub -> ERR_MODULE_NOT_FOUND
# dep     -> decoy index.js

What is the expected behavior?

A package.json that exists but cannot be read should surface the read error (or at minimum fail resolution with an error that names the errno), the way an unparsable package.json raises ERR_INVALID_PACKAGE_CONFIG.

What do you see instead?

The read failure is treated identically to the file being absent:

  • import("dep/sub") fails ERR_MODULE_NOT_FOUND for a subpath the package exports — with a Did you mean to import "dep/lib/sub.js"?-style hint in real packages, pointing users away from the actual problem.
  • import("dep") silently executes a different file than the package declares — legacy index.js resolution instead of the exports target. No error, no warning; wrong code runs.

The swallow is in src/node_modules.cc (GetPackageJSON):

if (ReadFileSync(&package_config.raw_json, path.data()) < 0) {
  return nullptr;
}

ReadFileSync's failure reason is discarded, so ENOENT (genuinely absent — the reasonable case to treat as "no package.json") is indistinguishable from EACCES, EMFILE, EIO, or ENOMEM on a file that exists.

Additional information

We hit this in CI as an unexplainable one-off: a correctly installed package with a correct exports map failed a subpath import with ERR_MODULE_NOT_FOUND on a heavily loaded runner, and everything on disk was byte-identical before and after. The root cause took a while to establish precisely because the errno is discarded — a transient read failure leaves no trace and masquerades as a missing module (taras/executable.md#439 has the investigation). Distinguishing ENOENT from other errnos at this one spot would turn silent misresolution into a diagnosable error.

Related but distinct: #54304 (over-long path makes the read fail, module type misdetected — same swallow surfacing through a different trigger), #49674 (misleading message when package.json exists but defines no matching export).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions