fix(update): retry node dependency preparation after ENOTEMPTY
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user