-
-
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
Open
dsanders11
wants to merge
5
commits into
nodejs:main
Choose a base branch
from
dsanders11:fix/concurrent-rebuilds
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e6c9291
refactor: move copyDirectory to own file for testability
dsanders11 3109bbc
test: add test to ensure copyDirectory copies files atomically
dsanders11 36366eb
fix: ensure copyDirectory atomically copies files
dsanders11 aba84f1
fix: don't fail if Python3 symlink already exists
dsanders11 d7a1e4a
test: add a test for parallel rebuilds
dsanders11 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
||
|
|
||
| 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') | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.