From 56c4e277b454402168da6571f1d12886d6fed95f Mon Sep 17 00:00:00 2001 From: Danshet <264011288+Danshet@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:30:54 +0200 Subject: [PATCH] Unit-test the atomic write path (deterministic torn-write drill) A crash cannot be unit-tested, but the disk state it leaves behind can. each failure point of the atomic write path: an abandoned temp, a death between the two commit renames, a stale write-in-progress marker, is constructed directly on disk and the recovery code asserted against it. The drill runs device-level without the game, deterministically. --- .../rexglue-sdk/tests/unit/CMakeLists.txt | 2 + .../unit/system/host_path_atomic_test.cpp | 160 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 thirdparty/rexglue-sdk/tests/unit/system/host_path_atomic_test.cpp diff --git a/thirdparty/rexglue-sdk/tests/unit/CMakeLists.txt b/thirdparty/rexglue-sdk/tests/unit/CMakeLists.txt index 8bd57a51..5dccaf83 100644 --- a/thirdparty/rexglue-sdk/tests/unit/CMakeLists.txt +++ b/thirdparty/rexglue-sdk/tests/unit/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(unit_tests memory/heap_allocation_test.cpp kernel/object_table_test.cpp kernel/xam_user_alias_test.cpp + system/host_path_atomic_test.cpp core/cvar_test.cpp core/fiber_test.cpp core/sha256_test.cpp @@ -22,6 +23,7 @@ target_link_libraries(unit_tests PRIVATE rexcore rexcodegen rexkernel + rexfilesystem Catch2::Catch2WithMain ) diff --git a/thirdparty/rexglue-sdk/tests/unit/system/host_path_atomic_test.cpp b/thirdparty/rexglue-sdk/tests/unit/system/host_path_atomic_test.cpp new file mode 100644 index 00000000..dc8222a6 --- /dev/null +++ b/thirdparty/rexglue-sdk/tests/unit/system/host_path_atomic_test.cpp @@ -0,0 +1,160 @@ +/** + * @file tests/unit/system/host_path_atomic_test.cpp + * @brief Unit tests for the atomic write path of the host-path device + * (torn saves / .rex-tmp / .rex-bak / write marker) + * + * @license BSD 3-Clause License + * See LICENSE file in the project root for full license text. + */ + +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; +using rex::filesystem::FileAccess; +using rex::filesystem::HostPathDevice; + +namespace { + +struct TempDir { + fs::path path; + explicit TempDir(const char* name) { + path = fs::temp_directory_path() / "rex_host_path_atomic_tests" / name; + fs::remove_all(path); + fs::create_directories(path); + } + ~TempDir() { + std::error_code ec; + fs::remove_all(path, ec); + } +}; + +void WriteHostFile(const fs::path& p, const std::string& content) { + std::ofstream f(p, std::ios::binary | std::ios::trunc); + f << content; +} + +std::string ReadHostFile(const fs::path& p) { + std::ifstream f(p, std::ios::binary); + return std::string(std::istreambuf_iterator(f), std::istreambuf_iterator()); +} + +} // namespace + +TEST_CASE("atomic write: in-flight data lives in the temp; commit swaps it in with one .bak", + "[filesystem][atomic_write]") { + TempDir dir("commit"); + WriteHostFile(dir.path / "save.dat", "OLD"); + + HostPathDevice device("\\Device\\AtomicTest1\\", dir.path, false); + REQUIRE(device.Initialize()); + auto* entry = device.ResolvePath("save.dat"); + REQUIRE(entry != nullptr); + + rex::filesystem::File* file = nullptr; + REQUIRE(entry->Open(FileAccess::kGenericRead | FileAccess::kGenericWrite, &file) == 0); + REQUIRE(file != nullptr); + + const std::string new_content = "NEW!"; + size_t written = 0; + REQUIRE(file->WriteSync({reinterpret_cast(new_content.data()), + new_content.size()}, + 0, &written) == 0); + CHECK(written == new_content.size()); + + // The write went to the temp, NOT the real file: an interruption here + // leaves the old data untouched. + CHECK(ReadHostFile(dir.path / "save.dat") == "OLD"); + CHECK(fs::exists(dir.path / "save.dat.rex-tmp")); + + // Close = commit: temp renamed over the real file, previous generation + // kept as .rex-bak. + file->Destroy(); + CHECK(ReadHostFile(dir.path / "save.dat") == "NEW!"); + CHECK(ReadHostFile(dir.path / "save.dat.rex-bak") == "OLD"); + CHECK(!fs::exists(dir.path / "save.dat.rex-tmp")); +} + +TEST_CASE("atomic write: a stale temp from a dead session is swept at mount, old data intact", + "[filesystem][atomic_write]") { + TempDir dir("sweep"); + WriteHostFile(dir.path / "save.dat", "OLD"); + // A session died mid-write and left its temp behind. + WriteHostFile(dir.path / "save.dat.rex-tmp", "TORN-PARTIAL-WRITE"); + + HostPathDevice device("\\Device\\AtomicTest2\\", dir.path, false); + REQUIRE(device.Initialize()); + + CHECK(ReadHostFile(dir.path / "save.dat") == "OLD"); + CHECK(!fs::exists(dir.path / "save.dat.rex-tmp")); +} + +TEST_CASE("atomic write: death between the two commit renames is recovered from the backup", + "[filesystem][atomic_write]") { + TempDir dir("recover"); + // The crash window: real file already moved to .rex-bak, temp never + // renamed in. + WriteHostFile(dir.path / "save.dat.rex-bak", "OLD"); + WriteHostFile(dir.path / "save.dat.rex-tmp", "NEW-NEVER-LANDED"); + + HostPathDevice device("\\Device\\AtomicTest3\\", dir.path, false); + REQUIRE(device.Initialize()); + + CHECK(ReadHostFile(dir.path / "save.dat") == "OLD"); + CHECK(!fs::exists(dir.path / "save.dat.rex-tmp")); +} + +TEST_CASE("atomic write: .rex artifacts are never guest-visible", "[filesystem][atomic_write]") { + TempDir dir("hidden"); + WriteHostFile(dir.path / "save.dat", "OLD"); + WriteHostFile(dir.path / "save.dat.rex-bak", "PREVIOUS"); + + HostPathDevice device("\\Device\\AtomicTest4\\", dir.path, false); + REQUIRE(device.Initialize()); + + auto* root = device.ResolvePath(""); + REQUIRE(root != nullptr); + bool saw_artifact = false; + for (const auto& child : root->children()) { + if (child->name().find(".rex-") != std::string::npos) { + saw_artifact = true; + } + } + CHECK(!saw_artifact); + CHECK(device.ResolvePath("save.dat") != nullptr); +} + +TEST_CASE("write marker: present exactly while a write session is open", "[filesystem][atomic_write]") { + TempDir dir("marker"); + WriteHostFile(dir.path / "save.dat", "OLD"); + const fs::path marker = dir.path.parent_path() / "marker-test.rex-writing"; + std::error_code ec; + fs::remove(marker, ec); + + HostPathDevice device("\\Device\\AtomicTest5\\", dir.path, false); + device.set_write_marker_path(marker); + REQUIRE(device.Initialize()); + CHECK(!fs::exists(marker)); + + auto* entry = device.ResolvePath("save.dat"); + REQUIRE(entry != nullptr); + rex::filesystem::File* file = nullptr; + REQUIRE(entry->Open(FileAccess::kGenericRead | FileAccess::kGenericWrite, &file) == 0); + + // A write session is open: the marker must be on disk NOW - this is what + // survives a kill and triggers quarantine at the next mount. + CHECK(fs::exists(marker)); + + file->Destroy(); + CHECK(!fs::exists(marker)); + + fs::remove(marker, ec); +}