From 23c40e6c8015923dda3ab038df437e4c40e407e8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 26 Feb 2026 13:52:13 -0400 Subject: [PATCH 1/2] Implement `sourcemeta::core::atomic_directory_replace` Signed-off-by: Juan Cruz Viotti --- src/lang/io/include/sourcemeta/core/io.h | 19 ++++ src/lang/io/io.cc | 26 +++++ test/io/CMakeLists.txt | 3 +- test/io/io_atomic_directory_replace_test.cc | 116 ++++++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 test/io/io_atomic_directory_replace_test.cc diff --git a/src/lang/io/include/sourcemeta/core/io.h b/src/lang/io/include/sourcemeta/core/io.h index ac607c8e8..b31fd7124 100644 --- a/src/lang/io/include/sourcemeta/core/io.h +++ b/src/lang/io/include/sourcemeta/core/io.h @@ -116,6 +116,25 @@ SOURCEMETA_CORE_IO_EXPORT auto hardlink_directory(const std::filesystem::path &source, const std::filesystem::path &destination) -> void; +/// @ingroup io +/// +/// Replace one directory with another using rename-swap with rollback. +/// Both directories must reside on the same filesystem and the original +/// path must not be a bare filename (it must have a parent component). +/// If the original does not exist, the replacement is simply renamed into +/// place. On failure, the original is restored from a temporary backup. +/// Note that there is a brief window between the two renames where the +/// original path does not exist. +/// +/// ```cpp +/// #include +/// +/// sourcemeta::core::atomic_directory_replace("/output", "/staging"); +/// ``` +SOURCEMETA_CORE_IO_EXPORT +auto atomic_directory_replace(const std::filesystem::path &original, + const std::filesystem::path &replacement) -> void; + /// @ingroup io /// /// Flush an existing file to disk, beyond just to the operating system. For diff --git a/src/lang/io/io.cc b/src/lang/io/io.cc index 745ddb56e..e4e2402d7 100644 --- a/src/lang/io/io.cc +++ b/src/lang/io/io.cc @@ -66,6 +66,32 @@ auto hardlink_directory(const std::filesystem::path &source, } } +auto atomic_directory_replace(const std::filesystem::path &original, + const std::filesystem::path &replacement) + -> void { + assert(std::filesystem::is_directory(replacement)); + assert(!std::filesystem::exists(original) || + std::filesystem::is_directory(original)); + assert(!original.parent_path().empty()); + + if (!std::filesystem::exists(original)) { + std::filesystem::rename(replacement, original); + return; + } + + // Note we cannot safely use the temporary directory of the + // system as it might be in another volume + TemporaryDirectory backup{original.parent_path(), ".backup-"}; + std::filesystem::remove(backup.path()); + std::filesystem::rename(original, backup.path()); + try { + std::filesystem::rename(replacement, original); + } catch (...) { + std::filesystem::rename(backup.path(), original); + throw; + } +} + auto flush(const std::filesystem::path &path) -> void { #if defined(_WIN32) HANDLE hFile = diff --git a/test/io/CMakeLists.txt b/test/io/CMakeLists.txt index 55500c59b..d1c1e9462 100644 --- a/test/io/CMakeLists.txt +++ b/test/io/CMakeLists.txt @@ -7,7 +7,8 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME io io_read_file_test.cc io_fileview_test.cc io_temporary_test.cc - io_hardlink_directory_test.cc) + io_hardlink_directory_test.cc + io_atomic_directory_replace_test.cc) target_link_libraries(sourcemeta_core_io_unit PRIVATE sourcemeta::core::io) diff --git a/test/io/io_atomic_directory_replace_test.cc b/test/io/io_atomic_directory_replace_test.cc new file mode 100644 index 000000000..dc7299d41 --- /dev/null +++ b/test/io/io_atomic_directory_replace_test.cc @@ -0,0 +1,116 @@ +#include + +#include + +#include // std::filesystem +#include // std::ofstream, std::ifstream +#include // std::string + +TEST(IO_atomic_directory_replace, creates_when_original_absent) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-atomic-"}; + const auto original{workspace.path() / "original"}; + const auto replacement{workspace.path() / "replacement"}; + + std::filesystem::create_directory(replacement); + std::ofstream{replacement / "file.txt"} << "hello"; + + EXPECT_FALSE(std::filesystem::exists(original)); + + sourcemeta::core::atomic_directory_replace(original, replacement); + + EXPECT_TRUE(std::filesystem::exists(original)); + EXPECT_TRUE(std::filesystem::is_directory(original)); + EXPECT_FALSE(std::filesystem::exists(replacement)); + + std::ifstream stream{original / "file.txt"}; + std::string content; + std::getline(stream, content); + EXPECT_EQ(content, "hello"); +} + +TEST(IO_atomic_directory_replace, replaces_existing_directory) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-atomic-"}; + const auto original{workspace.path() / "original"}; + const auto replacement{workspace.path() / "replacement"}; + + std::filesystem::create_directory(original); + std::ofstream{original / "old.txt"} << "old"; + + std::filesystem::create_directory(replacement); + std::ofstream{replacement / "new.txt"} << "new"; + + sourcemeta::core::atomic_directory_replace(original, replacement); + + EXPECT_TRUE(std::filesystem::exists(original)); + EXPECT_FALSE(std::filesystem::exists(original / "old.txt")); + EXPECT_TRUE(std::filesystem::exists(original / "new.txt")); + + std::ifstream stream{original / "new.txt"}; + std::string content; + std::getline(stream, content); + EXPECT_EQ(content, "new"); +} + +TEST(IO_atomic_directory_replace, replacement_is_consumed) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-atomic-"}; + const auto original{workspace.path() / "original"}; + const auto replacement{workspace.path() / "replacement"}; + + std::filesystem::create_directory(original); + std::filesystem::create_directory(replacement); + std::ofstream{replacement / "file.txt"} << "data"; + + sourcemeta::core::atomic_directory_replace(original, replacement); + + EXPECT_FALSE(std::filesystem::exists(replacement)); +} + +TEST(IO_atomic_directory_replace, preserves_nested_structure) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-atomic-"}; + const auto original{workspace.path() / "original"}; + const auto replacement{workspace.path() / "replacement"}; + + std::filesystem::create_directory(original); + + std::filesystem::create_directories(replacement / "a" / "b"); + std::ofstream{replacement / "root.txt"} << "root"; + std::ofstream{replacement / "a" / "mid.txt"} << "mid"; + std::ofstream{replacement / "a" / "b" / "deep.txt"} << "deep"; + + sourcemeta::core::atomic_directory_replace(original, replacement); + + EXPECT_TRUE(std::filesystem::exists(original / "root.txt")); + EXPECT_TRUE(std::filesystem::exists(original / "a" / "mid.txt")); + EXPECT_TRUE(std::filesystem::exists(original / "a" / "b" / "deep.txt")); + + std::ifstream stream{original / "a" / "b" / "deep.txt"}; + std::string content; + std::getline(stream, content); + EXPECT_EQ(content, "deep"); +} + +TEST(IO_atomic_directory_replace, no_leftover_backup) { + const sourcemeta::core::TemporaryDirectory workspace{ + std::filesystem::path{BUILD_DIRECTORY}, ".test-atomic-"}; + const auto original{workspace.path() / "original"}; + const auto replacement{workspace.path() / "replacement"}; + + std::filesystem::create_directory(original); + std::ofstream{original / "old.txt"} << "old"; + + std::filesystem::create_directory(replacement); + std::ofstream{replacement / "new.txt"} << "new"; + + sourcemeta::core::atomic_directory_replace(original, replacement); + + for (const auto &entry : + std::filesystem::directory_iterator{workspace.path()}) { + const auto filename{entry.path().filename().string()}; + EXPECT_FALSE(filename.starts_with(".backup-")) + << "leftover backup directory found: " << filename; + } +} From d717dc38515118ca66c110574bd9167b92a3a29e Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 26 Feb 2026 14:25:30 -0400 Subject: [PATCH 2/2] More atomicity Signed-off-by: Juan Cruz Viotti --- src/lang/io/include/sourcemeta/core/io.h | 12 ++++---- src/lang/io/io.cc | 36 ++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/lang/io/include/sourcemeta/core/io.h b/src/lang/io/include/sourcemeta/core/io.h index b31fd7124..aa949f944 100644 --- a/src/lang/io/include/sourcemeta/core/io.h +++ b/src/lang/io/include/sourcemeta/core/io.h @@ -118,13 +118,11 @@ auto hardlink_directory(const std::filesystem::path &source, /// @ingroup io /// -/// Replace one directory with another using rename-swap with rollback. -/// Both directories must reside on the same filesystem and the original -/// path must not be a bare filename (it must have a parent component). -/// If the original does not exist, the replacement is simply renamed into -/// place. On failure, the original is restored from a temporary backup. -/// Note that there is a brief window between the two renames where the -/// original path does not exist. +/// Replace one directory with another, guaranteeing an atomic swap when +/// possible. Both directories must reside on the same filesystem and the +/// original path must not be a bare filename (it must have a parent +/// component). If the original does not exist, the replacement is simply +/// renamed into place. /// /// ```cpp /// #include diff --git a/src/lang/io/io.cc b/src/lang/io/io.cc index e4e2402d7..cea7b3733 100644 --- a/src/lang/io/io.cc +++ b/src/lang/io/io.cc @@ -6,8 +6,14 @@ #include // HANDLE, CreateFileW, FlushFileBuffers, CloseHandle #else #include // errno (for error codes) -#include // open, O_RDWR +#include // open, O_RDWR, AT_FDCWD #include // close, fsync +#if defined(__linux__) +#include // RENAME_EXCHANGE +#include // renameat2 +#elif defined(__APPLE__) +#include // renameatx_np, RENAME_SWAP +#endif #endif namespace sourcemeta::core { @@ -79,8 +85,31 @@ auto atomic_directory_replace(const std::filesystem::path &original, return; } - // Note we cannot safely use the temporary directory of the - // system as it might be in another volume + // Atomic swap via renameat2 with RENAME_EXCHANGE +#if defined(__linux__) + if (renameat2(AT_FDCWD, replacement.c_str(), AT_FDCWD, original.c_str(), + RENAME_EXCHANGE) != 0) { + throw std::filesystem::filesystem_error{ + "failed to atomically swap directories", replacement, original, + std::error_code{errno, std::generic_category()}}; + } + + std::filesystem::remove_all(replacement); + // Atomic swap via renameatx_np with RENAME_SWAP +#elif defined(__APPLE__) + if (renameatx_np(AT_FDCWD, replacement.c_str(), AT_FDCWD, original.c_str(), + RENAME_SWAP) != 0) { + throw std::filesystem::filesystem_error{ + "failed to atomically swap directories", replacement, original, + std::error_code{errno, std::generic_category()}}; + } + + std::filesystem::remove_all(replacement); +#else + // Non-atomic fallback: two-rename approach with rollback. + + // Note we cannot safely use the temporary directory of the system as it + // might be in another volume TemporaryDirectory backup{original.parent_path(), ".backup-"}; std::filesystem::remove(backup.path()); std::filesystem::rename(original, backup.path()); @@ -90,6 +119,7 @@ auto atomic_directory_replace(const std::filesystem::path &original, std::filesystem::rename(backup.path(), original); throw; } +#endif } auto flush(const std::filesystem::path &path) -> void {