Skip to content

Commit 950ae32

Browse files
committed
chore: rename copyDirectory to moveDirectory
Reflects the change in functionality Assisted-by: Claude Opus 4.6
1 parent e050844 commit 950ae32

5 files changed

Lines changed: 146 additions & 8 deletions

File tree

lib/copy-directory.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const path = require('path')
66

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

9-
async function copyDirectory (src, dest) {
9+
async function moveDirectory (src, dest) {
1010
try {
1111
await fs.stat(src)
1212
} catch {
@@ -55,4 +55,4 @@ async function copyDirectory (src, dest) {
5555
}
5656
}
5757

58-
module.exports = copyDirectory
58+
module.exports = moveDirectory

lib/install.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { Transform, promises: { pipeline } } = require('stream')
88
const crypto = require('crypto')
99
const log = require('./log')
1010
const semver = require('semver')
11-
const copyDirectory = require('./copy-directory')
11+
const moveDirectory = require('./move-directory')
1212
const { download } = require('./download')
1313
const processRelease = require('./process-release')
1414

@@ -243,7 +243,7 @@ async function install (gyp, argv) {
243243
}
244244

245245
// copy over the files from the temp tarball extract directory to devDir
246-
await copyDirectory(tarExtractDir, devDir)
246+
await moveDirectory(tarExtractDir, devDir)
247247
} finally {
248248
try {
249249
// try to cleanup temp dir

lib/move-directory.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use strict'
2+
3+
const { promises: fs } = require('graceful-fs')
4+
const crypto = require('crypto')
5+
const path = require('path')
6+
7+
const RACE_ERRORS = ['ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM']
8+
9+
async function moveDirectory (src, dest) {
10+
try {
11+
await fs.stat(src)
12+
} catch {
13+
throw new Error(`Missing source directory for move: ${src}`)
14+
}
15+
await fs.mkdir(dest, { recursive: true })
16+
const entries = await fs.readdir(src, { withFileTypes: true })
17+
for (const entry of entries) {
18+
if (!entry.isDirectory() && !entry.isFile()) {
19+
throw new Error('Unexpected file directory entry type')
20+
}
21+
22+
// With parallel installs, multiple processes race to place the same
23+
// entry. Use fs.rename for an atomic move so no process ever sees a
24+
// partially written file. For cross-filesystem (EXDEV), copy to a
25+
// temp path in the dest directory first, then rename within the
26+
// same filesystem to keep it atomic.
27+
//
28+
// When another process wins the race, rename may fail with one of
29+
// these codes — all mean the destination was already placed and
30+
// are safe to ignore since every process extracts identical content.
31+
const srcPath = path.join(src, entry.name)
32+
const destPath = path.join(dest, entry.name)
33+
try {
34+
await fs.rename(srcPath, destPath)
35+
} catch (err) {
36+
if (RACE_ERRORS.includes(err.code)) {
37+
// Another parallel process already placed this entry — ignore
38+
} else if (err.code === 'EXDEV') {
39+
// Cross-filesystem: copy to a uniquely named temp path in the
40+
// dest directory, then rename into place atomically
41+
const tmpPath = `${destPath}.tmp.${crypto.randomBytes(6).toString('hex')}`
42+
try {
43+
await fs.cp(srcPath, tmpPath, { recursive: true })
44+
await fs.rename(tmpPath, destPath)
45+
} catch (e) {
46+
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
47+
if (!RACE_ERRORS.includes(e.code)) {
48+
throw e
49+
}
50+
}
51+
} else {
52+
throw err
53+
}
54+
}
55+
}
56+
}
57+
58+
module.exports = moveDirectory

test/test-copy-directory.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ const fs = require('fs')
77
const { promises: fsp } = fs
88
const os = require('os')
99
const { FULL_TEST, platformTimeout } = require('./common')
10-
const copyDirectory = require('../lib/copy-directory')
10+
const moveDirectory = require('../lib/move-directory')
1111

12-
describe('copyDirectory', function () {
12+
describe('moveDirectory', function () {
1313
let timer
1414
let tmpDir
1515

@@ -48,7 +48,7 @@ describe('copyDirectory', function () {
4848
await handle.close()
4949

5050
// Tight synchronous poll: stat the destination on every event-loop
51-
// turn while copyDirectory runs concurrently.
51+
// turn while moveDirectory runs concurrently.
5252
let polls = 0
5353
const violations = []
5454

@@ -64,7 +64,7 @@ describe('copyDirectory', function () {
6464
}
6565
}, 0)
6666

67-
await copyDirectory(srcDir, destDir)
67+
await moveDirectory(srcDir, destDir)
6868

6969
clearInterval(timer)
7070
timer = undefined

test/test-move-directory.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict'
2+
3+
const { describe, it, afterEach } = require('mocha')
4+
const assert = require('assert')
5+
const path = require('path')
6+
const fs = require('fs')
7+
const { promises: fsp } = fs
8+
const os = require('os')
9+
const { FULL_TEST, platformTimeout } = require('./common')
10+
const moveDirectory = require('../lib/move-directory')
11+
12+
describe('moveDirectory', function () {
13+
let timer
14+
let tmpDir
15+
16+
afterEach(async () => {
17+
if (tmpDir) {
18+
await fsp.rm(tmpDir, { recursive: true, force: true })
19+
tmpDir = null
20+
}
21+
clearInterval(timer)
22+
})
23+
24+
it('large file appears atomically (no partial writes visible)', async function () {
25+
if (!FULL_TEST) {
26+
return this.skip('Skipping due to test environment configuration')
27+
}
28+
29+
this.timeout(platformTimeout(5, { win32: 10 }))
30+
31+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'node-gyp-move-test-'))
32+
const srcDir = path.join(tmpDir, 'src')
33+
const destDir = path.join(tmpDir, 'dest')
34+
await fsp.mkdir(srcDir)
35+
36+
const fileName = 'large.bin'
37+
const srcFile = path.join(srcDir, fileName)
38+
const destFile = path.join(destDir, fileName)
39+
40+
// Create a 5 GB sparse file — instant to create, consumes no real
41+
// disk, but fs.copyFile still has to process the full extent map so
42+
// the destination file is visible at size 0 and grows over time.
43+
// fs.rename() is atomic at the VFS level: the file either does not
44+
// exist at the destination or appears at its full size in one step.
45+
const fileSize = 5 * 1024 * 1024 * 1024
46+
const handle = await fsp.open(srcFile, 'w')
47+
await handle.truncate(fileSize)
48+
await handle.close()
49+
50+
// Tight synchronous poll: stat the destination on every event-loop
51+
// turn while moveDirectory runs concurrently.
52+
let polls = 0
53+
const violations = []
54+
55+
timer = setInterval(() => {
56+
try {
57+
const stat = fs.statSync(destFile)
58+
polls++
59+
if (stat.size !== fileSize) {
60+
violations.push({ poll: polls, size: stat.size })
61+
}
62+
} catch (err) {
63+
if (err.code !== 'ENOENT') throw err
64+
}
65+
}, 0)
66+
67+
await moveDirectory(srcDir, destDir)
68+
69+
clearInterval(timer)
70+
timer = undefined
71+
72+
console.log(` ${polls} stats observed the file during the operation`)
73+
74+
assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size')
75+
76+
const finalStat = await fsp.stat(destFile)
77+
assert.strictEqual(finalStat.size, fileSize,
78+
'destination file should have the correct final size')
79+
})
80+
})

0 commit comments

Comments
 (0)