Make save and content writes atomic so an interrupted write cannot corrupt data

Overwriting a file through the content device used to delete the old file before the first new byte was written, so a crash or kill mid-save destroyed the existing data. A failed first-time save then left a half-written file the game reported as corrupted until it was deleted by hand. Writes now go to a temp file and commit by rename on close, keeping one .bak generation of the previous version. An interrupted write leaves the old data untouched, and a startup sweep resolves any write that died between the commit steps.
This commit is contained in:
Danshet
2026-08-17 21:18:49 +02:00
parent a0f1ca77dc
commit 7117b5b24a
7 changed files with 325 additions and 6 deletions
@@ -5,6 +5,8 @@
#pragma once
#include <filesystem>
#include <mutex>
#include <string>
#include <native/filesystem/device.h>
@@ -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<Entry> root_entry_;
bool read_only_;
std::filesystem::path write_marker_path_;
std::mutex write_marker_mutex_;
int active_atomic_writes_ = 0;
};
} // namespace rex::filesystem
@@ -6,6 +6,10 @@
#include "host_path_entry.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <native/filesystem.h>
#include <native/filesystem/devices/host_path_device.h>
@@ -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<Entry>(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<Entry>(child));
@@ -100,4 +130,80 @@ void HostPathDevice::PopulateEntry(HostPathEntry* parent_entry) {
}
}
void HostPathDevice::SweepStaleAtomicArtifacts() {
// A previous session that died mid-write can leave "<name>.rex-tmp" behind
// (and, in the narrow window between the two commit renames, the real file
// moved aside to "<name>.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<std::filesystem::path> 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<std::mutex> 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<std::mutex> 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
@@ -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 "<name>.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<HostPathDevice*>(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<HostPathDevice*>(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<memory::MappedMemory> 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<std::string_view>& 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) {
@@ -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 "<name>.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 "<name>.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<memory::MappedMemory> 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<std::string_view>& 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
@@ -12,9 +12,27 @@ HostPathFile::HostPathFile(uint32_t file_access, HostPathEntry* entry,
std::unique_ptr<rex::filesystem::FileHandle> file_handle)
: File(file_access, entry), file_handle_(std::move(file_handle)) {}
HostPathFile::HostPathFile(uint32_t file_access, HostPathEntry* entry,
std::unique_ptr<rex::filesystem::FileHandle> 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<HostPathEntry*>(entry_)->CommitAtomicWrite(temp_path_, !write_failed_, dirty_);
}
delete this;
}
@@ -38,9 +56,13 @@ X_STATUS HostPathFile::WriteSync(std::span<const uint8_t> 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;
}
}
@@ -5,6 +5,7 @@
#pragma once
#include <filesystem>
#include <span>
#include <string>
@@ -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<rex::filesystem::FileHandle> file_handle);
// Atomic write session: the handle points at "<name>.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<rex::filesystem::FileHandle> 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<rex::filesystem::FileHandle> file_handle_;
// Atomic write session state.
bool atomic_ = false;
std::filesystem::path temp_path_;
bool dirty_ = false;
bool write_failed_ = false;
};
} // namespace rex::filesystem
@@ -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;