diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index 473688b4b7..17183344bb 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -43,6 +43,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] def get_birthtime_ns(st, path, fd=None): diff --git a/src/borg/platform/base.py b/src/borg/platform/base.py index 84081a8290..184002da3c 100644 --- a/src/borg/platform/base.py +++ b/src/borg/platform/base.py @@ -143,7 +143,6 @@ class SyncFile: Calling SyncFile(path) for an existing path will raise FileExistsError, see 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 d0e3f948c1..e12a4bec4c 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -1,3 +1,4 @@ +import fcntl import os from libc.stdint cimport uint32_t @@ -259,3 +260,30 @@ 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)) + + +def fdatasync(fd): + """macOS fdatasync using F_FULLFSYNC for true data durability. + + os.fsync() is an OS-level flush (kernel page cache -> drive write buffer). + F_FULLFSYNC additionally issues a HW-level flush (drive write buffer -> persistent storage). + Falls back to os.fsync() if F_FULLFSYNC is not supported (e.g. network fs). + """ + try: + fcntl.fcntl(fd, fcntl.F_FULLFSYNC) + except OSError: + os.fsync(fd) + + +def sync_dir(path): + """Sync a directory to persistent storage on macOS using F_FULLFSYNC.""" + if isinstance(path, str): + path = os.fsencode(path) + fd = os.open(path, os.O_RDONLY) + try: + fdatasync(fd) + except OSError as os_error: + if os_error.errno != errno.EINVAL: + raise + finally: + os.close(fd) diff --git a/src/borg/testsuite/platform.py b/src/borg/testsuite/platform.py index 0c5781baef..81f0d017df 100644 --- a/src/borg/testsuite/platform.py +++ b/src/borg/testsuite/platform.py @@ -9,6 +9,7 @@ from ..platformflags import is_win32, is_linux, is_freebsd, is_netbsd, is_darwin, is_haiku from ..platform import acl_get, acl_set, swidth from ..platform import get_process_id, process_alive +from ..platform import fdatasync, sync_dir from . import BaseTestCase, unopened_tempfile from .locking import free_pid @@ -221,6 +222,68 @@ def test_extended_acl(self): self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000::0:allow:read', self.get_acl(file2.name, numeric_ids=True)['acl_extended']) +@unittest.skipUnless(is_darwin, 'macOS only test') +def test_fdatasync_uses_f_fullfsync(monkeypatch): + 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' + + +@unittest.skipUnless(is_darwin, 'macOS only test') +def test_fdatasync_falls_back_to_fsync(monkeypatch): + 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) + + # Cython does runtime attribute lookup on module objects, so patching + # fcntl.fcntl and os.fsync here affects darwin.fdatasync as expected. + 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(): + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'test data for fdatasync') + tmp.flush() + fdatasync(tmp.fileno()) + + +def test_sync_dir_basic(): + with tempfile.TemporaryDirectory() as tmpdir: + sync_dir(tmpdir) + + @unittest.skipUnless(sys.platform.startswith(('linux', 'freebsd', 'darwin')), 'POSIX only tests') class PlatformPosixTestCase(BaseTestCase):