fix(update): retry node dependency preparation after ENOTEMPTY

This commit is contained in:
Hermes Agent
2026-09-24 19:43:13 -05:00
committed by brooklyn!
parent b42474b0d1
commit 625f1d8926
2 changed files with 69 additions and 4 deletions

View File

@@ -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)

View File

@@ -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)