|
| 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 copyDirectory = require('../lib/copy-directory') |
| 11 | + |
| 12 | +describe('copyDirectory', 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-copy-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 copyDirectory runs concurrently. |
| 52 | + // |
| 53 | + // With the old fs.copyFile approach the dest file is created at |
| 54 | + // size 0 and grows → this loop catches it at a partial size. |
| 55 | + // |
| 56 | + // With fs.rename the file either isn't there yet (ENOENT) or is |
| 57 | + // already at its full size → zero violations. |
| 58 | + let polls = 0 |
| 59 | + const violations = [] |
| 60 | + |
| 61 | + timer = setInterval(() => { |
| 62 | + try { |
| 63 | + const stat = fs.statSync(destFile) |
| 64 | + polls++ |
| 65 | + if (stat.size !== fileSize) { |
| 66 | + violations.push({ poll: polls, size: stat.size }) |
| 67 | + } |
| 68 | + } catch (err) { |
| 69 | + if (err.code !== 'ENOENT') throw err |
| 70 | + } |
| 71 | + }, 0) |
| 72 | + |
| 73 | + await copyDirectory(srcDir, destDir) |
| 74 | + |
| 75 | + clearInterval(timer) |
| 76 | + timer = undefined |
| 77 | + |
| 78 | + console.log(` ${polls} stats observed the file during the operation`) |
| 79 | + |
| 80 | + assert.strictEqual(violations.length, 0, 'file must never be observed at a partial size') |
| 81 | + |
| 82 | + const finalStat = await fsp.stat(destFile) |
| 83 | + assert.strictEqual(finalStat.size, fileSize, |
| 84 | + 'destination file should have the correct final size') |
| 85 | + }) |
| 86 | +}) |
0 commit comments