-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix: extract tarball to temp directory on Windows #2846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |
|
|
||
| const fs = require('graceful-fs') | ||
| const os = require('os') | ||
| const { backOff } = require('exponential-backoff') | ||
| const rm = require('rimraf') | ||
| const tar = require('tar') | ||
| const path = require('path') | ||
| const util = require('util') | ||
|
|
@@ -98,6 +100,40 @@ async function install (fs, gyp, argv) { | |
| } | ||
| } | ||
|
|
||
| async function copyDirectory (src, dest) { | ||
| try { | ||
| await fs.promises.stat(src) | ||
| } catch { | ||
| throw new Error(`Missing source directory for copy: ${src}`) | ||
| } | ||
| await fs.promises.mkdir(dest, { recursive: true }) | ||
| const entries = await fs.promises.readdir(src, { withFileTypes: true }) | ||
| for (const entry of entries) { | ||
| if (entry.isDirectory()) { | ||
| await copyDirectory(path.join(src, entry.name), path.join(dest, entry.name)) | ||
| } else if (entry.isFile()) { | ||
| // with parallel installs, copying files may cause file errors on | ||
| // Windows so use an exponential backoff to resolve collisions | ||
| await backOff(async () => { | ||
| try { | ||
| await fs.promises.copyFile(path.join(src, entry.name), path.join(dest, entry.name)) | ||
| } catch (err) { | ||
| // if ensure, check if file already exists and that's good enough | ||
| if (gyp.opts.ensure && err.code === 'EBUSY') { | ||
| try { | ||
| await fs.promises.stat(path.join(dest, entry.name)) | ||
| return | ||
| } catch {} | ||
| } | ||
| throw err | ||
| } | ||
| }) | ||
| } else { | ||
| throw new Error('Unexpected file directory entry type') | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function go () { | ||
| log.verbose('ensuring nodedir is created', devDir) | ||
|
|
||
|
|
@@ -118,6 +154,7 @@ async function install (fs, gyp, argv) { | |
|
|
||
| // now download the node tarball | ||
| const tarPath = gyp.opts.tarball | ||
| let extractErrors = false | ||
| let extractCount = 0 | ||
| const contentShasums = {} | ||
| const expectShasums = {} | ||
|
|
@@ -136,71 +173,99 @@ async function install (fs, gyp, argv) { | |
| return isValid | ||
| } | ||
|
|
||
| function onwarn (code, message) { | ||
| extractErrors = true | ||
| log.error('error while extracting tarball', code, message) | ||
| } | ||
|
|
||
| // download the tarball and extract! | ||
|
|
||
| if (tarPath) { | ||
| await tar.extract({ | ||
| file: tarPath, | ||
| strip: 1, | ||
| filter: isValid, | ||
| cwd: devDir | ||
| }) | ||
| } else { | ||
| try { | ||
| const res = await download(gyp, release.tarballUrl) | ||
| // on Windows there can be file errors from tar if parallel installs | ||
| // are happening (not uncommon with multiple native modules) so | ||
| // extract the tarball to a temp directory first and then copy over | ||
| const tarExtractDir = win ? await fs.promises.mkdtemp(path.join(os.tmpdir(), 'node-gyp-tmp-')) : devDir | ||
|
|
||
| if (res.status !== 200) { | ||
| throw new Error(`${res.status} response downloading ${release.tarballUrl}`) | ||
| } | ||
| try { | ||
| if (tarPath) { | ||
| await tar.extract({ | ||
| file: tarPath, | ||
| strip: 1, | ||
| filter: isValid, | ||
| onwarn, | ||
| cwd: tarExtractDir | ||
| }) | ||
| } else { | ||
| try { | ||
| const res = await download(gyp, release.tarballUrl) | ||
|
|
||
| await streamPipeline( | ||
| res.body, | ||
| // content checksum | ||
| new ShaSum((_, checksum) => { | ||
| const filename = path.basename(release.tarballUrl).trim() | ||
| contentShasums[filename] = checksum | ||
| log.verbose('content checksum', filename, checksum) | ||
| }), | ||
| tar.extract({ | ||
| strip: 1, | ||
| cwd: devDir, | ||
| filter: isValid | ||
| }) | ||
| ) | ||
| } catch (err) { | ||
| // something went wrong downloading the tarball? | ||
| if (err.code === 'ENOTFOUND') { | ||
| throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' + | ||
| 'is related to network connectivity. In most cases you are behind a proxy or have bad \n' + | ||
| 'network settings.') | ||
| if (res.status !== 200) { | ||
| throw new Error(`${res.status} response downloading ${release.tarballUrl}`) | ||
| } | ||
|
|
||
| await streamPipeline( | ||
| res.body, | ||
| // content checksum | ||
| new ShaSum((_, checksum) => { | ||
| const filename = path.basename(release.tarballUrl).trim() | ||
| contentShasums[filename] = checksum | ||
| log.verbose('content checksum', filename, checksum) | ||
| }), | ||
| tar.extract({ | ||
| strip: 1, | ||
| cwd: tarExtractDir, | ||
| filter: isValid, | ||
| onwarn | ||
| }) | ||
| ) | ||
| } catch (err) { | ||
| // something went wrong downloading the tarball? | ||
| if (err.code === 'ENOTFOUND') { | ||
| throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' + | ||
| 'is related to network connectivity. In most cases you are behind a proxy or have bad \n' + | ||
| 'network settings.') | ||
| } | ||
| throw err | ||
| } | ||
| throw err | ||
| } | ||
| } | ||
|
|
||
| // invoked after the tarball has finished being extracted | ||
| if (extractCount === 0) { | ||
| throw new Error('There was a fatal problem while downloading/extracting the tarball') | ||
| } | ||
| // invoked after the tarball has finished being extracted | ||
| if (extractErrors || extractCount === 0) { | ||
| throw new Error('There was a fatal problem while downloading/extracting the tarball') | ||
| } | ||
|
|
||
| log.verbose('tarball', 'done parsing tarball') | ||
|
|
||
| const installVersionPath = path.resolve(tarExtractDir, 'installVersion') | ||
| await Promise.all([ | ||
| // need to download node.lib | ||
| ...(win ? downloadNodeLib() : []), | ||
| // write the "installVersion" file | ||
| fs.promises.writeFile(installVersionPath, gyp.package.installVersion + '\n'), | ||
| // Only download SHASUMS.txt if we downloaded something in need of SHA verification | ||
| ...(!tarPath || win ? [downloadShasums()] : []) | ||
| ]) | ||
|
|
||
| log.verbose('download contents checksum', JSON.stringify(contentShasums)) | ||
| // check content shasums | ||
| for (const k in contentShasums) { | ||
| log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k]) | ||
| if (contentShasums[k] !== expectShasums[k]) { | ||
| throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k]) | ||
| } | ||
| } | ||
|
|
||
| log.verbose('tarball', 'done parsing tarball') | ||
|
|
||
| const installVersionPath = path.resolve(devDir, 'installVersion') | ||
| await Promise.all([ | ||
| // need to download node.lib | ||
| ...(win ? downloadNodeLib() : []), | ||
| // write the "installVersion" file | ||
| fs.promises.writeFile(installVersionPath, gyp.package.installVersion + '\n'), | ||
| // Only download SHASUMS.txt if we downloaded something in need of SHA verification | ||
| ...(!tarPath || win ? [downloadShasums()] : []) | ||
| ]) | ||
|
|
||
| log.verbose('download contents checksum', JSON.stringify(contentShasums)) | ||
| // check content shasums | ||
| for (const k in contentShasums) { | ||
| log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k]) | ||
| if (contentShasums[k] !== expectShasums[k]) { | ||
| throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k]) | ||
| // copy over the files from the temp tarball extract directory to devDir | ||
| if (tarExtractDir !== devDir) { | ||
| await copyDirectory(tarExtractDir, devDir) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason we wouldn't want to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Since there are parallel installs happening, there's no way to atomically rename to
The I've stress tested this change (on CI and locally with a real package) with 35 parallel installs and didn't trip over any race conditions in practice. |
||
| } | ||
| } finally { | ||
| if (tarExtractDir !== devDir) { | ||
| try { | ||
| // try to cleanup temp dir | ||
| await util.promisify(rm)(tarExtractDir) | ||
| } catch { | ||
| log.warn('failed to clean up temp tarball extract directory') | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -232,7 +297,7 @@ async function install (fs, gyp, argv) { | |
| log.verbose('on Windows; need to download `' + release.name + '.lib`...') | ||
| const archs = ['ia32', 'x64', 'arm64'] | ||
| return archs.map(async (arch) => { | ||
| const dir = path.resolve(devDir, arch) | ||
| const dir = path.resolve(tarExtractDir, arch) | ||
| const targetLibPath = path.resolve(dir, release.name + '.lib') | ||
| const { libUrl, libPath } = release[arch] | ||
| const name = `${arch} ${release.name}.lib` | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.