Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: don't trash optional dependencies that fail due to incompatible platform #8127

Open
wants to merge 9 commits into
base: latest
Choose a base branch
from
11 changes: 10 additions & 1 deletion workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,16 @@ module.exports = cls => class Reifier extends cls {
// if the node is optional, then the failure of the promise is nonfatal
// just add it and its optional set to the trash list.
[_handleOptionalFailure] (node, p) {
return (node.optional ? p.catch(() => {
return (node.optional ? p.catch((e) => {
// Optional dependencies that fail to install due to an incompatible platform
// should not be added to the trash list to prevent rebuild issues when the lockfile is not present.
// This does cause optional dependencies to be present on all platforms in the node_modules directory,
// even though these aren't really used
const trashOptionalDependency = e?.code !== 'EBADPLATFORM'
if (!trashOptionalDependency) {
log.verbose('reify', `skip trashing optional dependency due to platform mismatch`, node.path)
return
}
const set = optionalSet(node)
for (node of set) {
log.verbose('reify', 'failed optional dependency', node.path)
Expand Down
100 changes: 95 additions & 5 deletions workspaces/arborist/tap-snapshots/test/arborist/reify.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2842,11 +2842,29 @@ ArboristNode {

exports[`test/arborist/reify.js TAP do not install optional deps with mismatched platform specifications > expect resolving Promise 1`] = `
ArboristNode {
"children": Map {
"platform-specifying-test-package" => ArboristNode {
"edgesIn": Set {
EdgeIn {
"from": "",
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"type": "optional",
},
},
"location": "node_modules/platform-specifying-test-package",
"name": "platform-specifying-test-package",
"optional": true,
"path": "{CWD}/test/arborist/tap-testdir-reify-do-not-install-optional-deps-with-mismatched-platform-specifications/node_modules/platform-specifying-test-package",
"resolved": "https://registry.npmjs.org/platform-specifying-test-package/-/platform-specifying-test-package-1.0.0.tgz",
"version": "1.0.0",
},
},
"edgesOut": Map {
"platform-specifying-test-package" => EdgeOut {
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"to": null,
"to": "node_modules/platform-specifying-test-package",
"type": "optional",
},
},
Expand Down Expand Up @@ -3320,11 +3338,29 @@ ArboristNode {

exports[`test/arborist/reify.js TAP fail to install optional deps with matched os and matched cpu and mismatched libc with os and cpu and libc options > expect resolving Promise 1`] = `
ArboristNode {
"children": Map {
"platform-specifying-test-package" => ArboristNode {
"edgesIn": Set {
EdgeIn {
"from": "",
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"type": "optional",
},
},
"location": "node_modules/platform-specifying-test-package",
"name": "platform-specifying-test-package",
"optional": true,
"path": "{CWD}/test/arborist/tap-testdir-reify-fail-to-install-optional-deps-with-matched-os-and-matched-cpu-and-mismatched-libc-with-os-and-cpu-and-libc-options/node_modules/platform-specifying-test-package",
"resolved": "https://registry.npmjs.org/platform-specifying-test-package/-/platform-specifying-test-package-1.0.0.tgz",
"version": "1.0.0",
},
},
"edgesOut": Map {
"platform-specifying-test-package" => EdgeOut {
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"to": null,
"to": "node_modules/platform-specifying-test-package",
"type": "optional",
},
},
Expand All @@ -3339,11 +3375,29 @@ ArboristNode {

exports[`test/arborist/reify.js TAP fail to install optional deps with matched os and mismatched cpu with os and cpu and libc options > expect resolving Promise 1`] = `
ArboristNode {
"children": Map {
"platform-specifying-test-package" => ArboristNode {
"edgesIn": Set {
EdgeIn {
"from": "",
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"type": "optional",
},
},
"location": "node_modules/platform-specifying-test-package",
"name": "platform-specifying-test-package",
"optional": true,
"path": "{CWD}/test/arborist/tap-testdir-reify-fail-to-install-optional-deps-with-matched-os-and-mismatched-cpu-with-os-and-cpu-and-libc-options/node_modules/platform-specifying-test-package",
"resolved": "https://registry.npmjs.org/platform-specifying-test-package/-/platform-specifying-test-package-1.0.0.tgz",
"version": "1.0.0",
},
},
"edgesOut": Map {
"platform-specifying-test-package" => EdgeOut {
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"to": null,
"to": "node_modules/platform-specifying-test-package",
"type": "optional",
},
},
Expand All @@ -3358,11 +3412,29 @@ ArboristNode {

exports[`test/arborist/reify.js TAP fail to install optional deps with mismatched os and matched cpu with os and cpu and libc options > expect resolving Promise 1`] = `
ArboristNode {
"children": Map {
"platform-specifying-test-package" => ArboristNode {
"edgesIn": Set {
EdgeIn {
"from": "",
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"type": "optional",
},
},
"location": "node_modules/platform-specifying-test-package",
"name": "platform-specifying-test-package",
"optional": true,
"path": "{CWD}/test/arborist/tap-testdir-reify-fail-to-install-optional-deps-with-mismatched-os-and-matched-cpu-with-os-and-cpu-and-libc-options/node_modules/platform-specifying-test-package",
"resolved": "https://registry.npmjs.org/platform-specifying-test-package/-/platform-specifying-test-package-1.0.0.tgz",
"version": "1.0.0",
},
},
"edgesOut": Map {
"platform-specifying-test-package" => EdgeOut {
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"to": null,
"to": "node_modules/platform-specifying-test-package",
"type": "optional",
},
},
Expand Down Expand Up @@ -33177,11 +33249,29 @@ exports[`test/arborist/reify.js TAP scoped registries > should preserve original

exports[`test/arborist/reify.js TAP still do not install optional deps with mismatched platform specifications even when forced > expect resolving Promise 1`] = `
ArboristNode {
"children": Map {
"platform-specifying-test-package" => ArboristNode {
"edgesIn": Set {
EdgeIn {
"from": "",
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"type": "optional",
},
},
"location": "node_modules/platform-specifying-test-package",
"name": "platform-specifying-test-package",
"optional": true,
"path": "{CWD}/test/arborist/tap-testdir-reify-still-do-not-install-optional-deps-with-mismatched-platform-specifications-even-when-forced/node_modules/platform-specifying-test-package",
"resolved": "https://registry.npmjs.org/platform-specifying-test-package/-/platform-specifying-test-package-1.0.0.tgz",
"version": "1.0.0",
},
},
"edgesOut": Map {
"platform-specifying-test-package" => EdgeOut {
"name": "platform-specifying-test-package",
"spec": "1.0.0",
"to": null,
"to": "node_modules/platform-specifying-test-package",
"type": "optional",
},
},
Expand Down
29 changes: 26 additions & 3 deletions workspaces/arborist/test/isolated-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,10 @@ tap.test('failing optional deps are not installed', async t => {
const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
await arborist.reify({ installStrategy: 'linked' })

t.notOk(setupRequire(dir)('which'), 'Failing optional deps should not be installed')
t.ok(setupRequire(dir)('which'), 'Failing optional deps should not be installed, but the empty directory is there')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I right to say that the new behavior would be to leave empty directories for all of the optional deps that did not succeed? Hopefully there's a way to avoid this, since that would leave a load of empty dirs for a lot of native packages, and potentially confuse code which is scanning for these dirs to know which one to load for the current platform...

Copy link
Author

@paulrutter paulrutter Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is correct indeed. This is used to rebuild the proper package-json.lock file based on the node_modules directory.
I don't know enough of NPM to surface a better solution, but maybe others have ideas on how this can prevented?

Instead of the empty directory, it could also keep a list of failed optionals in a separate file or something, but that would require far more changes i would assume.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's really unfortunate; I thought that npm stored a file inside node_modules that described the layout? It seems like a bad idea to rely on an empty dir existing, and I really do think it'll cause something to break.

Copy link
Author

@paulrutter paulrutter Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, i can imagine breakage if packages rely on directory scanning without verifying the contents.

If this is not the desired approach, i would need guidance from the maintainers on what would be the best way forward.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we'll have @wraithgar weigh in

Copy link
Author

@paulrutter paulrutter Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, i checked out bindings which i use myself in https://github.com/blueconic/node-oom-heapdump, and that library handles this just fine. Haven't checked how nodejs itself handles it, as it's able to load native modules out of the box nowadays.

https://github.com/TooTallNate/node-bindings/blob/c8033dcfc04c34397384e23f7399a30e6c13830d/bindings.js#L111

Also, the rollup package doesn't rely on directory scanning: https://github.com/rollup/rollup/blob/d3e79bd79e6b53b01ef7d6535d6ea9ad9be714dd/native.js#L77

sharp seems safe as well: https://github.com/lovell/sharp/blob/1533bf995acda779313fc178d2b9d46791349961/lib/sharp.js#L23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main ones to check out are rollup and esbuild (the bulk of the people in the issue having this problem), but there's also swc, oxc, dprint, who have this same problem too.

Copy link
Author

@paulrutter paulrutter Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


t.notOk(fs.existsSync(path.join(dir, 'node_modules', '.bin', 'which')))
t.ok(fs.existsSync(path.join(dir, 'node_modules', 'which')))
t.ok(isEmptyDir(t, path.join(dir, 'node_modules', 'which')))
})

tap.test('Optional deps are installed when possible', async t => {
Expand Down Expand Up @@ -1319,7 +1320,12 @@ tap.test('failing optional peer deps are not installed', async t => {
const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
await arborist.reify({ installStrategy: 'linked' })

t.notOk(setupRequire(dir)('bar', 'which'), 'Failing optional peer deps should not be installed')
t.ok(setupRequire(dir)('bar', 'which'), 'Failing optional peer deps should not be installed, but an empty directory is there')

t.ok(fs.existsSync(path.join(dir, 'node_modules', 'which')))
t.ok(isEmptyDir(t, path.join(dir, 'node_modules', 'which')))
t.ok(fs.existsSync(path.join(dir, 'node_modules', 'bar')))
t.ok(isEmptyDir(t, path.join(dir, 'node_modules', 'bar')))
})

// Virtual packages are 2 packages that have the same version but are
Expand Down Expand Up @@ -1452,6 +1458,23 @@ function setupRequire (cwd) {
}
}

/**
* Determine whether the given `path` points to an empty directory.
* @param {String} path
* @returns {boolean}
*/
async function isEmptyDir (path) {
try {
const directory = await fs.opendir(path)
const entry = await directory.read()
await directory.close()

return entry === null
} catch (error) {
return false
}
}

function pathExists (path) {
try {
fs.statSync(path)
Expand Down