libnpmexec 10.1.6 → 10.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { dirname, resolve } = require('node:path')
3
+ const { dirname, join, resolve } = require('node:path')
4
4
  const crypto = require('node:crypto')
5
5
  const { mkdir } = require('node:fs/promises')
6
6
  const Arborist = require('@npmcli/arborist')
@@ -16,6 +16,7 @@ const getBinFromManifest = require('./get-bin-from-manifest.js')
16
16
  const noTTY = require('./no-tty.js')
17
17
  const runScript = require('./run-script.js')
18
18
  const isWindows = require('./is-windows.js')
19
+ const withLock = require('./with-lock.js')
19
20
 
20
21
  const binPaths = []
21
22
 
@@ -247,7 +248,8 @@ const exec = async (opts) => {
247
248
  ...flatOptions,
248
249
  path: installDir,
249
250
  })
250
- const npxTree = await npxArb.loadActual()
251
+ const lockPath = join(installDir, 'concurrency.lock')
252
+ const npxTree = await withLock(lockPath, () => npxArb.loadActual())
251
253
  await Promise.all(needInstall.map(async ({ spec }) => {
252
254
  const { manifest } = await missingFromTree({
253
255
  spec,
@@ -290,11 +292,11 @@ const exec = async (opts) => {
290
292
  }
291
293
  }
292
294
  }
293
- await npxArb.reify({
295
+ await withLock(lockPath, () => npxArb.reify({
294
296
  ...flatOptions,
295
297
  save: true,
296
298
  add,
297
- })
299
+ }))
298
300
  }
299
301
  binPaths.push(resolve(installDir, 'node_modules/.bin'))
300
302
  const pkgJson = await PackageJson.load(installDir)
package/lib/run-script.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const ciInfo = require('ci-info')
2
2
  const runScript = require('@npmcli/run-script')
3
- const readPackageJson = require('read-package-json-fast')
3
+ const pkgJson = require('@npmcli/package-json')
4
4
  const { log, output } = require('proc-log')
5
5
  const noTTY = require('./no-tty.js')
6
6
  const isWindowsShell = require('./is-windows.js')
@@ -28,7 +28,10 @@ const run = async ({
28
28
 
29
29
  // do the fakey runScript dance
30
30
  // still should work if no package.json in cwd
31
- const realPkg = await readPackageJson(`${path}/package.json`).catch(() => ({}))
31
+ const { content: realPkg } = await pkgJson.normalize(path, { steps: [
32
+ 'binDir',
33
+ ...pkgJson.normalizeSteps,
34
+ ] }).catch(() => ({ content: {} }))
32
35
  const pkg = {
33
36
  ...realPkg,
34
37
  scripts: {
@@ -0,0 +1,175 @@
1
+ const fs = require('node:fs/promises')
2
+ const { rmdirSync } = require('node:fs')
3
+ const promiseRetry = require('promise-retry')
4
+ const { onExit } = require('signal-exit')
5
+
6
+ // a lockfile implementation inspired by the unmaintained proper-lockfile library
7
+ //
8
+ // similarities:
9
+ // - based on mkdir's atomicity
10
+ // - works across processes and even machines (via NFS)
11
+ // - cleans up after itself
12
+ // - detects compromised locks
13
+ //
14
+ // differences:
15
+ // - higher-level API (just a withLock function)
16
+ // - written in async/await style
17
+ // - uses mtime + inode for more reliable compromised lock detection
18
+ // - more ergonomic compromised lock handling (i.e. withLock will reject, and callbacks have access to an AbortSignal)
19
+ // - uses a more recent version of signal-exit
20
+
21
+ const touchInterval = 1_000
22
+ // mtime precision is platform dependent, so use a reasonably large threshold
23
+ const staleThreshold = 5_000
24
+
25
+ // track current locks and their cleanup functions
26
+ const currentLocks = new Map()
27
+
28
+ function cleanupLocks () {
29
+ for (const [, cleanup] of currentLocks) {
30
+ try {
31
+ cleanup()
32
+ } catch (err) {
33
+ //
34
+ }
35
+ }
36
+ }
37
+
38
+ // clean up any locks that were not released normally
39
+ onExit(cleanupLocks)
40
+
41
+ /**
42
+ * Acquire an advisory lock for the given path and hold it for the duration of the callback.
43
+ *
44
+ * The lock will be released automatically when the callback resolves or rejects.
45
+ * Concurrent calls to withLock() for the same path will wait until the lock is released.
46
+ */
47
+ async function withLock (lockPath, cb) {
48
+ try {
49
+ const signal = await acquireLock(lockPath)
50
+ return await new Promise((resolve, reject) => {
51
+ signal.addEventListener('abort', () => {
52
+ reject(Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' }))
53
+ });
54
+
55
+ (async () => {
56
+ try {
57
+ resolve(await cb(signal))
58
+ } catch (err) {
59
+ reject(err)
60
+ }
61
+ })()
62
+ })
63
+ } finally {
64
+ releaseLock(lockPath)
65
+ }
66
+ }
67
+
68
+ function acquireLock (lockPath) {
69
+ return promiseRetry({
70
+ minTimeout: 100,
71
+ maxTimeout: 5_000,
72
+ // if another process legitimately holds the lock, wait for it to release; if it dies abnormally and the lock becomes stale, we'll acquire it automatically
73
+ forever: true,
74
+ }, async (retry) => {
75
+ try {
76
+ await fs.mkdir(lockPath)
77
+ } catch (err) {
78
+ if (err.code !== 'EEXIST' && err.code !== 'EBUSY' && err.code !== 'EPERM') {
79
+ throw err
80
+ }
81
+
82
+ const status = await getLockStatus(lockPath)
83
+
84
+ if (status === 'locked') {
85
+ // let's see if we can acquire it on the next attempt 🤞
86
+ return retry(err)
87
+ }
88
+ if (status === 'stale') {
89
+ try {
90
+ // there is a very tiny window where another process could also release the stale lock and acquire it before we release it here; the lock compromise checker should detect this and throw an error
91
+ deleteLock(lockPath)
92
+ } catch (e) {
93
+ // on windows, EBUSY/EPERM can happen if another process is (re)creating the lock; maybe we can acquire it on a subsequent attempt 🤞
94
+ if (e.code === 'EBUSY' || e.code === 'EPERM') {
95
+ return retry(e)
96
+ }
97
+ throw e
98
+ }
99
+ }
100
+ // immediately attempt to acquire the lock (no backoff)
101
+ return await acquireLock(lockPath)
102
+ }
103
+ try {
104
+ const signal = await maintainLock(lockPath)
105
+ return signal
106
+ } catch (err) {
107
+ throw Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' })
108
+ }
109
+ })
110
+ }
111
+
112
+ function deleteLock (lockPath) {
113
+ try {
114
+ // synchronous, so we can call in an exit handler
115
+ rmdirSync(lockPath)
116
+ } catch (err) {
117
+ if (err.code !== 'ENOENT') {
118
+ throw err
119
+ }
120
+ }
121
+ }
122
+
123
+ function releaseLock (lockPath) {
124
+ currentLocks.get(lockPath)?.()
125
+ currentLocks.delete(lockPath)
126
+ }
127
+
128
+ async function getLockStatus (lockPath) {
129
+ try {
130
+ const stat = await fs.stat(lockPath)
131
+ return (Date.now() - stat.mtimeMs > staleThreshold) ? 'stale' : 'locked'
132
+ } catch (err) {
133
+ if (err.code === 'ENOENT') {
134
+ return 'unlocked'
135
+ }
136
+ throw err
137
+ }
138
+ }
139
+
140
+ async function maintainLock (lockPath) {
141
+ const controller = new AbortController()
142
+ const stats = await fs.stat(lockPath)
143
+ // fs.utimes operates on floating points seconds (directly, or via strings/Date objects), which may not match the underlying filesystem's mtime precision, meaning that we might read a slightly different mtime than we write. always round to the nearest second, since all filesystems support at least second precision
144
+ let mtime = Math.round(stats.mtimeMs / 1000)
145
+ const signal = controller.signal
146
+
147
+ async function touchLock () {
148
+ try {
149
+ const currentStats = (await fs.stat(lockPath))
150
+ const currentMtime = Math.round(currentStats.mtimeMs / 1000)
151
+ if (currentStats.ino !== stats.ino || currentMtime !== mtime) {
152
+ throw new Error('Lock compromised')
153
+ }
154
+ mtime = Math.round(Date.now() / 1000)
155
+ // touch the lock, unless we just released it during this iteration
156
+ if (currentLocks.has(lockPath)) {
157
+ await fs.utimes(lockPath, mtime, mtime)
158
+ }
159
+ } catch (err) {
160
+ // stats mismatch or other fs error means the lock was compromised
161
+ controller.abort()
162
+ }
163
+ }
164
+
165
+ const timeout = setInterval(touchLock, touchInterval)
166
+ timeout.unref()
167
+ function cleanup () {
168
+ clearInterval(timeout)
169
+ deleteLock(lockPath)
170
+ }
171
+ currentLocks.set(lockPath, cleanup)
172
+ return signal
173
+ }
174
+
175
+ module.exports = withLock
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libnpmexec",
3
- "version": "10.1.6",
3
+ "version": "10.1.7",
4
4
  "files": [
5
5
  "bin/",
6
6
  "lib/"
@@ -52,7 +52,7 @@
52
52
  "devDependencies": {
53
53
  "@npmcli/eslint-config": "^5.0.1",
54
54
  "@npmcli/mock-registry": "^1.0.0",
55
- "@npmcli/template-oss": "4.24.4",
55
+ "@npmcli/template-oss": "4.25.1",
56
56
  "bin-links": "^5.0.0",
57
57
  "chalk": "^5.2.0",
58
58
  "just-extend": "^6.2.0",
@@ -60,21 +60,22 @@
60
60
  "tap": "^16.3.8"
61
61
  },
62
62
  "dependencies": {
63
- "@npmcli/arborist": "^9.1.4",
64
- "@npmcli/package-json": "^6.1.1",
65
- "@npmcli/run-script": "^9.0.1",
63
+ "@npmcli/arborist": "^9.1.5",
64
+ "@npmcli/package-json": "^7.0.0",
65
+ "@npmcli/run-script": "^10.0.0",
66
66
  "ci-info": "^4.0.0",
67
- "npm-package-arg": "^12.0.0",
68
- "pacote": "^21.0.0",
67
+ "npm-package-arg": "^13.0.0",
68
+ "pacote": "^21.0.2",
69
69
  "proc-log": "^5.0.0",
70
+ "promise-retry": "^2.0.1",
70
71
  "read": "^4.0.0",
71
- "read-package-json-fast": "^4.0.0",
72
72
  "semver": "^7.3.7",
73
+ "signal-exit": "^4.1.0",
73
74
  "walk-up-path": "^4.0.0"
74
75
  },
75
76
  "templateOSS": {
76
77
  "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
77
- "version": "4.24.4",
78
+ "version": "4.25.1",
78
79
  "content": "../../scripts/template-oss/index.js"
79
80
  }
80
81
  }