Skip to content

Commit 36f0fd9

Browse files
codebytereaduh95
authored andcommitted
fs: copy directory trees for fs.cp() on the thread pool
fs.cp() and fs.promises.cp() walked the tree in JavaScript with several thread pool round trips per entry (opendir batches, two stat()s, the copyFile(), a chmod()), all awaited in sequence: a 2 100-file tree took ~215 ms with ~110 ms of that on the main thread, against ~36 ms for fs.cpSync(), which copies the tree in C++ when no filter is given. Factor that C++ walk into CopyDirRecursive(), which records the error instead of throwing so that it can run on any thread, and run it as one ThreadPoolWork request (CpDirJob) for fs.cp()/fs.promises.cp() when the destination directory does not exist yet and nothing has to run per entry (no filter, no dereference, permission model off). Copying into an existing tree keeps the JavaScript walk and its rules for what may already be there. The same tree now takes ~30 ms with under 1 ms on the main thread. For that job the walk follows the JavaScript walk's rules rather than cpSync's: it creates every directory with mkdir() and every file with an exclusive uv_fs_copyfile() (honouring the copyFile() mode flags) and fails with EEXIST if anything has appeared in their place since the JavaScript check, so it never opens or follows something it did not create; sockets, FIFOs and unknown entries are reported back to JavaScript, which rejects them with the same SystemErrors as before; relative link targets are made absolute lexically as path.resolve() does. cpSync keeps merging into existing directories, skipping special files and canonicalizing link targets. The walk now uses the error_code overloads of std::filesystem throughout (directory iteration included), so an unreadable directory inside the tree is reported as EACCES by both cp() and cpSync() instead of terminating the process, which cpSync() has done since the walk moved to C++. Filesystem errors raised inside the walk keep their codes, with 'cp' or 'copyfile'/'mkdir' as the syscall. With preserveTimestamps the walk stamps each directory it filled, root included, so cp() keeps preserving directory times and cpSync() without a filter now does too. Both file copies set the destination's mode before writing the data, which clears setuid and setgid, so the walk puts the source mode back after each file it copied; the JavaScript walk already chmod()s there. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com> PR-URL: #65488 Refs: #58461 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Aviv Keller <me@aviv.sh> Reviewed-By: Jake Yuesong Li <jake.yuesong@gmail.com> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
1 parent b197034 commit 36f0fd9

9 files changed

Lines changed: 761 additions & 213 deletions

benchmark/fs/bench-cp.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
// fs.promises.cp() of a directory tree.
4+
5+
const common = require('../common');
6+
const fs = require('fs');
7+
const path = require('path');
8+
const tmpdir = require('../../test/common/tmpdir');
9+
10+
const bench = common.createBenchmark(main, {
11+
files: [500],
12+
n: [3],
13+
});
14+
15+
function prepareSource(files) {
16+
const src = tmpdir.resolve('cp-src');
17+
for (let i = 0; i < files; i++) {
18+
const dir = path.join(src, `dir-${i % 10}`, `sub-${i % 7}`);
19+
fs.mkdirSync(dir, { recursive: true });
20+
fs.writeFileSync(path.join(dir, `file-${i}.js`), 'x'.repeat(1024 + (i % 512)));
21+
}
22+
return src;
23+
}
24+
25+
async function main({ files, n }) {
26+
tmpdir.refresh();
27+
const src = prepareSource(files);
28+
bench.start();
29+
for (let i = 0; i < n; i++) {
30+
await fs.promises.cp(src, tmpdir.resolve(`cp-dest-${i}`), { recursive: true });
31+
}
32+
bench.end(n);
33+
}

lib/internal/fs/cp/cp.js

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const {
66
ArrayPrototypeEvery,
77
ArrayPrototypeFilter,
88
Boolean,
9+
ErrorCaptureStackTrace,
10+
Promise,
911
PromisePrototypeThen,
1012
PromiseReject,
1113
SafePromiseAll,
@@ -55,6 +57,7 @@ const {
5557
sep,
5658
} = require('path');
5759
const fsBinding = internalBinding('fs');
60+
const permission = require('internal/process/permission');
5861

5962
async function cpFn(src, dest, opts) {
6063
// Warn about using preserveTimestamps on 32-bit node
@@ -211,30 +214,19 @@ async function getStatsForCopy(destStat, src, dest, opts) {
211214
return onFile(srcStat, destStat, src, dest, opts);
212215
} else if (srcStat.isSymbolicLink()) {
213216
return onLink(destStat, src, dest, opts);
214-
} else if (srcStat.isSocket()) {
215-
throw new ERR_FS_CP_SOCKET({
216-
message: `cannot copy a socket file: ${dest}`,
217-
path: dest,
218-
syscall: 'cp',
219-
errno: EINVAL,
220-
code: 'EINVAL',
221-
});
222-
} else if (srcStat.isFIFO()) {
223-
throw new ERR_FS_CP_FIFO_PIPE({
224-
message: `cannot copy a FIFO pipe: ${dest}`,
225-
path: dest,
226-
syscall: 'cp',
227-
errno: EINVAL,
228-
code: 'EINVAL',
229-
});
230217
}
231-
throw new ERR_FS_CP_UNKNOWN({
232-
message: `cannot copy an unknown file type: ${dest}`,
233-
path: dest,
234-
syscall: 'cp',
235-
errno: EINVAL,
236-
code: 'EINVAL',
237-
});
218+
throw errorForSpecialFile(srcStat.isSocket() ? 'socket' : srcStat.isFIFO() ? 'fifo' : 'unknown', dest);
219+
}
220+
221+
function errorForSpecialFile(kind, dest) {
222+
const info = { path: dest, syscall: 'cp', errno: EINVAL, code: 'EINVAL' };
223+
if (kind === 'socket') {
224+
return new ERR_FS_CP_SOCKET({ message: `cannot copy a socket file: ${dest}`, ...info });
225+
}
226+
if (kind === 'fifo') {
227+
return new ERR_FS_CP_FIFO_PIPE({ message: `cannot copy a FIFO pipe: ${dest}`, ...info });
228+
}
229+
return new ERR_FS_CP_UNKNOWN({ message: `cannot copy an unknown file type: ${dest}`, ...info });
238230
}
239231

240232
function onFile(srcStat, destStat, src, dest, opts) {
@@ -318,6 +310,15 @@ async function onDir(srcStat, destStat, src, dest, opts) {
318310
}
319311

320312
async function mkDirAndCopy(srcMode, src, dest, opts) {
313+
// A destination directory that does not exist yet is filled in one thread
314+
// pool request by the walk fs.cpSync() uses, unless a filter has to run per
315+
// entry, links inside the tree must be dereferenced, or the permission model
316+
// has to check each path. Copying into an existing tree keeps the per-entry
317+
// walk below and its rules for what may already be there.
318+
if (!opts.filter && !opts.dereference && !permission.isEnabled()) {
319+
// Creates dest itself, with the mode of src.
320+
return copyDirNative(src, dest, opts);
321+
}
321322
await mkdir(dest);
322323
await copyDir(src, dest, opts);
323324
if (opts.preserveTimestamps) {
@@ -326,6 +327,27 @@ async function mkDirAndCopy(srcMode, src, dest, opts) {
326327
return setDestMode(dest, srcMode);
327328
}
328329

330+
function copyDirNative(src, dest, opts) {
331+
return new Promise((resolve, reject) => {
332+
const job = new fsBinding.CpDirJob(src, dest, opts.force, opts.dereference, opts.errorOnExist,
333+
opts.verbatimSymlinks, opts.preserveTimestamps, opts.mode);
334+
// Sockets, FIFOs and unknown entries come back as (kind, path) so that
335+
// they reject with the same errors as the walk above.
336+
job.ondone = (err, specialFile, specialFilePath) => {
337+
if (specialFile !== undefined) {
338+
err = errorForSpecialFile(specialFile, specialFilePath);
339+
}
340+
if (err != null) {
341+
ErrorCaptureStackTrace(err, copyDirNative);
342+
reject(err);
343+
} else {
344+
resolve();
345+
}
346+
};
347+
job.run();
348+
});
349+
}
350+
329351
async function copyDir(src, dest, opts) {
330352
const dir = await opendir(src);
331353

0 commit comments

Comments
 (0)