macOS fsync and F_FULLFSYNC: File Data Is Not Rename Durability
Design macOS save paths with short-write handling, fsync versus F_FULLFSYNC, rename boundaries, and honest power-loss guarantees.
A successful write() means that the kernel accepted bytes from the process; it does not, by itself, prove that those bytes reached nonvolatile media. On macOS, fsync() asks the system to synchronize a file descriptor’s modified data and attributes, while fcntl(fd, F_FULLFSYNC, 0) asks for the stronger operation Apple describes as flushing the drive’s buffered data after the fsync work. Neither operation should be confused with renaming a temporary file into place: a name transition and file-content durability are separate parts of a save protocol.
This distinction matters for databases, local state stores, package indexes, and any application that reports “saved” when loss after a sudden power interruption would be unacceptable. It also matters when documenting limitations. A syscall can request a flush, but the storage device and the complete path to media must honor that request for the physical guarantee to hold.
Start with a complete write, not a single write call
write() may transfer fewer bytes than requested. A robust writer advances through the buffer until all bytes have been accepted, retries when interrupted before progress, and treats a zero-byte result for a nonempty request as a failure rather than looping forever.
#include <errno.h>
#include <limits.h>
#include <stddef.h>
#include <sys/types.h>
#include <unistd.h>
static int write_all(int fd, const void *buffer, size_t length) {
const unsigned char *p = buffer;
while (length != 0) {
size_t request = length > (size_t)SSIZE_MAX
? (size_t)SSIZE_MAX : length;
ssize_t n = write(fd, p, request);
if (n > 0) {
p += (size_t)n;
length -= (size_t)n;
continue;
}
if (n == -1 && errno == EINTR)
continue;
if (n == 0)
errno = EIO;
return -1;
}
return 0;
}
The loop handles partial progress correctly: if write() returns a positive count, those bytes are consumed even if the original request was larger. It retries only the no-progress EINTR case. The caller must still handle ENOSPC, EIO, an invalid descriptor, and other errors. The sample intentionally writes to an already-open descriptor; secure temporary-file creation, permissions, metadata, and cleanup are part of the surrounding application contract.
fsync and F_FULLFSYNC ask for different things
Apple’s archived fsync(2) manual says that fsync() moves modified data and attributes for the descriptor to a permanent storage device. The same page warns that the drive may defer physical media writes and reorder them, so a crash or loss of power can leave only some, or none, of the data present. The manual points applications requiring tighter integrity guarantees to F_FULLFSYNC.
The archived fcntl(2) page describes F_FULLFSYNC as performing the fsync operation and then asking the drive to flush buffered data to permanent storage. It also warns that the call may take a long time and that some FireWire drives have been known to ignore the flush request. Apple’s current public XNU header still defines F_FULLFSYNC as “fsync + ask the drive to flush to the media.” The header confirms the command exists in that source tree; it is not a current compatibility matrix for every filesystem, Mac model, external enclosure, or release.
For a product whose documented contract requires this stronger request, make its success a required stage:
#include <fcntl.h>
static int synchronize_file_for_strict_contract(int fd) {
/* F_FULLFSYNC is an Apple-specific fcntl command. */
if (fcntl(fd, F_FULLFSYNC, 0) == -1)
return -1;
return 0;
}
Do not silently replace a failed F_FULLFSYNC with fsync() and then report that the original durability contract was met. A fallback can be offered as an explicit, weaker mode if the product explains the residual risk and records which mode actually succeeded. If the caller does not require the stronger media-flush request, a successful fsync() may be the intended boundary; say exactly that rather than using the unqualified word “durable.”
A renamed file has a second persistence boundary
A common update pattern writes a new file and renames it over the previous path. Apple’s archived rename(2) manual says that source and destination must be on the same filesystem and that an instance of the new name is guaranteed to exist even if the system crashes in the middle of the rename operation. That is a namespace guarantee. It does not say that the bytes in the new file have been flushed to the storage device.
A sensible transaction therefore separates at least these stages: create a uniquely named temporary file in the destination filesystem; write the complete contents; synchronize the temporary file using the mode required by the application; check close errors; and rename the completed temporary file to the published name. If a step before rename fails, do not publish the incomplete file. If rename succeeds but a later operation fails, report that the new name may already be visible rather than claiming the operation rolled back.
Do not infer from F_FULLFSYNC on the temporary file descriptor that the parent directory’s rename metadata is also durably committed. The cited Apple fcntl documentation describes a request on the file descriptor, while the cited rename documentation describes name replacement; neither page in that archived set defines a complete cross-filesystem crash-recovery protocol for every current macOS storage stack. If surviving a power loss at every point in this transaction is a formal requirement, establish the exact sequence and guarantee for the supported macOS releases and filesystems, then test it on the actual storage hardware. Keep the data-flush claim and the namespace-recovery claim separate in both code and user-facing status.
Make failure states observable
A write or synchronization error is not a cosmetic warning. If the final path must remain unchanged until the new file meets the declared contract, stop before rename on a failed write, F_FULLFSYNC, or required close. Preserve the error and the transaction stage. A synchronization failure can reveal an earlier storage error, and retrying indefinitely can conceal a failing device while leaving the application without a known-good result.
After the rename succeeds, an error in any additional durability step cannot be described as “nothing changed”: the new name may already resolve for other processes. Model that as a published-but-not-confirmed-durable state and reconcile it on startup with a generation number, checksum, or journal if the application needs recovery. Retain enough diagnostics to identify the attempted file, operation, and stage, but do not log sensitive file contents.
Validate the actual storage path
The old manual’s filesystem list is historical: it names HFS, FAT, and UDF as implementations at the time that page was written. It should not be projected onto current APFS releases, third-party filesystems, virtual disks, USB bridges, or network volumes. Conversely, the current XNU header’s command definition is not proof that every device’s cache honors a flush. Test on the OS and storage configurations the application supports, and record the hardware and connection path in any reproducible durability test.
For fault testing, interrupt a disposable test system after each transaction stage and check both the bytes and the path visible after restart. Include full-volume and injected-I/O-error cases, not only graceful process termination. A process-kill test exercises application cleanup; it does not model a sudden power cut or an unresponsive device cache. Keep independent backups for user data regardless of the syscall sequence.
The practical rule is to make a precise promise: handle short writes, request the synchronization level the product needs, keep rename visibility distinct from media persistence, and fail visibly when the requested guarantee cannot be confirmed.
Related:
- APFS Explained: Snapshots, Clones, and Space Sharing in Apple’s Filesystem
- FileHandle Streaming I/O on macOS: Bounded Reads, EOF, and Ownership
Sources: