diff --git a/thirdparty/rexglue-sdk/include/native/filesystem/devices/host_path_device.h b/thirdparty/rexglue-sdk/include/native/filesystem/devices/host_path_device.h index cdd2d20b..50b734e7 100644 --- a/thirdparty/rexglue-sdk/include/native/filesystem/devices/host_path_device.h +++ b/thirdparty/rexglue-sdk/include/native/filesystem/devices/host_path_device.h @@ -5,6 +5,8 @@ #pragma once +#include +#include #include #include @@ -35,13 +37,30 @@ class HostPathDevice : public Device { uint32_t sectors_per_allocation_unit() const override { return 1; } uint32_t bytes_per_sector() const override { return 0x200; } + // Write-in-progress marker. When a marker path is set (content + // packages do this), the device keeps a marker file on disk while any + // atomic write session is open and removes it when the last one commits. + // A marker still present at the next mount means a write died mid-flight + // and the container may be torn across files - the content manager then + // quarantines it. Empty path (the default) disables the marker. + void set_write_marker_path(const std::filesystem::path& marker_path) { + write_marker_path_ = marker_path; + } + void OnAtomicWriteBegin(); + void OnAtomicWriteEnd(); + private: void PopulateEntry(HostPathEntry* parent_entry); + void SweepStaleAtomicArtifacts(); std::string name_; std::filesystem::path host_path_; std::unique_ptr root_entry_; bool read_only_; + + std::filesystem::path write_marker_path_; + std::mutex write_marker_mutex_; + int active_atomic_writes_ = 0; }; } // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_device.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_device.cpp index efbf528d..75930c7a 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_device.cpp +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_device.cpp @@ -6,6 +6,10 @@ #include "host_path_entry.h" #include +#include +#include +#include +#include #include #include @@ -15,6 +19,22 @@ namespace rex::filesystem { +namespace { + +bool HasAsciiSuffix(const std::string& text, const char* suffix) { + const size_t suffix_len = std::strlen(suffix); + return text.size() >= suffix_len && + text.compare(text.size() - suffix_len, suffix_len, suffix) == 0; +} + +// Host-side artifact of the atomic write path? (never guest-visible) +bool IsAtomicWriteArtifactName(const std::string& utf8_name) { + return HasAsciiSuffix(utf8_name, kAtomicWriteTempSuffix) || + HasAsciiSuffix(utf8_name, kAtomicWriteBackupSuffix); +} + +} // namespace + HostPathDevice::HostPathDevice(const std::string_view mount_path, const std::filesystem::path& host_path, bool read_only) : Device(mount_path), name_("STFS"), host_path_(host_path), read_only_(read_only) {} @@ -32,6 +52,10 @@ bool HostPathDevice::Initialize() { } } + if (!read_only_) { + SweepStaleAtomicArtifacts(); + } + auto root_entry = new HostPathEntry(this, nullptr, "", host_path_); root_entry->attributes_ = kFileAttributeDirectory; root_entry_ = std::unique_ptr(root_entry); @@ -90,6 +114,12 @@ Entry* HostPathDevice::ResolvePath(const std::string_view path) { void HostPathDevice::PopulateEntry(HostPathEntry* parent_entry) { auto child_infos = rex::filesystem::ListFiles(parent_entry->host_path()); for (auto& child_info : child_infos) { + if (child_info.type == rex::filesystem::FileInfo::Type::kFile && + IsAtomicWriteArtifactName(rex::path_to_utf8(child_info.name))) { + // .rex-tmp / .rex-bak are host-side artifacts of the atomic write + // path; the guest must never see them. + continue; + } auto child = HostPathEntry::Create(this, parent_entry, parent_entry->host_path() / child_info.name, child_info); parent_entry->children_.push_back(std::unique_ptr(child)); @@ -100,4 +130,80 @@ void HostPathDevice::PopulateEntry(HostPathEntry* parent_entry) { } } +void HostPathDevice::SweepStaleAtomicArtifacts() { + // A previous session that died mid-write can leave ".rex-tmp" behind + // (and, in the narrow window between the two commit renames, the real file + // moved aside to ".rex-bak" with the temp never renamed in). Recover + // the backup when the real file is missing, then drop stale temps. .rex-bak + // files themselves are kept: they are the one previous generation the + // atomic write path maintains. + std::vector stale_temps; + std::error_code ec; + for (auto it = std::filesystem::recursive_directory_iterator( + host_path_, std::filesystem::directory_options::skip_permission_denied, ec); + !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { + std::error_code file_ec; + if (!it->is_regular_file(file_ec)) { + continue; + } + if (HasAsciiSuffix(rex::path_to_utf8(it->path().filename()), kAtomicWriteTempSuffix)) { + stale_temps.push_back(it->path()); + } + } + + for (const auto& temp_path : stale_temps) { + const std::string temp_utf8 = rex::path_to_utf8(temp_path); + const auto real_path = + rex::to_path(temp_utf8.substr(0, temp_utf8.size() - std::strlen(kAtomicWriteTempSuffix))); + std::filesystem::path bak_path = real_path; + bak_path += kAtomicWriteBackupSuffix; + + std::error_code sweep_ec; + if (!std::filesystem::exists(real_path, sweep_ec) && + std::filesystem::exists(bak_path, sweep_ec)) { + std::error_code restore_ec; + std::filesystem::rename(bak_path, real_path, restore_ec); + if (!restore_ec) { + REXFS_WARN("Recovered '{}' from its backup after an interrupted write", + rex::path_to_utf8(real_path)); + } + } + std::error_code rm_ec; + if (std::filesystem::remove(temp_path, rm_ec) && !rm_ec) { + REXFS_INFO("Removed stale write temp '{}' (interrupted write; original kept)", temp_utf8); + } + } +} + +void HostPathDevice::OnAtomicWriteBegin() { + if (write_marker_path_.empty()) { + return; + } + std::lock_guard lock(write_marker_mutex_); + if (active_atomic_writes_++ == 0) { + auto marker = rex::filesystem::OpenFile(write_marker_path_, "wb"); + if (marker) { + static const char kMarkerText[] = + "Write in progress. If this file is still here on the next launch, the last " + "write died mid-flight and the container next to it will be quarantined.\n"; + fwrite(kMarkerText, 1, sizeof(kMarkerText) - 1, marker); + fclose(marker); + } else { + REXFS_WARN("Could not create write-in-progress marker '{}'", + rex::path_to_utf8(write_marker_path_)); + } + } +} + +void HostPathDevice::OnAtomicWriteEnd() { + if (write_marker_path_.empty()) { + return; + } + std::lock_guard lock(write_marker_mutex_); + if (active_atomic_writes_ > 0 && --active_atomic_writes_ == 0) { + std::error_code ec; + std::filesystem::remove(write_marker_path_, ec); + } +} + } // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.cpp index 396748dd..3311bccc 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.cpp +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.cpp @@ -50,6 +50,72 @@ X_STATUS HostPathEntry::Open(uint32_t desired_access, File** out_file) { REXFS_ERROR("Attempting to open file for write access on read-only device"); return X_STATUS_ACCESS_DENIED; } + + const bool wants_write = + (desired_access & (FileAccess::kGenericWrite | FileAccess::kFileWriteData | + FileAccess::kFileAppendData)) != 0; + const bool truncate_pending = pending_truncate_; + pending_truncate_ = false; + + // Atomic write session: guest writes land in ".rex-tmp" in + // the same directory (same volume, so the commit rename is atomic) and are + // committed over the real file when the handle closes, keeping one + // ".rex-bak" generation. A crash, kill or failed write mid-save leaves the + // OLD file intact instead of a torn one - the mechanism that left saves + // permanently stuck behind the game's "Game Data is corrupted / please + // delete it" dialog. + if (wants_write && !is_read_only()) { + if (!atomic_write_active_) { + std::filesystem::path temp_path = host_path_; + temp_path += kAtomicWriteTempSuffix; + + std::error_code ec; + bool temp_ready = false; + if (!truncate_pending && std::filesystem::is_regular_file(host_path_, ec)) { + // Preserve read-modify-write semantics: the handle must see the + // current contents until the guest overwrites them. + std::filesystem::copy_file(host_path_, temp_path, + std::filesystem::copy_options::overwrite_existing, ec); + temp_ready = !ec; + } else { + // Fresh create, or a deferred truncation: start from empty. + auto temp_file = rex::filesystem::OpenFile(temp_path, "wb"); + if (temp_file) { + fclose(temp_file); + temp_ready = true; + } + } + + if (temp_ready) { + auto temp_handle = rex::filesystem::FileHandle::OpenExisting(temp_path, desired_access); + if (temp_handle) { + atomic_write_active_ = true; + static_cast(device_)->OnAtomicWriteBegin(); + *out_file = new HostPathFile(desired_access, this, std::move(temp_handle), + std::move(temp_path), truncate_pending); + return X_STATUS_SUCCESS; + } + } + std::error_code cleanup_ec; + std::filesystem::remove(temp_path, cleanup_ec); + REXFS_WARN("Atomic write unavailable for '{}' - writing in place", + rex::path_to_utf8(host_path_)); + } else { + REXFS_WARN("Second concurrent write handle for '{}' - writing in place (not atomic)", + rex::path_to_utf8(host_path_)); + } + } + + // Fallback / read-only path: direct handle on the real file. A truncation + // that could not ride an atomic session must land on disk here after all. + if (truncate_pending) { + auto file = rex::filesystem::OpenFile(host_path_, "wb"); + if (!file) { + return X_STATUS_ACCESS_DENIED; + } + fclose(file); + } + auto file_handle = rex::filesystem::FileHandle::OpenExisting(host_path_, desired_access); if (!file_handle) { return X_STATUS_NO_SUCH_FILE; @@ -58,6 +124,51 @@ X_STATUS HostPathEntry::Open(uint32_t desired_access, File** out_file) { return X_STATUS_SUCCESS; } +void HostPathEntry::CommitAtomicWrite(const std::filesystem::path& temp_path, bool commit, + bool dirty) { + atomic_write_active_ = false; + auto* host_device = static_cast(device_); + + std::error_code ec; + if (!commit) { + // A write failed during the session: abandon the temp, the previous file + // stays exactly as it was. The guest already saw the write error. + std::filesystem::remove(temp_path, ec); + REXFS_ERROR("Write to '{}' failed - previous contents kept intact", + rex::path_to_utf8(host_path_)); + } else if (!dirty) { + // Write handle closed without writing anything: nothing to commit. + std::filesystem::remove(temp_path, ec); + } else { + // Keep exactly one backup generation, then swap the finished temp in. + std::filesystem::path bak_path = host_path_; + bak_path += kAtomicWriteBackupSuffix; + bool have_bak = false; + if (std::filesystem::exists(host_path_, ec) && !ec) { + std::error_code bak_ec; + std::filesystem::remove(bak_path, bak_ec); + bak_ec.clear(); + std::filesystem::rename(host_path_, bak_path, bak_ec); + have_bak = !bak_ec; + } + ec.clear(); + std::filesystem::rename(temp_path, host_path_, ec); + if (ec) { + REXFS_ERROR("Failed to commit write to '{}': {} - restoring previous contents", + rex::path_to_utf8(host_path_), ec.message()); + if (have_bak) { + std::error_code restore_ec; + std::filesystem::rename(bak_path, host_path_, restore_ec); + } + std::error_code rm_ec; + std::filesystem::remove(temp_path, rm_ec); + } + update(); + } + + host_device->OnAtomicWriteEnd(); +} + std::unique_ptr HostPathEntry::OpenMapped(memory::MappedMemory::Mode mode, size_t offset, size_t length) { return memory::MappedMemory::Open(host_path_, mode, offset, length); @@ -67,11 +178,20 @@ bool HostPathEntry::Truncate() { if (is_read_only() || (attributes_ & kFileAttributeDirectory)) { return false; } - auto file = rex::filesystem::OpenFile(host_path_, "wb"); + // Probe writability without destroying anything: the old behaviour opened + // "wb" (truncating the real file on the spot), so an interrupted overwrite + // had already lost the previous contents before the first new byte landed. + // A locked file (AV/cloud sync) must still fail LOUDLY here - the guest + // gets a save error and retries - rather than silently later. + auto file = rex::filesystem::OpenFile(host_path_, "r+b"); if (!file) { return false; } fclose(file); + // Defer the on-disk truncation into the atomic write session the VFS opens + // right after: the session starts from an empty temp and the + // real file is only replaced at commit. + pending_truncate_ = true; size_ = 0; allocation_size_ = 0; return true; @@ -129,8 +249,15 @@ void HostPathEntry::RenameEntryInternal(const std::vector& pat } void HostPathEntry::update() { + // During an atomic write session the in-flight contents live in the temp + // file; size queries must reflect what the guest just wrote, not the + // yet-to-be-replaced previous file. + std::filesystem::path query_path = host_path_; + if (atomic_write_active_) { + query_path += kAtomicWriteTempSuffix; + } rex::filesystem::FileInfo file_info; - if (!rex::filesystem::GetInfo(host_path_, &file_info)) { + if (!rex::filesystem::GetInfo(query_path, &file_info)) { return; } if (file_info.type == rex::filesystem::FileInfo::Type::kFile) { diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.h b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.h index 2173ab86..ecb8219f 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.h +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_entry.h @@ -14,6 +14,11 @@ namespace rex::filesystem { class HostPathDevice; +// Host-side artifacts of the atomic write path. Never guest-visible: +// the device skips them when populating the entry tree. +inline constexpr char kAtomicWriteTempSuffix[] = ".rex-tmp"; +inline constexpr char kAtomicWriteBackupSuffix[] = ".rex-bak"; + class HostPathEntry : public Entry { public: HostPathEntry(Device* device, Entry* parent, const std::string_view path, @@ -29,6 +34,15 @@ class HostPathEntry : public Entry { X_STATUS Open(uint32_t desired_access, File** out_file) override; bool Truncate() override; + // Atomic write session: a write handle opened by Open() writes + // into ".rex-tmp" in the same directory; HostPathFile calls this on + // close. commit=false abandons the temp (failed write - old data stays); + // dirty=false means nothing was written (temp discarded, file untouched). + // A successful commit keeps ONE previous generation at ".rex-bak" + // and renames the temp over the real file, so an interrupted or failed + // save can never leave a torn file in place. + void CommitAtomicWrite(const std::filesystem::path& temp_path, bool commit, bool dirty); + bool can_map() const override { return true; } std::unique_ptr OpenMapped(memory::MappedMemory::Mode mode, size_t offset, size_t length) override; @@ -47,6 +61,13 @@ class HostPathEntry : public Entry { void RenameEntryInternal(const std::vector& path_parts) override; std::filesystem::path host_path_; + // Truncate() defers the on-disk truncation into the atomic write session + // the VFS opens immediately afterwards, so an interrupted overwrite leaves + // the previous file intact. Consumed (and cleared) by the next Open(). + bool pending_truncate_ = false; + // One atomic session per entry at a time; a second concurrent write handle + // falls back to in-place access (logged). + bool atomic_write_active_ = false; }; } // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.cpp index 5da12ccc..95a15a13 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.cpp +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.cpp @@ -12,9 +12,27 @@ HostPathFile::HostPathFile(uint32_t file_access, HostPathEntry* entry, std::unique_ptr file_handle) : File(file_access, entry), file_handle_(std::move(file_handle)) {} +HostPathFile::HostPathFile(uint32_t file_access, HostPathEntry* entry, + std::unique_ptr file_handle, + std::filesystem::path temp_path, bool started_dirty) + : File(file_access, entry), + file_handle_(std::move(file_handle)), + atomic_(true), + temp_path_(std::move(temp_path)), + dirty_(started_dirty) {} + HostPathFile::~HostPathFile() = default; void HostPathFile::Destroy() { + if (atomic_) { + // Flush before the commit renames: the data must be on its way to disk + // before the temp becomes the real file. + if (file_handle_) { + file_handle_->Flush(); + file_handle_.reset(); + } + static_cast(entry_)->CommitAtomicWrite(temp_path_, !write_failed_, dirty_); + } delete this; } @@ -38,9 +56,13 @@ X_STATUS HostPathFile::WriteSync(std::span buffer, size_t byte_of return X_STATUS_ACCESS_DENIED; } + dirty_ = true; if (file_handle_->Write(byte_offset, buffer.data(), buffer.size(), out_bytes_written)) { return X_STATUS_SUCCESS; } else { + // A failed write poisons the session: committing a partial temp over the + // real file would be exactly the torn save this path exists to prevent. + write_failed_ = true; return X_STATUS_END_OF_FILE; } } @@ -50,9 +72,11 @@ X_STATUS HostPathFile::SetLength(size_t length) { return X_STATUS_ACCESS_DENIED; } + dirty_ = true; if (file_handle_->SetLength(length)) { return X_STATUS_SUCCESS; } else { + write_failed_ = true; return X_STATUS_END_OF_FILE; } } diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.h b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.h index 92a0da3d..78706b7a 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.h +++ b/thirdparty/rexglue-sdk/src/native/filesystem/devices/host_path_file.h @@ -5,6 +5,7 @@ #pragma once +#include #include #include @@ -17,8 +18,17 @@ class HostPathEntry; class HostPathFile : public File { public: + // Direct handle on the real file (read-only opens, and the fallback when an + // atomic session cannot be established). HostPathFile(uint32_t file_access, HostPathEntry* entry, std::unique_ptr file_handle); + // Atomic write session: the handle points at ".rex-tmp"; + // Destroy() flushes, closes and asks the entry to commit it over the real + // file. started_dirty marks a deferred truncation as a modification even if + // the guest then writes nothing. + HostPathFile(uint32_t file_access, HostPathEntry* entry, + std::unique_ptr file_handle, + std::filesystem::path temp_path, bool started_dirty); ~HostPathFile() override; void Destroy() override; @@ -30,6 +40,11 @@ class HostPathFile : public File { private: std::unique_ptr file_handle_; + // Atomic write session state. + bool atomic_ = false; + std::filesystem::path temp_path_; + bool dirty_ = false; + bool write_failed_ = false; }; } // namespace rex::filesystem diff --git a/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp b/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp index 32ec3eaa..30a39128 100644 --- a/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp +++ b/thirdparty/rexglue-sdk/src/native/filesystem/virtual_file_system.cpp @@ -312,11 +312,18 @@ X_STATUS VirtualFileSystem::OpenFile(Entry* root_entry, const std::string_view p break; case FileDisposition::kOverwrite: case FileDisposition::kOverwriteIf: - // Overwrite by delete + recreate, or truncate if delete fails - // (host file may be briefly locked by cloud sync, AV, etc.). - if (entry->Delete()) { + // Truncate first: a device that supports it (host paths) defers the + // truncation into the atomic write session opened right below, so an + // interrupted overwrite keeps the previous file contents. + // The historical delete + recreate destroyed the old file before the + // first new byte was written. Devices without Truncate, and files a + // truncate probe finds locked (cloud sync, AV), keep the old + // delete-or-fail behaviour. + if (entry->Truncate()) { + // Entry stays; the truncation materializes at open/commit. + } else if (entry->Delete()) { entry = nullptr; - } else if (!entry->Truncate()) { + } else { return X_STATUS_ACCESS_DENIED; } *out_action = FileAction::kOverwritten;