Skip to content

Commit aaa117c

Browse files
authored
fix: extract tarball to temp directory on Windows (#2846)
* fix: check for errors while extracting downloaded tarball Signed-off-by: David Sanders <[email protected]> * test: parallel installs Signed-off-by: David Sanders <[email protected]> * fix: extract tarball to temp directory on Windows Signed-off-by: David Sanders <[email protected]> --------- Signed-off-by: David Sanders <[email protected]>
1 parent bb76021 commit aaa117c

File tree

4 files changed

+212
-62
lines changed

4 files changed

+212
-62
lines changed

lib/install.js

Lines changed: 123 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
const fs = require('graceful-fs')
44
const os = require('os')
5+
const { backOff } = require('exponential-backoff')
6+
const rm = require('rimraf')
57
const tar = require('tar')
68
const path = require('path')
79
const util = require('util')
@@ -98,6 +100,40 @@ async function install (fs, gyp, argv) {
98100
}
99101
}
100102

103+
async function copyDirectory (src, dest) {
104+
try {
105+
await fs.promises.stat(src)
106+
} catch {
107+
throw new Error(`Missing source directory for copy: ${src}`)
108+
}
109+
await fs.promises.mkdir(dest, { recursive: true })
110+
const entries = await fs.promises.readdir(src, { withFileTypes: true })
111+
for (const entry of entries) {
112+
if (entry.isDirectory()) {
113+
await copyDirectory(path.join(src, entry.name), path.join(dest, entry.name))
114+
} else if (entry.isFile()) {
115+
// with parallel installs, copying files may cause file errors on
116+
// Windows so use an exponential backoff to resolve collisions
117+
await backOff(async () => {
118+
try {
119+
await fs.promises.copyFile(path.join(src, entry.name), path.join(dest, entry.name))
120+
} catch (err) {
121+
// if ensure, check if file already exists and that's good enough
122+
if (gyp.opts.ensure && err.code === 'EBUSY') {
123+
try {
124+
await fs.promises.stat(path.join(dest, entry.name))
125+
return
126+
} catch {}
127+
}
128+
throw err
129+
}
130+
})
131+
} else {
132+
throw new Error('Unexpected file directory entry type')
133+
}
134+
}
135+
}
136+
101137
async function go () {
102138
log.verbose('ensuring nodedir is created', devDir)
103139

@@ -118,6 +154,7 @@ async function install (fs, gyp, argv) {
118154

119155
// now download the node tarball
120156
const tarPath = gyp.opts.tarball
157+
let extractErrors = false
121158
let extractCount = 0
122159
const contentShasums = {}
123160
const expectShasums = {}
@@ -136,71 +173,99 @@ async function install (fs, gyp, argv) {
136173
return isValid
137174
}
138175

176+
function onwarn (code, message) {
177+
extractErrors = true
178+
log.error('error while extracting tarball', code, message)
179+
}
180+
139181
// download the tarball and extract!
140182

141-
if (tarPath) {
142-
await tar.extract({
143-
file: tarPath,
144-
strip: 1,
145-
filter: isValid,
146-
cwd: devDir
147-
})
148-
} else {
149-
try {
150-
const res = await download(gyp, release.tarballUrl)
183+
// on Windows there can be file errors from tar if parallel installs
184+
// are happening (not uncommon with multiple native modules) so
185+
// extract the tarball to a temp directory first and then copy over
186+
const tarExtractDir = win ? await fs.promises.mkdtemp(path.join(os.tmpdir(), 'node-gyp-tmp-')) : devDir
151187

152-
if (res.status !== 200) {
153-
throw new Error(`${res.status} response downloading ${release.tarballUrl}`)
154-
}
188+
try {
189+
if (tarPath) {
190+
await tar.extract({
191+
file: tarPath,
192+
strip: 1,
193+
filter: isValid,
194+
onwarn,
195+
cwd: tarExtractDir
196+
})
197+
} else {
198+
try {
199+
const res = await download(gyp, release.tarballUrl)
155200

156-
await streamPipeline(
157-
res.body,
158-
// content checksum
159-
new ShaSum((_, checksum) => {
160-
const filename = path.basename(release.tarballUrl).trim()
161-
contentShasums[filename] = checksum
162-
log.verbose('content checksum', filename, checksum)
163-
}),
164-
tar.extract({
165-
strip: 1,
166-
cwd: devDir,
167-
filter: isValid
168-
})
169-
)
170-
} catch (err) {
171-
// something went wrong downloading the tarball?
172-
if (err.code === 'ENOTFOUND') {
173-
throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' +
174-
'is related to network connectivity. In most cases you are behind a proxy or have bad \n' +
175-
'network settings.')
201+
if (res.status !== 200) {
202+
throw new Error(`${res.status} response downloading ${release.tarballUrl}`)
203+
}
204+
205+
await streamPipeline(
206+
res.body,
207+
// content checksum
208+
new ShaSum((_, checksum) => {
209+
const filename = path.basename(release.tarballUrl).trim()
210+
contentShasums[filename] = checksum
211+
log.verbose('content checksum', filename, checksum)
212+
}),
213+
tar.extract({
214+
strip: 1,
215+
cwd: tarExtractDir,
216+
filter: isValid,
217+
onwarn
218+
})
219+
)
220+
} catch (err) {
221+
// something went wrong downloading the tarball?
222+
if (err.code === 'ENOTFOUND') {
223+
throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' +
224+
'is related to network connectivity. In most cases you are behind a proxy or have bad \n' +
225+
'network settings.')
226+
}
227+
throw err
176228
}
177-
throw err
178229
}
179-
}
180230

181-
// invoked after the tarball has finished being extracted
182-
if (extractCount === 0) {
183-
throw new Error('There was a fatal problem while downloading/extracting the tarball')
184-
}
231+
// invoked after the tarball has finished being extracted
232+
if (extractErrors || extractCount === 0) {
233+
throw new Error('There was a fatal problem while downloading/extracting the tarball')
234+
}
235+
236+
log.verbose('tarball', 'done parsing tarball')
237+
238+
const installVersionPath = path.resolve(tarExtractDir, 'installVersion')
239+
await Promise.all([
240+
// need to download node.lib
241+
...(win ? downloadNodeLib() : []),
242+
// write the "installVersion" file
243+
fs.promises.writeFile(installVersionPath, gyp.package.installVersion + '\n'),
244+
// Only download SHASUMS.txt if we downloaded something in need of SHA verification
245+
...(!tarPath || win ? [downloadShasums()] : [])
246+
])
247+
248+
log.verbose('download contents checksum', JSON.stringify(contentShasums))
249+
// check content shasums
250+
for (const k in contentShasums) {
251+
log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k])
252+
if (contentShasums[k] !== expectShasums[k]) {
253+
throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k])
254+
}
255+
}
185256

186-
log.verbose('tarball', 'done parsing tarball')
187-
188-
const installVersionPath = path.resolve(devDir, 'installVersion')
189-
await Promise.all([
190-
// need to download node.lib
191-
...(win ? downloadNodeLib() : []),
192-
// write the "installVersion" file
193-
fs.promises.writeFile(installVersionPath, gyp.package.installVersion + '\n'),
194-
// Only download SHASUMS.txt if we downloaded something in need of SHA verification
195-
...(!tarPath || win ? [downloadShasums()] : [])
196-
])
197-
198-
log.verbose('download contents checksum', JSON.stringify(contentShasums))
199-
// check content shasums
200-
for (const k in contentShasums) {
201-
log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k])
202-
if (contentShasums[k] !== expectShasums[k]) {
203-
throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k])
257+
// copy over the files from the temp tarball extract directory to devDir
258+
if (tarExtractDir !== devDir) {
259+
await copyDirectory(tarExtractDir, devDir)
260+
}
261+
} finally {
262+
if (tarExtractDir !== devDir) {
263+
try {
264+
// try to cleanup temp dir
265+
await util.promisify(rm)(tarExtractDir)
266+
} catch {
267+
log.warn('failed to clean up temp tarball extract directory')
268+
}
204269
}
205270
}
206271

@@ -232,7 +297,7 @@ async function install (fs, gyp, argv) {
232297
log.verbose('on Windows; need to download `' + release.name + '.lib`...')
233298
const archs = ['ia32', 'x64', 'arm64']
234299
return archs.map(async (arch) => {
235-
const dir = path.resolve(devDir, arch)
300+
const dir = path.resolve(tarExtractDir, arch)
236301
const targetLibPath = path.resolve(dir, release.name + '.lib')
237302
const { libUrl, libPath } = release[arch]
238303
const name = `${arch} ${release.name}.lib`

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"gyp"
1313
],
1414
"version": "9.3.1",
15-
"installVersion": 9,
15+
"installVersion": 10,
1616
"author": "Nathan Rajlich <[email protected]> (http://tootallnate.net)",
1717
"repository": {
1818
"type": "git",
@@ -23,6 +23,7 @@
2323
"main": "./lib/node-gyp.js",
2424
"dependencies": {
2525
"env-paths": "^2.2.0",
26+
"exponential-backoff": "^3.1.1",
2627
"glob": "^7.1.4",
2728
"graceful-fs": "^4.2.6",
2829
"make-fetch-happen": "^11.0.3",
@@ -45,6 +46,6 @@
4546
},
4647
"scripts": {
4748
"lint": "standard */*.js test/**/*.js",
48-
"test": "npm run lint && tap --timeout=600 test/test-*"
49+
"test": "npm run lint && tap --timeout=1200 test/test-*"
4950
}
5051
}

test/test-download.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ test('download headers (actual)', async (t) => {
188188
await util.promisify(install)(prog, [])
189189

190190
const data = await fs.promises.readFile(path.join(expectedDir, 'installVersion'), 'utf8')
191-
t.strictEqual(data, '9\n', 'correct installVersion')
191+
t.strictEqual(data, '10\n', 'correct installVersion')
192192

193193
const list = await fs.promises.readdir(path.join(expectedDir, 'include/node'))
194194
t.ok(list.includes('common.gypi'))

test/test-install.js

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
'use strict'
22

33
const { test } = require('tap')
4-
const { test: { install } } = require('../lib/install')
4+
const path = require('path')
5+
const os = require('os')
6+
const util = require('util')
7+
const { test: { download, install } } = require('../lib/install')
8+
const rimraf = require('rimraf')
9+
const gyp = require('../lib/node-gyp')
510
const log = require('npmlog')
11+
const semver = require('semver')
12+
const stream = require('stream')
13+
const streamPipeline = util.promisify(stream.pipeline)
614

715
log.level = 'error' // we expect a warning
816

@@ -44,3 +52,79 @@ test('EACCES retry once', async (t) => {
4452
}
4553
}
4654
})
55+
56+
// only run these tests if we are running a version of Node with predictable version path behavior
57+
const skipParallelInstallTests = process.env.FAST_TEST ||
58+
process.release.name !== 'node' ||
59+
semver.prerelease(process.version) !== null ||
60+
semver.satisfies(process.version, '<10')
61+
62+
async function parallelInstallsTest (t, fs, devDir, prog) {
63+
if (skipParallelInstallTests) {
64+
return t.skip('Skipping parallel installs test due to test environment configuration')
65+
}
66+
67+
t.tearDown(async () => {
68+
await util.promisify(rimraf)(devDir)
69+
})
70+
71+
const expectedDir = path.join(devDir, process.version.replace(/^v/, ''))
72+
await util.promisify(rimraf)(expectedDir)
73+
74+
await Promise.all([
75+
install(fs, prog, []),
76+
install(fs, prog, []),
77+
install(fs, prog, []),
78+
install(fs, prog, []),
79+
install(fs, prog, []),
80+
install(fs, prog, []),
81+
install(fs, prog, []),
82+
install(fs, prog, []),
83+
install(fs, prog, []),
84+
install(fs, prog, [])
85+
])
86+
}
87+
88+
test('parallel installs (ensure=true)', async (t) => {
89+
const fs = require('graceful-fs')
90+
const devDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'node-gyp-test-'))
91+
92+
const prog = gyp()
93+
prog.parseArgv([])
94+
prog.devDir = devDir
95+
prog.opts.ensure = true
96+
log.level = 'warn'
97+
98+
await parallelInstallsTest(t, fs, devDir, prog)
99+
})
100+
101+
test('parallel installs (ensure=false)', async (t) => {
102+
const fs = require('graceful-fs')
103+
const devDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'node-gyp-test-'))
104+
105+
const prog = gyp()
106+
prog.parseArgv([])
107+
prog.devDir = devDir
108+
prog.opts.ensure = false
109+
log.level = 'warn'
110+
111+
await parallelInstallsTest(t, fs, devDir, prog)
112+
})
113+
114+
test('parallel installs (tarball)', async (t) => {
115+
const fs = require('graceful-fs')
116+
const devDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'node-gyp-test-'))
117+
118+
const prog = gyp()
119+
prog.parseArgv([])
120+
prog.devDir = devDir
121+
prog.opts.tarball = path.join(devDir, 'node-headers.tar.gz')
122+
log.level = 'warn'
123+
124+
await streamPipeline(
125+
(await download(prog, 'https://nodejs.org/dist/v16.0.0/node-v16.0.0.tar.gz')).body,
126+
fs.createWriteStream(prog.opts.tarball)
127+
)
128+
129+
await parallelInstallsTest(t, fs, devDir, prog)
130+
})

0 commit comments

Comments
 (0)