-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix: better handle concurrent builds #3301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
e6c9291
3109bbc
36366eb
aba84f1
d7a1e4a
e050844
950ae32
b5992fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 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() && !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 = copyDirectory | ||
| Original file line number | Diff line number | Diff 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 copyDirectory = require('../lib/copy-directory') | ||
|
|
||
| describe('copyDirectory', 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 copyDirectory 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 copyDirectory(srcDir, destDir) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you mind adding a test that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added a test that |
||
|
|
||
| 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') | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
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
moveDirectoryinstead ofcopyDirectory.There was a problem hiding this comment.
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.