diff --git a/src/lang/io/include/sourcemeta/core/io.h b/src/lang/io/include/sourcemeta/core/io.h index ac607c8e8..aa949f944 100644 --- a/src/lang/io/include/sourcemeta/core/io.h +++ b/src/lang/io/include/sourcemeta/core/io.h @@ -116,6 +116,23 @@ 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, 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 +/// +/// 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..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 { @@ -66,6 +72,56 @@ 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; + } + + // 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()); + try { + std::filesystem::rename(replacement, original); + } catch (...) { + std::filesystem::rename(backup.path(), original); + throw; + } +#endif +} + 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; + } +}