Skip to content

Commit 085f9f6

Browse files
fix(patch): stop a workdir cleanup race from masking update errors (#9798)
`npm patch update` builds a throwaway git repo in a temp dir to rebase the existing patch, then removes it. Two problems made that removal flaky: Since git 2.48, `maintenance.autoDetach` defaults to true, so `git commit` detaches `git maintenance run --auto` and returns while that background process is still writing under `.git/objects/pack`. Removing the workdir then races it and fails with `ENOTEMPTY`. Both cleanup sites removed the workdir with an unguarded `await rm(...)`, so that failure replaced the command's real error, a `EPATCHBASE` rebase failure surfaced as `ENOTEMPTY`, and on the success path a cleanup failure would have failed an otherwise-complete update. The throwaway repo's git invocations now pass `-c gc.auto=0 -c maintenance.auto=false` so git never spawns background maintenance, and both cleanup sites go through a helper that retries the removal and never throws. ## References Reported in #9793 (comment) as an unrelated CI failure.
1 parent 52f3093 commit 085f9f6

2 files changed

Lines changed: 73 additions & 3 deletions

File tree

‎lib/commands/patch.js‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ class Patch extends BaseCommand {
408408
pkgJson, patched, entry, name: entry.name, newVersion, newKey, diff, installedVersions,
409409
})
410410
} finally {
411-
await rm(rebase.work, { recursive: true, force: true })
411+
await this.#removeWork(rebase.work)
412412
}
413413
}
414414

@@ -561,13 +561,24 @@ class Patch extends BaseCommand {
561561
}
562562
return { work, repo, newDir, conflicted }
563563
} catch (er) {
564-
await rm(work, { recursive: true, force: true })
564+
await this.#removeWork(work)
565565
throw er
566566
}
567567
}
568568

569+
// Remove the throwaway rebase workdir.
570+
// Retries because git can briefly hold files there, and never throws so a cleanup failure cannot mask the real error.
571+
async #removeWork (work) {
572+
try {
573+
await rm(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
574+
} catch (er) {
575+
log.verbose('patch', `could not remove ${work}: ${er.message}`)
576+
}
577+
}
578+
569579
#git (args, cwd) {
570-
return git.spawn(args, {
580+
// The throwaway repo never needs maintenance and a detached auto-gc would race the workdir cleanup.
581+
return git.spawn(['-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...args], {
571582
cwd,
572583
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
573584
})

‎test/lib/commands/patch.js‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const fs = require('node:fs')
2+
const fsPromises = require('node:fs/promises')
23
const path = require('node:path')
34
const t = require('tap')
45
const Arborist = require('@npmcli/arborist')
@@ -909,6 +910,64 @@ t.test('update: a patch that no longer applies to its baseline rejects with EPAT
909910
await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHBASE' })
910911
})
911912

913+
// fs/promises whose rm always fails for the rebase workdir, mimicking a concurrent git process leaving it non-empty.
914+
const failingWorkdirRm = {
915+
'node:fs/promises': {
916+
...fsPromises,
917+
rm: async (p, opts) => {
918+
if (path.basename(p).startsWith('npm-patch-rebase-')) {
919+
throw Object.assign(new Error(`ENOTEMPTY: directory not empty, rmdir '${p}'`), { code: 'ENOTEMPTY' })
920+
}
921+
return fsPromises.rm(p, opts)
922+
},
923+
},
924+
}
925+
926+
t.test('update: a failed workdir cleanup does not mask the real error', async t => {
927+
const name = 'upd-drift-rm'
928+
const { npm, registry } = await loadMockNpm(t, {
929+
config: { 'ignore-scripts': true, audit: false },
930+
strictRegistryNock: false,
931+
prefixDir: rootWith({ [name]: '^1.0.0' }),
932+
mocks: failingWorkdirRm,
933+
})
934+
await setupVersions(npm, registry, name, { '1.0.0': 'real\n', '2.0.0': 'real2\n' })
935+
await npm.exec('install', [])
936+
fs.mkdirSync(path.join(npm.prefix, 'patches'), { recursive: true })
937+
fs.writeFileSync(path.join(npm.prefix, 'patches', `${name}@1.0.0.patch`),
938+
'--- a/index.js\t\n+++ b/index.js\t\n@@ -1,1 +1,1 @@\n-NOT-THE-REAL-LINE\n+changed\n')
939+
const pkg = readJson(path.join(npm.prefix, 'package.json'))
940+
pkg.patchedDependencies = { [`${name}@1.0.0`]: `patches/${name}@1.0.0.patch` }
941+
fs.writeFileSync(path.join(npm.prefix, 'package.json'), JSON.stringify(pkg))
942+
npm.config.set('to', '2.0.0')
943+
await t.rejects(npm.exec('patch', ['update', name]), { code: 'EPATCHBASE' })
944+
})
945+
946+
t.test('update: a failed workdir cleanup does not fail a successful update', async t => {
947+
const name = 'upd-exact-rm'
948+
const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, {
949+
config: { 'ignore-scripts': true, audit: false },
950+
strictRegistryNock: false,
951+
prefixDir: rootWith({ [name]: '^1.0.0' }),
952+
mocks: failingWorkdirRm,
953+
})
954+
await setupVersions(npm, registry, name, { '1.0.0': 'a\nb\nc\n', '2.0.0': 'a\nb\nCC\n' })
955+
await npm.exec('install', [])
956+
957+
outputs.length = 0
958+
await npm.exec('patch', ['add', name])
959+
const editDir = joinedOutput().match(/directory: (.+)/)[1].trim()
960+
fs.writeFileSync(path.join(editDir, 'index.js'), 'AA\nb\nc\n')
961+
await npm.exec('patch', ['commit', editDir])
962+
963+
npm.config.set('to', '2.0.0')
964+
await npm.exec('patch', ['update', name])
965+
966+
const pkg = readJson(path.join(npm.prefix, 'package.json'))
967+
t.same(pkg.patchedDependencies, { [`${name}@2.0.0`]: `patches/${name}@2.0.0.patch` },
968+
'selector renamed to the new version')
969+
})
970+
912971
t.test('update: when the new version already contains the patch, reports EPATCHEMPTY', async t => {
913972
const name = 'upd-empty'
914973
const { npm, joinedOutput, outputs, registry } = await loadMockNpm(t, {

0 commit comments

Comments
 (0)