Skip to content
14 changes: 11 additions & 3 deletions bench/fs-cp/cp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
// bun cp.mjs
// node cp.mjs
//
// The "regular files only" trees are eligible for the whole-tree clonefile()
// fast path on macOS; the trees containing a symlink always go through the
// node-ported walker.
// The "regular files only" trees take the native copy (thread pool, or one
// whole-tree clonefile() on macOS). The trees containing a symlink take it on
// Linux only. macOS sends them through the node-ported walker, and Windows
// sends every tree through it. A `filter` always means the walker.
import { cpSync, mkdirSync, promises, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -72,6 +73,13 @@ recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, regular files
recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, tree contains a symlink)`, dest =>
promises.cp(symlinkSrc, dest, { recursive: true }),
);
recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, destination is an empty directory)`, dest => {
mkdirSync(dest);
return promises.cp(plainSrc, dest, { recursive: true });
});
recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, filter)`, dest =>
promises.cp(plainSrc, dest, { recursive: true, filter: () => true }),
);

try {
await run();
Expand Down
92 changes: 61 additions & 31 deletions src/js/internal/fs/cp-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
const { dirname, isAbsolute, join, parse, resolve, sep } = require("node:path");

const { EEXIST, EISDIR, EINVAL, ENOTDIR } = $processBindingConstants.os.errno;
const { COPYFILE_FICLONE_FORCE } = $processBindingConstants.fs;

const ArrayPrototypeEvery = Array.prototype.every;
const ArrayPrototypeFilter = Array.prototype.filter;
Expand Down Expand Up @@ -247,35 +248,62 @@ function checkParentPathsSync(src, srcStat, dest) {
return checkParentPathsSync(src, srcStat, destParent);
}

// The native recursive copy (a single clonefile() on macOS) copies symlinks
// verbatim and clones special files, while node rewrites relative symlink
// targets against the source tree and raises ERR_FS_CP_SOCKET /
// ERR_FS_CP_FIFO_PIPE. It is therefore only node-equivalent for trees made of
// regular files and directories; anything else — including entries whose type
// the filesystem does not report — bails to the ported walker. Scan errors
// also bail so the walker surfaces them the way node would.
function treeContainsOnlyFilesAndDirsSync(root) {
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isDirectory()) {
stack.push(join(dir, entry.name));
} else if (!entry.isFile()) {
// The native copy ignores `mode`, and only COPYFILE_FICLONE_FORCE changes the result (it must fail without a clone).
function nativeHonorsOptions(opts) {
return (
!opts.filter &&
!opts.dereference &&
!opts.preserveTimestamps &&
!opts.verbatimSymlinks &&
(opts.mode & COPYFILE_FICLONE_FORCE) === 0
);
}

// The native tree copy on Windows fails on paths longer than MAX_PATH.
const nativeCopiesTrees = process.platform !== "win32";

// Elsewhere the native copy rewrites a relative link target against the source tree, like node.
const nativeResolvesSymlinks = process.platform !== "darwin" && process.platform !== "win32";

// The native copy recurses once per directory level on a 4 MB thread stack.
const kNativeMaxDepth = 64;

// False for a tree the walker has to copy: special files (node's ERR_FS_CP_* errors), scan errors, too deep.
function nativeCanCopyTreeSync(root) {
let dirs = [root];
for (let depth = 0; dirs.length; depth++) {
if (depth > kNativeMaxDepth) return false;
const next = [];
for (let d = 0; d < dirs.length; d++) {
const dir = dirs[d];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isDirectory()) {
next.push(join(dir, entry.name));
} else if (!entry.isFile() && !(nativeResolvesSymlinks && entry.isSymbolicLink())) {
return false;
}
}
}
dirs = next;
}
return true;
}

function isEmptyDirSync(path) {
try {
return readdirSync(path).length === 0;
} catch {
return false;
}
}

// node-correct validation before handing off to the native fast path
// (which performs the copy but does not implement node's cp error codes).
function tryNativeFastPathSync(src, dest, opts) {
Expand All @@ -292,20 +320,18 @@ function tryNativeFastPathSync(src, dest, opts) {
});
}
if (srcStat.isDirectory()) {
// On macOS the native path clones the whole tree with a single
// clonefile(). Only take it when the result is indistinguishable from
// node's walker: dest must not exist (no merge semantics) and the tree
// must contain only regular files and directories.
return {
ok: process.platform === "darwin" && !destStat && treeContainsOnlyFilesAndDirsSync(src),
checked,
};
// node rejects an existing dest directory, even an empty one, for `errorOnExist` without `force`.
const nothingToMerge = !destStat || (!(opts.errorOnExist && !opts.force) && isEmptyDirSync(dest));
return { ok: nativeCopiesTrees && nothingToMerge && nativeCanCopyTreeSync(src), checked };
}
// The single-file native copy is only node-equivalent for regular-file ->
// regular-file (or missing dest). Symlinks (node resolves relative link
// targets) and special files (node-specific error codes) must go through
// the ported implementation.
return { ok: srcStat.isFile() && (!destStat || destStat.isFile()), checked };
if (!srcStat.isFile()) return { ok: false, checked };
// node unlinks an existing dest first and the native copy overwrites it in place: default options only, as before.
const overwrites = opts.force && !opts.errorOnExist && opts.mode === 0;
return { ok: !destStat || (overwrites && destStat.isFile()), checked };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function cpSyncFn(src, dest, opts, checked?) {
Expand Down Expand Up @@ -523,4 +549,8 @@ export default {
fsEisdirError,
areIdentical,
isSrcSubdir,
kNativeMaxDepth,
nativeCopiesTrees,
nativeHonorsOptions,
nativeResolvesSymlinks,
};
78 changes: 47 additions & 31 deletions src/js/internal/fs/cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const {
fsEisdirError,
areIdentical,
isSrcSubdir,
kNativeMaxDepth,
nativeCopiesTrees,
nativeResolvesSymlinks,
} = require("internal/fs/cp-sync");

const {
Expand Down Expand Up @@ -119,35 +122,50 @@ async function checkParentPaths(src, srcStat, dest) {
return checkParentPaths(src, srcStat, destParent);
}

// The native recursive copy (a single clonefile() on macOS) copies symlinks
// verbatim and clones special files, while node rewrites relative symlink
// targets against the source tree and raises ERR_FS_CP_SOCKET /
// ERR_FS_CP_FIFO_PIPE. It is therefore only node-equivalent for trees made of
// regular files and directories; anything else — including entries whose type
// the filesystem does not report — bails to the ported walker. Scan errors
// also bail so the walker surfaces them the way node would.
async function treeContainsOnlyFilesAndDirs(root) {
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return false;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isDirectory()) {
stack.push(join(dir, entry.name));
} else if (!entry.isFile()) {
const kScanConcurrency = 64;

// False for a tree the walker has to copy: special files (node's ERR_FS_CP_* errors), scan errors, too deep.
async function nativeCanCopyTree(root) {
let dirs = [root];
for (let depth = 0; dirs.length; depth++) {
if (depth > kNativeMaxDepth) return false;
const next = [];
for (let start = 0; start < dirs.length; start += kScanConcurrency) {
const pending = [];
for (let d = start; d < dirs.length && d < start + kScanConcurrency; d++) {
pending.push(readdir(dirs[d], { withFileTypes: true }));
}
let lists;
try {
lists = await Promise.all(pending);
} catch {
return false;
}
for (let d = 0; d < lists.length; d++) {
const entries = lists[d];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.isDirectory()) {
next.push(join(dirs[start + d], entry.name));
} else if (!entry.isFile() && !(nativeResolvesSymlinks && entry.isSymbolicLink())) {
return false;
}
}
}
}
dirs = next;
}
return true;
}

async function isEmptyDir(path) {
try {
return (await readdir(path)).length === 0;
} catch {
return false;
}
}

// node-correct validation before handing off to the native fast path
// (which performs the copy but does not implement node's cp error codes).
async function tryNativeFastPath(src, dest, opts) {
Expand All @@ -164,20 +182,18 @@ async function tryNativeFastPath(src, dest, opts) {
});
}
if (srcStat.isDirectory()) {
// On macOS the native path clones the whole tree with a single
// clonefile(). Only take it when the result is indistinguishable from
// node's walker: dest must not exist (no merge semantics) and the tree
// must contain only regular files and directories.
return {
ok: process.platform === "darwin" && !destStat && (await treeContainsOnlyFilesAndDirs(src)),
checked,
};
// node rejects an existing dest directory, even an empty one, for `errorOnExist` without `force`.
const nothingToMerge = !destStat || (!(opts.errorOnExist && !opts.force) && (await isEmptyDir(dest)));
return { ok: nativeCopiesTrees && nothingToMerge && (await nativeCanCopyTree(src)), checked };
}
// The single-file native copy is only node-equivalent for regular-file ->
// regular-file (or missing dest). Symlinks (node resolves relative link
// targets) and special files (node-specific error codes) must go through
// the ported implementation.
return { ok: srcStat.isFile() && (!destStat || destStat.isFile()), checked };
if (!srcStat.isFile()) return { ok: false, checked };
// node unlinks an existing dest first and the native copy overwrites it in place: default options only, as before.
const overwrites = opts.force && !opts.errorOnExist && opts.mode === 0;
return { ok: !destStat || (overwrites && destStat.isFile()), checked };
}

async function cpFn(src, dest, opts, checked?) {
Expand Down
9 changes: 5 additions & 4 deletions src/js/node/fs.promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,17 @@ function watch(
// and on MacOS, simple cases of recursive directory trees can be done in a single `clonefile()`
// using filter and other options uses a lazily loaded js fallback ported from node.js
async function cp(src, dest, options) {
const { validateCpOptions } = require("internal/fs/cp-sync");
const { validateCpOptions, nativeHonorsOptions } = require("internal/fs/cp-sync");
const { getValidatedFsPath } = require("internal/validators");
options = validateCpOptions(options);
src = getValidatedFsPath(src, "src");
dest = getValidatedFsPath(dest, "dest");
const { filter, dereference, preserveTimestamps, verbatimSymlinks, mode, errorOnExist, force, recursive } = options;
if (!filter && !dereference && !preserveTimestamps && !verbatimSymlinks && !mode && !errorOnExist && force) {
if (nativeHonorsOptions(options)) {
const { ok, checked } = await require("internal/fs/cp").tryNativeFastPath(src, dest, options);
if (ok) {
return fs.cp(src, dest, recursive, errorOnExist, force, mode);
const { errorOnExist, force, recursive, mode } = options;
// node ignores `errorOnExist` when `force` is set.
return fs.cp(src, dest, recursive, errorOnExist && !force, force, mode);
}
return require("internal/fs/cp").cpFn(src, dest, options, checked);
}
Expand Down
9 changes: 5 additions & 4 deletions src/js/node/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,16 +968,17 @@ realpathSync.native = fs.realpathNativeSync.bind(fs);
// and on MacOS, simple cases of recursive directory trees can be done in a single `clonefile()`
// using filter and other options uses a lazily loaded js fallback ported from node.js
function cpSync(src, dest, options) {
const { cpSyncFn, validateCpOptions, tryNativeFastPathSync } = require("internal/fs/cp-sync");
const { cpSyncFn, validateCpOptions, nativeHonorsOptions, tryNativeFastPathSync } = require("internal/fs/cp-sync");
const { getValidatedFsPath } = require("internal/validators");
options = validateCpOptions(options);
src = getValidatedFsPath(src, "src");
dest = getValidatedFsPath(dest, "dest");
const { filter, dereference, preserveTimestamps, verbatimSymlinks, mode, errorOnExist, force, recursive } = options;
if (!filter && !dereference && !preserveTimestamps && !verbatimSymlinks && !mode && !errorOnExist && force) {
if (nativeHonorsOptions(options)) {
const { ok, checked } = tryNativeFastPathSync(src, dest, options);
if (ok) {
return fs.cpSync(src, dest, recursive, errorOnExist, force, mode);
const { errorOnExist, force, recursive, mode } = options;
// node ignores `errorOnExist` when `force` is set.
return fs.cpSync(src, dest, recursive, errorOnExist && !force, force, mode);
}
return cpSyncFn(src, dest, options, checked);
}
Expand Down
Loading
Loading