Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,7 +204,11 @@ async function build (gyp, argv) {
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
await fs.symlink(python, symlinkDestination)
try {
await fs.symlink(python, symlinkDestination)
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
log.verbose('bin symlinks', `created symlink to "${python}" in "${buildBinsDir}" and added to PATH`)
}

Expand Down
58 changes: 58 additions & 0 deletions lib/copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the semantics to moveDirectory instead of copyDirectory.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename it and the files. You're right that now it's move, outside of the edge case of cross-device on Windows, which falls back to a copy and move.

} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
38 changes: 2 additions & 36 deletions lib/install.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,13 @@

const { createWriteStream, promises: fs } = require('graceful-fs')
const os = require('os')
const { backOff } = require('exponential-backoff')
const tar = require('tar')
const path = require('path')
const { Transform, promises: { pipeline } } = require('stream')
const crypto = require('crypto')
const log = require('./log')
const semver = require('semver')
const moveDirectory = require('./move-directory')
const { download } = require('./download')
const processRelease = require('./process-release')

Expand DownExpand Up@@ -119,40 +119,6 @@ async function install (gyp, argv) {
}
}

async function copyDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for copy: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.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.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.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 devDir is created', devDir)

Expand DownExpand Up@@ -277,7 +243,7 @@ async function install (gyp, argv) {
}

// copy over the files from the temp tarball extract directory to devDir
await copyDirectory(tarExtractDir, devDir)
await moveDirectory(tarExtractDir, devDir)
} finally {
try {
// try to cleanup temp dir
Expand Down
58 changes: 58 additions & 0 deletions lib/move-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
'use strict'

const { promises: fs } = require('graceful-fs')
const crypto = require('crypto')
const path = require('path')

const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']

async function moveDirectory (src, dest) {
try {
await fs.stat(src)
} catch {
throw new Error(`Missing source directory for move: ${src}`)
}
await fs.mkdir(dest, { recursive: true })
const entries = await fs.readdir(src, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error('Unexpected file directory entry type')
}

// With parallel installs, multiple processes race to place the same
// entry. Use fs.rename for an atomic move so no process ever sees a
// partially written file. For cross-filesystem (EXDEV), copy to a
// temp path in the dest directory first, then rename within the
// same filesystem to keep it atomic.
//
// When another process wins the race, rename may fail with one of
// these codes — all mean the destination was already placed and
// are safe to ignore since every process extracts identical content.
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
try {
await fs.rename(srcPath, destPath)
} catch (err) {
if (RACE_ERRORS.includes(err.code)) {
// Another parallel process already placed this entry — ignore
} else if (err.code === 'EXDEV') {
// Cross-filesystem: copy to a uniquely named temp path in the
// dest directory, then rename into place atomically
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
try {
await fs.cp(srcPath, tmpPath, { recursive: true })
await fs.rename(tmpPath, destPath)
} catch (e) {
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
if (!RACE_ERRORS.includes(e.code)) {
throw e
}
}
} else {
throw err
}
}
}
}

module.exports = moveDirectory
1 change: 0 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@
"main": "./lib/node-gyp.js",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
Expand Down
62 changes: 60 additions & 2 deletions test/test-addon.js
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
'use strict'

const { describe, it } = require('mocha')
const { describe, it, beforeEach, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('graceful-fs')
const { rm, mkdtemp } = require('fs/promises')
const os = require('os')
const cp = require('child_process')
const util = require('../lib/util')
const { platformTimeout } = require('./common')
const { FULL_TEST, platformTimeout } = require('./common')

const addonPath = path.resolve(__dirname, 'node_modules', 'hello_world')
const nodeGyp = path.resolve(__dirname, '..', 'bin', 'node-gyp.js')
Expand DownExpand Up@@ -129,4 +130,61 @@ describe('addon', function () {
assert.strictEqual(runHello(notNodePath), 'world')
fs.unlinkSync(notNodePath)
})

describe('parallel', function () {
let devDir
let addonCopiesDir

beforeEach(async () => {
devDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-'))
addonCopiesDir = await mkdtemp(path.join(os.tmpdir(), 'node-gyp-test-addons-'))
})

afterEach(async () => {
await Promise.all([
rm(devDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }),
rm(addonCopiesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 })
])
devDir = null
addonCopiesDir = null
})

const runIt = (name, fn) => {
if (!FULL_TEST) {
return it.skip('Skipping parallel rebuild test due to test environment configuration')
}

if (process.platform === 'darwin' && process.arch === 'x64') {
return it.skip('Skipping parallel rebuild test on x64 macOS')
}

return it(name, async function () {
this.timeout(platformTimeout(4, { win32: 20 }))
await fn.call(this)
})
}

runIt('parallel rebuild', async function () {
// Install dependencies (nan) so copies in temp directories can resolve them
const [npmErr] = await util.execFile('npm', ['install', '--ignore-scripts'], { cwd: addonPath, shell: process.platform === 'win32' })
assert.strictEqual(npmErr, null)

const copies = await Promise.all(new Array(5).fill(0).map(async (_, i) => {
const copyDir = path.join(addonCopiesDir, `hello_world_${i}`)
await fs.promises.cp(addonPath, copyDir, { recursive: true })
return copyDir
}))
await Promise.all(copies.map(async (copyDir, i) => {
const cmd = [nodeGyp, 'rebuild', '-C', copyDir, '--loglevel=verbose', `--devdir=${devDir}`]
const title = `${' '.repeat(8)}parallel rebuild ${(i + 1).toString().padEnd(2, ' ')}`
console.log(`${title} : Start`)
console.time(title)
const [err, logLines] = await execFile(cmd)
console.timeEnd(title)
const lastLine = logLines[logLines.length - 1]
assert.strictEqual(err, null)
assert.strictEqual(lastLine, 'gyp info ok', 'should end in ok')
}))
})
})
})
80 changes: 80 additions & 0 deletions test/test-copy-directory.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
'use strict'

const { describe, it, afterEach } = require('mocha')
const assert = require('assert')
const path = require('path')
const fs = require('fs')
const { promises: fsp } = fs
const os = require('os')
const { FULL_TEST, platformTimeout } = require('./common')
const moveDirectory = require('../lib/move-directory')

describe('moveDirectory', function () {
let timer
let tmpDir

afterEach(async () => {
if (tmpDir) {
await fsp.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
clearInterval(timer)
})

it('large file appears atomically (no partial writes visible)', async function () {
if (!FULL_TEST) {
return this.skip('Skipping due to test environment configuration')
}

this.timeout(platformTimeout(5, { win32: 10 }))

tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-copy-test-'))
const srcDir = path.join(tmpDir, 'src')
const destDir = path.join(tmpDir, 'dest')
await fsp.mkdir(srcDir)

const fileName = 'large.bin'
const srcFile = path.join(srcDir, fileName)
const destFile = path.join(destDir, fileName)

// Create a 5 GB sparse file — instant to create, consumes no real
// disk, but fs.copyFile still has to process the full extent map so
// the destination file is visible at size 0 and grows over time.
// fs.rename() is atomic at the VFS level: the file either does not
// exist at the destination or appears at its full size in one step.
const fileSize = 5 * 1024 * 1024 * 1024
const handle = await fsp.open(srcFile, 'w')
await handle.truncate(fileSize)
await handle.close()

// Tight synchronous poll: stat the destination on every event-loop
// turn while moveDirectory runs concurrently.
let polls = 0
const violations = []

timer = setInterval(() => {
try {
const stat = fs.statSync(destFile)
polls++
if (stat.size !== fileSize) {
violations.push({ poll: polls, size: stat.size })
}
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
}, 0)

await moveDirectory(srcDir, destDir)

clearInterval(timer)
timer = undefined

console.log(` ${polls} stats observed the file during the operation`)

assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')

const finalStat = await fsp.stat(destFile)
assert.strictEqual(finalStat.size, fileSize,
'destination file should have the correct final size')
})
})
Loading
Loading