diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index ea0914fc5a..a4d0696c0a 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -50,6 +50,7 @@ from .darwin import acl_get, acl_set from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns from .darwin import set_flags + from .darwin import fdatasync, sync_dir # type: ignore[no-redef] from .base import get_flags from .base import SyncFile from .posix import process_alive, local_pid_alive diff --git a/src/borg/platform/base.py b/src/borg/platform/base.py index 53c1a3563c..06fcab206a 100644 --- a/src/borg/platform/base.py +++ b/src/borg/platform/base.py @@ -151,7 +151,6 @@ class SyncFile: Calling SyncFile(path) for an existing path will raise FileExistsError. See the comment in __init__. - TODO: Use F_FULLSYNC on macOS. TODO: A Windows implementation should use CreateFile with FILE_FLAG_WRITE_THROUGH. """ diff --git a/src/borg/platform/darwin.pyx b/src/borg/platform/darwin.pyx index 4d73ff5316..72a2da7aad 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -262,3 +262,33 @@ def set_flags(path, bsd_flags, fd=None): path_bytes = os.fsencode(path) if lchflags(path_bytes, c_flags) == -1: raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path_bytes)) + + +import errno as errno_mod +import fcntl as fcntl_mod + + +def fdatasync(fd): + """macOS fdatasync using F_FULLFSYNC for true data durability. + + On macOS, os.fsync() only flushes to the drive's write cache. + fcntl F_FULLFSYNC flushes to persistent storage. + Falls back to os.fsync() if F_FULLFSYNC is not supported. + """ + try: + fcntl_mod.fcntl(fd, fcntl_mod.F_FULLFSYNC) + except OSError: + # F_FULLFSYNC not supported (e.g. network filesystem), fall back + os.fsync(fd) + + +def sync_dir(path): + """Sync a directory to persistent storage on macOS using F_FULLFSYNC.""" + fd = os.open(str(path), os.O_RDONLY) + try: + fdatasync(fd) + except OSError as os_error: + if os_error.errno != errno_mod.EINVAL: + raise + finally: + os.close(fd) diff --git a/src/borg/testsuite/platform/darwin_test.py b/src/borg/testsuite/platform/darwin_test.py index 18e1a2a513..c69a7e1452 100644 --- a/src/borg/testsuite/platform/darwin_test.py +++ b/src/borg/testsuite/platform/darwin_test.py @@ -2,6 +2,7 @@ import tempfile from ...platform import acl_get, acl_set +from ...platform import fdatasync, sync_dir from .platform_test import skipif_not_darwin, skipif_fakeroot_detected, skipif_acls_not_working # Set module-level skips @@ -46,3 +47,65 @@ def test_extended_acl(): b"group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000::0:allow:read" in get_acl(file2.name, numeric_ids=True)["acl_extended"] ) + + +def test_fdatasync_uses_f_fullfsync(monkeypatch): + """Verify fcntl F_FULLFSYNC is called.""" + import fcntl as fcntl_mod + from ...platform import darwin + + calls = [] + original_fcntl = fcntl_mod.fcntl + + def mock_fcntl(fd, cmd, *args): + calls.append((fd, cmd)) + return original_fcntl(fd, cmd, *args) + + monkeypatch.setattr(fcntl_mod, "fcntl", mock_fcntl) + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b"test data") + tmp.flush() + darwin.fdatasync(tmp.fileno()) + + assert any(cmd == fcntl_mod.F_FULLFSYNC for _, cmd in calls), "fdatasync should call fcntl with F_FULLFSYNC" + + +def test_fdatasync_falls_back_to_fsync(monkeypatch): + """Verify os.fsync fallback when F_FULLFSYNC fails.""" + import fcntl as fcntl_mod + from ...platform import darwin + + fsync_calls = [] + + def mock_fcntl(fd, cmd, *args): + if cmd == fcntl_mod.F_FULLFSYNC: + raise OSError("F_FULLFSYNC not supported") + return 0 + + def mock_fsync(fd): + fsync_calls.append(fd) + + monkeypatch.setattr(fcntl_mod, "fcntl", mock_fcntl) + monkeypatch.setattr(os, "fsync", mock_fsync) + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b"test data") + tmp.flush() + darwin.fdatasync(tmp.fileno()) + + assert len(fsync_calls) == 1, "Should fall back to os.fsync when F_FULLFSYNC fails" + + +def test_fdatasync_basic(): + """Integration: fdatasync completes on a real file without error.""" + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b"test data for fdatasync") + tmp.flush() + fdatasync(tmp.fileno()) + + +def test_sync_dir_basic(): + """Integration: sync_dir completes on a real directory without error.""" + with tempfile.TemporaryDirectory() as tmpdir: + sync_dir(tmpdir)