diff --git a/scripts/build/node-deps.mjs b/scripts/build/node-deps.mjs index 5edc4415b0..a36c5f534e 100644 --- a/scripts/build/node-deps.mjs +++ b/scripts/build/node-deps.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' @@ -45,6 +46,28 @@ function completedInstallMatches({ source, receipt, hiddenLock, key, nativeKey } .every(path => existsSync(join(source, path))) } +// An interrupted Windows update can leave a nested .bin that npm ci's own rmdir +// cannot clear (ENOTEMPTY, #75584); only deleting node_modules recovers it. npm's +// debug log names the code while stdio stays on the terminal, so give each run +// its own logs dir and retry once only on that code. Other failures keep the tree. +function runNpmCi(node, npm, args, { source, env }) { + const logsDir = mkdtempSync(join(tmpdir(), 'hermes-npm-logs-')) + // Builders set CI=1, which turns npm's spinner off. Ask for it back: npm + // still shows it only on a terminal. Kept out of `args`, which keys the receipt. + const run = () => execFileSync(node, [npm, ...args, '--progress=true', `--logs-dir=${logsDir}`], + { cwd: source, env, stdio: 'inherit' }) + try { + run() + } catch (error) { + const logged = readdirSync(logsDir).some(name => readFileSync(join(logsDir, name), 'utf8').includes('ENOTEMPTY')) + if (!logged) throw error + console.log('node-deps: npm ci hit ENOTEMPTY; removing node_modules and retrying once...') + rmSync(join(source, 'node_modules'), { recursive: true, force: true, maxRetries: 3 }) + run() + } + rmSync(logsDir, { recursive: true, force: true }) +} + /** Install the full requested workspace union in one strict, locked operation. */ export function prepareNodeDependencies({ source, workspaces, env = process.env, reuse = false, install = true, nativeToolchain }) { source = resolve(source) @@ -105,9 +128,7 @@ export function prepareNodeDependencies({ source, workspaces, env = process.env, rmSync(receipt, { force: true }) rmSync(nativeReceipt, { force: true }) console.log(`node-deps: installing workspace dependencies with npm ci (${selected.join(', ')})...`) - // Builders set CI=1, which turns npm's spinner off. Ask for it back: npm - // still shows it only on a terminal. Kept out of `args`, which keys the receipt. - execFileSync(node, [npm, ...args, '--progress=true'], { cwd: source, env, stdio: 'inherit' }) + runNpmCi(node, npm, args, { source, env }) if (reuse) { const completed = `${key}\n${createHash('sha256').update(readFileSync(hiddenLock)).digest('hex')}\n` writeFileSync(receipt, completed) diff --git a/tests-js/node-deps.test.mjs b/tests-js/node-deps.test.mjs index 616f3263c3..83c7fec897 100644 --- a/tests-js/node-deps.test.mjs +++ b/tests-js/node-deps.test.mjs @@ -187,3 +187,47 @@ test('npm configuration name casing does not invalidate a completed install', as prepareNodeDependencies({ ...options, env: { ...env, NPM_CONFIG_PREFIX: join(source, 'other-prefix') } }) expect(existsSync(artifact)).toBe(false) }, 30000) + +// A stand-in npm: `ci` fails with ENOTEMPTY (or `failure`) while a stuck +// node_modules/.bin entry survives, and records every ci run. +function fakeNpm(root, failure) { + const [, realCli] = npmCommand() + const dir = join(root, 'fake-npm') + mkdirSync(join(dir, 'node_modules'), { recursive: true }) + symlinkSync(dirname(createRequire(realCli).resolve('semver/package.json')), join(dir, 'node_modules/semver'), 'junction') + const cli = join(dir, 'npm-cli.js') + writeFileSync(cli, `const fs = require('fs'), path = require('path') +const args = process.argv.slice(2) +if (args[0] === '--version') { console.log('10.9.0'); process.exit(0) } +fs.appendFileSync(path.join(${JSON.stringify(root)}, 'ci-runs'), 'ci\\n') +if (fs.existsSync('node_modules/.bin/stuck')) { + const logs = args.find(arg => arg.startsWith('--logs-dir=')).slice('--logs-dir='.length) + fs.writeFileSync(path.join(logs, 'debug-0.log'), 'error code ${failure}\\n') + process.exit(1) +} +`) + return { ...process.env, npm_execpath: cli } +} + +function stuckNodeModules(root) { + mkdirSync(join(root, 'node_modules/.bin'), { recursive: true }) + writeFileSync(join(root, 'node_modules/.bin/stuck'), '') +} + +test('npm ci ENOTEMPTY clears node_modules and retries once', async () => { + const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs') + const source = fixture() + stuckNodeModules(source) + prepareNodeDependencies({ source, workspaces: ['web'], env: fakeNpm(source, 'ENOTEMPTY') }) + expect(readFileSync(join(source, 'ci-runs'), 'utf8')).toBe('ci\nci\n') + expect(existsSync(join(source, 'node_modules/.bin/stuck'))).toBe(false) +}, 30000) + +test('any other npm ci failure keeps node_modules and does not retry', async () => { + const { prepareNodeDependencies } = await import('../scripts/build/node-deps.mjs') + const source = fixture() + stuckNodeModules(source) + expect(() => prepareNodeDependencies({ source, workspaces: ['web'], env: fakeNpm(source, 'EINTEGRITY') })).toThrow() + expect(readFileSync(join(source, 'ci-runs'), 'utf8')).toBe('ci\n') + expect(existsSync(join(source, 'node_modules/.bin/stuck'))).toBe(true) +}, 30000)