Skip to content

fsfork: Clone with copyfile on macOS, as clonefile(2) says to - #13

Open
penberg wants to merge 2 commits into
mainfrom
fsfork-copyfile
Open

penberg wants to merge 2 commits into
mainfrom
fsfork-copyfile

Conversation

@penberg

@penberg penberg commented Nov 9, 2026

Copy link
Copy Markdown
Owner

The clonefile(2) man page says, under LIMITATIONS:

Cloning directories with these functions is strongly discouraged. Use copyfile(3) to clone directories instead.

The man page doesn't say why, but Apple's DTS does:

  • Why is using clonefile for a folder strongly discouraged? (Kevin Elliott, DTS Engineer, accepted answer): cloning a directory pushes the copy into the kernel and "[prevents] all changes to the source hierarchy while the operation is in progress, making the process atomic. That second point is what makes this potentially dangerous as, in the worst case, you could theoretically panic the kernel by stalling all activity on critical locations long enough that the kernel 'gives up' and panics."
  • Clarification on clonefile / copyfile support of clone directories?: the warning "only applies to directly using the clonefile function on directories, because it ties up the filesystem in ways that can be detrimental to the overall system health. On the other hand cloning directories recursively with copyfile is perfectly fine, because copyfile internally only calls clonefile on each file separately."

fsfork clones the directory an agent works in, often with target/ or node_modules in it, so it is the case this warns about.

Change

fsfork/darwin.rs clones with copyfile(COPYFILE_CLONE | COPYFILE_RECURSIVE), and makes up for what it does not do as clonefile did:

copyfile so
copies into to if it exists the clone is made at .<to>.<pid>.part and moved to to with renamex_np(RENAME_EXCL), so it fails if to exists (fork numbering relies on that), and to is never half made
fails with ENOTSUP on FIFOs and sockets a status callback makes FIFOs anew and leaves sockets and devices out, as clone_entries already did beside what is ignored
makes symlinks after setting their directory's mtime directory mtimes are set again once done
falls back to a full copy on another volume that fails with EXDEV, as clonefile did
follows a symlink given as the source entries that aren't directories are cloned without COPYFILE_RECURSIVE

A file deleted while the directory is being cloned is left out rather than failing the fork.

README and fsfork(1) say what changed, including that a fork is no longer one moment of the directory.

Trade-offs

  • Not atomic: clonefile on a directory gave a point-in-time copy of the whole tree. Now, what is written to the directory while it is forked may be in the fork or not, each file by itself. The fork and its base are still the same, as the fork is cloned from the base.
  • Slower: on this repo's target/ (6,838 entries, 386 MB), fsfork --no-ignore takes 2.1 s instead of 0.18 s, as Apple's "~10×" says.
  • Leftovers: a fork killed while cloning leaves a hidden .<name>.<pid>.part next to it, which --prune doesn't remove.

Elliott says directory clonefile is fine on a hierarchy the app controls. The base is that (hidden, and nothing writes to it), so cloning the fork from the base with clonefile would win back half the time. I left that out to keep to the man page; say if you want it.

Testing

  • cargo test: all pass, with a new test, copies_fifos_links_and_modification_times_deep_in_the_directory, which checks:
    • nested FIFOs, sockets and dangling symlinks
    • directory mtimes, which it fails without the fix
    • that no .part is left behind
  • cargo clippy --all-targets: clean.

clonefile(2) says "Cloning directories with these functions is strongly
discouraged. Use copyfile(3) to clone directories instead." Apple's DTS
says why: cloning a directory holds off every change to it until the
clone is done, which on a large directory, or one the system uses, can
stall the system, and in the worst case panic the kernel:

- https://developer.apple.com/forums/thread/784446
- https://developer.apple.com/forums/thread/818372

So a directory is now cloned with copyfile(COPYFILE_CLONE |
COPYFILE_RECURSIVE), which clones each file. What it does not do as
clonefile did is made up for:

- It copies into `to` if it exists, so the clone is made next to it and
  moved there with renamex_np(RENAME_EXCL), which fails if it exists,
  and leaves `to` never half made.
- It fails on FIFOs and sockets, so a callback makes FIFOs anew and
  leaves sockets and devices out, as beside what is ignored already.
- It makes symbolic links after setting their directory's modification
  time, so the times are set again once it is done.
- It falls back to copying on another volume, so that fails, as before.
- It follows a symbolic link given to it, so entries that are not
  directories are cloned without COPYFILE_RECURSIVE.

A file gone while the directory is cloned is left out. The fork is no
longer one moment of the directory: what is written to it meanwhile may
be in the fork or not, each file by itself, as the manual page now says.
On Linux, cp -a makes sockets anew, so the test of forking deep in the
directory failed there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant