diff --git a/Source/Core/Common/CMakeLists.txt b/Source/Core/Common/CMakeLists.txt index cf209ab2279..e833893a9de 100644 --- a/Source/Core/Common/CMakeLists.txt +++ b/Source/Core/Common/CMakeLists.txt @@ -52,6 +52,8 @@ add_library(common Debug/Threads.h Debug/Watches.cpp Debug/Watches.h + DirectIOFile.cpp + DirectIOFile.h DynamicLibrary.cpp DynamicLibrary.h ENet.cpp diff --git a/Source/Core/Common/DirectIOFile.cpp b/Source/Core/Common/DirectIOFile.cpp new file mode 100644 index 00000000000..d5e805529de --- /dev/null +++ b/Source/Core/Common/DirectIOFile.cpp @@ -0,0 +1,372 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "Common/DirectIOFile.h" + +#include + +#if defined(_WIN32) +#include + +#include + +#include "Common/Buffer.h" +#include "Common/CommonFuncs.h" +#include "Common/MathUtil.h" +#include "Common/StringUtil.h" +#else +#include + +#include +#include +#include + +#ifdef ANDROID +#include "jni/AndroidCommon/AndroidCommon.h" + +#include "Common/Lazy.h" +#endif + +#include "Common/FileUtil.h" +#endif + +#include "Common/Assert.h" + +namespace File +{ +DirectIOFile::DirectIOFile() = default; + +DirectIOFile::~DirectIOFile() +{ + Close(); +} + +DirectIOFile::DirectIOFile(const DirectIOFile& other) +{ + *this = other.Duplicate(); +} + +DirectIOFile& DirectIOFile::operator=(const DirectIOFile& other) +{ + return *this = other.Duplicate(); +} + +DirectIOFile::DirectIOFile(DirectIOFile&& other) +{ + Swap(other); +} + +DirectIOFile& DirectIOFile::operator=(DirectIOFile&& other) +{ + Close(); + Swap(other); + return *this; +} + +DirectIOFile::DirectIOFile(const std::string& path, AccessMode access_mode, OpenMode open_mode) +{ + Open(path, access_mode, open_mode); +} + +bool DirectIOFile::Open(const std::string& path, AccessMode access_mode, OpenMode open_mode) +{ + ASSERT(!IsOpen()); + + if (open_mode == OpenMode::Default) + open_mode = (access_mode == AccessMode::Write) ? OpenMode::Truncate : OpenMode::Existing; + + // This is not a sensible combination. Fail here to not rely on OS-specific behaviors. + if (access_mode == AccessMode::Read && open_mode == OpenMode::Truncate) + return false; + +#if defined(_WIN32) + DWORD desired_access = GENERIC_READ | GENERIC_WRITE; + if (access_mode == AccessMode::Read) + desired_access = GENERIC_READ; + else if (access_mode == AccessMode::Write) + desired_access = GENERIC_WRITE; + + // Allow deleting and renaming through our handle. + desired_access |= DELETE; + + // All sharing is allowed to more closely match default behavior on other OSes. + constexpr DWORD share_mode = FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE; + + DWORD creation_disposition = OPEN_ALWAYS; + if (open_mode == OpenMode::Truncate) + creation_disposition = CREATE_ALWAYS; + else if (open_mode == OpenMode::Create) + creation_disposition = CREATE_NEW; + else if (open_mode == OpenMode::Existing) + creation_disposition = OPEN_EXISTING; + + m_handle = CreateFile(UTF8ToTStr(path).c_str(), desired_access, share_mode, nullptr, + creation_disposition, FILE_ATTRIBUTE_NORMAL, nullptr); + if (!IsOpen()) + WARN_LOG_FMT(COMMON, "CreateFile: {}", Common::GetLastErrorString()); + +#else +#if defined(ANDROID) + if (IsPathAndroidContent(path)) + { + // Android documentation says that "w" may or may not truncate. + // In case it does, we'll use "rw" when we don't want truncation. + // This wrongly enables read access when we don't need it. + if (access_mode == AccessMode::Write && open_mode != OpenMode::Truncate) + access_mode = AccessMode::ReadAndWrite; + + std::string open_mode_str = "rw"; + if (access_mode == AccessMode::Read) + open_mode_str = "r"; + else if (access_mode == AccessMode::Write) + open_mode_str = "w"; + + // FYI: File::Exists can be slow on Android. + Common::Lazy file_exists{[&] { return Exists(path); }}; + + // A few features are emulated in a non-atomic manner. + if (open_mode == OpenMode::Existing) + { + if (access_mode != AccessMode::Read && !*file_exists) + return false; + } + else + { + if (open_mode == OpenMode::Truncate) + open_mode_str += 't'; + else if (open_mode == OpenMode::Create && *file_exists) + return false; + + // Modes other than `Existing` may create a file, but "r" won't do that automatically. + if (access_mode == AccessMode::Read && !*file_exists) + CreateEmptyFile(path); + } + + m_fd = OpenAndroidContent(path, open_mode_str); + + return IsOpen(); + } +#endif + int flags = O_RDWR; + if (access_mode == AccessMode::Read) + flags = O_RDONLY; + else if (access_mode == AccessMode::Write) + flags = O_WRONLY; + + if (open_mode == OpenMode::Truncate) + flags |= O_CREAT | O_TRUNC; + else if (open_mode == OpenMode::Create) + flags |= O_CREAT | O_EXCL; + else if (open_mode != OpenMode::Existing) + flags |= O_CREAT; + + m_fd = open(path.c_str(), flags, 0666); + +#endif + + return IsOpen(); +} + +bool DirectIOFile::Close() +{ + if (!IsOpen()) + return false; + + m_current_offset = 0; + +#if defined(_WIN32) + return CloseHandle(std::exchange(m_handle, INVALID_HANDLE_VALUE)) != 0; +#else + return close(std::exchange(m_fd, -1)) == 0; +#endif +} + +bool DirectIOFile::IsOpen() const +{ +#if defined(_WIN32) + return m_handle != INVALID_HANDLE_VALUE; +#else + return m_fd != -1; +#endif +} + +#if defined(_WIN32) +template +static bool OverlappedTransfer(HANDLE handle, u64 offset, auto* data_ptr, u64 size) +{ + // ReadFile/WriteFile take a 32bit size so we must loop to handle our 64bit size. + while (true) + { + OVERLAPPED overlapped{}; + overlapped.Offset = DWORD(offset); + overlapped.OffsetHigh = DWORD(offset >> 32); + + DWORD bytes_transferred{}; + if (TransferFunc(handle, data_ptr, MathUtil::SaturatingCast(size), &bytes_transferred, + &overlapped) == 0) + { + ERROR_LOG_FMT(COMMON, "OverlappedTransfer: {}", Common::GetLastErrorString()); + return false; + } + + size -= bytes_transferred; + + if (size == 0) + return true; + + offset += bytes_transferred; + data_ptr += bytes_transferred; + } +} +#endif + +bool DirectIOFile::OffsetRead(u64 offset, u8* out_ptr, u64 size) +{ +#if defined(_WIN32) + return OverlappedTransfer(m_handle, offset, out_ptr, size); +#else + return pread(m_fd, out_ptr, size, off_t(offset)) == ssize_t(size); +#endif +} + +bool DirectIOFile::OffsetWrite(u64 offset, const u8* in_ptr, u64 size) +{ +#if defined(_WIN32) + return OverlappedTransfer(m_handle, offset, in_ptr, size); +#else + return pwrite(m_fd, in_ptr, size, off_t(offset)) == ssize_t(size); +#endif +} + +u64 DirectIOFile::GetSize() const +{ +#if defined(_WIN32) + LARGE_INTEGER result{}; + if (GetFileSizeEx(m_handle, &result) != 0) + return result.QuadPart; +#else + struct stat st{}; + if (fstat(m_fd, &st) == 0) + return st.st_size; +#endif + + return 0; +} + +bool DirectIOFile::Seek(s64 offset, SeekOrigin origin) +{ + if (!IsOpen()) + return false; + + u64 reference_pos = 0; + switch (origin) + { + case SeekOrigin::Current: + reference_pos = m_current_offset; + break; + case SeekOrigin::End: + reference_pos = GetSize(); + break; + default: + break; + } + + // Don't let our current offset underflow. + if (offset < 0 && u64(-offset) > reference_pos) + return false; + + m_current_offset = reference_pos + offset; + return true; +} + +bool DirectIOFile::Flush() +{ +#if defined(_WIN32) + return FlushFileBuffers(m_handle) != 0; +#else + return fsync(m_fd) == 0; +#endif +} + +void DirectIOFile::Swap(DirectIOFile& other) +{ +#if defined(_WIN32) + std::swap(m_handle, other.m_handle); +#else + std::swap(m_fd, other.m_fd); +#endif + std::swap(m_current_offset, other.m_current_offset); +} + +DirectIOFile DirectIOFile::Duplicate() const +{ + DirectIOFile result; + + if (!IsOpen()) + return result; + +#if defined(_WIN32) + const auto current_process = GetCurrentProcess(); + if (DuplicateHandle(current_process, m_handle, current_process, &result.m_handle, 0, FALSE, + DUPLICATE_SAME_ACCESS) == 0) + { + ERROR_LOG_FMT(COMMON, "DuplicateHandle: {}", Common::GetLastErrorString()); + } +#else + result.m_fd = dup(m_fd); +#endif + + ASSERT(result.IsOpen()); + + result.m_current_offset = m_current_offset; + + return result; +} + +bool Resize(DirectIOFile& file, u64 size) +{ +#if defined(_WIN32) + // This operation is not "atomic", but it's the only thing we're using the file pointer for. + // Concurrent `Resize` would need some external synchronization to prevent race regardless. + const LARGE_INTEGER distance{.QuadPart = LONGLONG(size)}; + return (SetFilePointerEx(file.GetHandle(), distance, nullptr, FILE_BEGIN) != 0) && + (SetEndOfFile(file.GetHandle()) != 0); +#else + return ftruncate(file.GetHandle(), off_t(size)) == 0; +#endif +} + +bool Rename(DirectIOFile& file, const std::string& source_path [[maybe_unused]], + const std::string& destination_path) +{ +#if defined(_WIN32) + const auto dest_name = UTF8ToWString(destination_path); + const auto dest_name_byte_size = DWORD(dest_name.size() * sizeof(WCHAR)); + FILE_RENAME_INFO info{ + .ReplaceIfExists = TRUE, + .FileNameLength = dest_name_byte_size, // The size in bytes, not including null termination. + }; + constexpr auto filename_struct_offset = offsetof(FILE_RENAME_INFO, FileName); + Common::UniqueBuffer buffer(filename_struct_offset + dest_name_byte_size + sizeof(WCHAR)); + std::memcpy(buffer.data(), &info, filename_struct_offset); + std::memcpy(buffer.data() + filename_struct_offset, dest_name.c_str(), + dest_name_byte_size + sizeof(WCHAR)); + return SetFileInformationByHandle(file.GetHandle(), FileRenameInfo, buffer.data(), + DWORD(buffer.size())) != 0; +#else + return file.IsOpen() && Rename(source_path, destination_path); +#endif +} + +bool Delete(DirectIOFile& file, const std::string& filename) +{ +#if defined(_WIN32) + FILE_DISPOSITION_INFO info{.DeleteFile = TRUE}; + return SetFileInformationByHandle(file.GetHandle(), FileDispositionInfo, &info, sizeof(info)) != + 0; +#else + return file.IsOpen() && Delete(filename, IfAbsentBehavior::NoConsoleWarning); +#endif +} + +} // namespace File diff --git a/Source/Core/Common/DirectIOFile.h b/Source/Core/Common/DirectIOFile.h new file mode 100644 index 00000000000..79a5e68a2c1 --- /dev/null +++ b/Source/Core/Common/DirectIOFile.h @@ -0,0 +1,150 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/IOFile.h" + +namespace File +{ +enum class AccessMode +{ + Read, + Write, + ReadAndWrite, +}; + +enum class OpenMode +{ + // Based on the provided `AccessMode`. + // Read: Existing. + // Write: Truncate. + // ReadAndWrite: Existing. + Default, + + // Either create a new file or open an existing file. + Always, + + // Like `Always`, but also erase the contents of an existing file. + Truncate, + + // Require a file to already exist. Fail otherwise. + Existing, + + // Require a new file be created. Fail if one already exists. + Create, +}; + +// This file wrapper avoids use of the underlying system file position. +// It keeps track of its own file position and read/write calls directly use it. +// This makes copied handles entirely thread safe. +class DirectIOFile final +{ +public: + DirectIOFile(); + ~DirectIOFile(); + + // Copies and moves are allowed. + DirectIOFile(const DirectIOFile&); + DirectIOFile& operator=(const DirectIOFile&); + DirectIOFile(DirectIOFile&&); + DirectIOFile& operator=(DirectIOFile&&); + + explicit DirectIOFile(const std::string& path, AccessMode access_mode, + OpenMode open_mode = OpenMode::Default); + + bool Open(const std::string& path, AccessMode access_mode, + OpenMode open_mode = OpenMode::Default); + + bool Close(); + + bool IsOpen() const; + + // An offset from the start of the file may be specified directly. + // These explicit offset versions entirely ignore the current file position. + // They are thread safe, even when used on the same object. + + bool OffsetRead(u64 offset, u8* out_ptr, u64 size); + bool OffsetRead(u64 offset, std::span out_data) + { + return OffsetRead(offset, out_data.data(), out_data.size()); + } + bool OffsetWrite(u64 offset, const u8* in_ptr, u64 size); + bool OffsetWrite(auto offset, std::span in_data) + { + return OffsetWrite(offset, in_data.data(), in_data.size()); + } + + // These Read/Write functions advance the current position on success. + + bool Read(u8* out_ptr, u64 size) + { + if (!OffsetRead(m_current_offset, out_ptr, size)) + return false; + m_current_offset += size; + return true; + } + bool Read(std::span out_data) { return Read(out_data.data(), out_data.size()); } + + bool Write(const u8* in_ptr, u64 size) + { + if (!OffsetWrite(m_current_offset, in_ptr, size)) + return false; + m_current_offset += size; + return true; + } + bool Write(std::span in_data) { return Write(in_data.data(), in_data.size()); } + + // Returns 0 on error. + u64 GetSize() const; + + bool Seek(s64 offset, SeekOrigin origin); + + // Returns 0 when not open. + u64 Tell() const { return m_current_offset; } + + bool Flush(); + + auto GetHandle() const + { +#if defined(_WIN32) + return m_handle; +#else + return m_fd; +#endif + } + +private: + void Swap(DirectIOFile& other); + DirectIOFile Duplicate() const; + +#if defined(_WIN32) + // A workaround to avoid including in this header. + // HANDLE is just void* and INVALID_HANDLE_VALUE is -1. + using HandleType = void*; + HandleType m_handle{HandleType(-1)}; +#else + using HandleType = int; + HandleType m_fd{-1}; +#endif + + u64 m_current_offset{}; +}; + +// These take an open file handle to avoid failures from other processes trying to open our files. +// This is mainly an issue on Windows. + +bool Resize(DirectIOFile& file, u64 size); + +// Attempts to replace a destination file if one already exists. +// Note: Windows uses `file`. Elsewhere uses `source_path`, but `file` is checked for openness. +bool Rename(DirectIOFile& file, const std::string& source_path, + const std::string& destination_path); +// Note: Ditto, only Windows actually uses the file handle. Provide both. +bool Delete(DirectIOFile& file, const std::string& filename); + +} // namespace File diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props index 32f76c8d014..17e44675ac2 100644 --- a/Source/Core/DolphinLib.props +++ b/Source/Core/DolphinLib.props @@ -51,6 +51,7 @@ + @@ -820,6 +821,7 @@ + diff --git a/Source/UnitTests/Common/FileUtilTest.cpp b/Source/UnitTests/Common/FileUtilTest.cpp index 6b3f1168d24..bfdc9128f6b 100644 --- a/Source/UnitTests/Common/FileUtilTest.cpp +++ b/Source/UnitTests/Common/FileUtilTest.cpp @@ -1,9 +1,15 @@ // Copyright 2020 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include +#include +#include + #include +#include "Common/BitUtils.h" +#include "Common/DirectIOFile.h" #include "Common/FileUtil.h" class FileUtilTest : public testing::Test @@ -147,3 +153,262 @@ TEST_F(FileUtilTest, CreateFullPath) EXPECT_FALSE(File::CreateFullPath(p3file + "/")); EXPECT_TRUE(File::IsFile(p3file)); } + +TEST_F(FileUtilTest, DirectIOFile) +{ + static constexpr std::array u8_test_data = {42, 7, 99}; + static constexpr std::array u16_test_data = {0xdead, 0xbeef, 0xf00d}; + + static constexpr int u16_data_offset = 73; + + File::DirectIOFile file; + EXPECT_FALSE(file.IsOpen()); + + // Read mode fails with a non-existing file. + EXPECT_FALSE(file.Open(m_file_path, File::AccessMode::Read)); + + std::array u8_buffer = {}; + + // Everything fails when a file isn't open. + EXPECT_FALSE(file.Write(u8_buffer)); + EXPECT_FALSE(file.Read(u8_buffer)); + EXPECT_FALSE(file.Flush()); + EXPECT_FALSE(file.Seek(12, File::SeekOrigin::Begin)); + EXPECT_EQ(file.Tell(), 0); + EXPECT_EQ(file.GetSize(), 0); + + // Fail to open non-existing file. + EXPECT_FALSE(file.Open(m_file_path, File::AccessMode::ReadAndWrite)); + EXPECT_FALSE(file.Open(m_file_path, File::AccessMode::Read, File::OpenMode::Existing)); + EXPECT_FALSE(file.Open(m_file_path, File::AccessMode::Write, File::OpenMode::Existing)); + + // Read mode can create files + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Read, File::OpenMode::Create)); + EXPECT_TRUE(file.Close()); + + // Open a file for writing. + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Write, File::OpenMode::Always)); + EXPECT_TRUE(file.Flush()); + EXPECT_TRUE(file.IsOpen()); + + // Note: Double Open() currently ASSERTs. It's not obvious if that should succeed or fail. + + EXPECT_TRUE(file.Close()); + EXPECT_FALSE(file.Open(m_file_path, File::AccessMode::Write, File::OpenMode::Create)); + + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Write, File::OpenMode::Existing)); + EXPECT_TRUE(file.Close()); + + // Rename, Resize, and Delete fail with a closed handle. + EXPECT_FALSE(Rename(file, m_file_path, "fail")); + EXPECT_FALSE(Resize(file, 72)); + EXPECT_FALSE(Delete(file, m_file_path)); + EXPECT_TRUE(File::Exists(m_file_path)); + + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Write)); + + EXPECT_TRUE(file.Write(u8_buffer.data(), 0)); // write of 0 succeeds. + EXPECT_FALSE(file.Read(u8_buffer.data(), 0)); // read of 0 (in write mode) fails. + + // Resize through handle works. + EXPECT_TRUE(Resize(file, 1)); + EXPECT_EQ(file.GetSize(), 1); + file.Close(); + + // Write truncates by default. + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Write)); + EXPECT_EQ(file.GetSize(), 0); + + EXPECT_TRUE(file.Write(u8_test_data)); + EXPECT_EQ(file.GetSize(), u8_test_data.size()); // size changed + EXPECT_EQ(file.Tell(), u8_test_data.size()); // file position changed + + // Relative seek works. + EXPECT_TRUE(file.Seek(0, File::SeekOrigin::End)); + EXPECT_EQ(file.Tell(), file.GetSize()); + EXPECT_TRUE(file.Seek(-int(u8_test_data.size()), File::SeekOrigin::Current)); + EXPECT_EQ(file.Tell(), 0); + + // Read while in "write mode" fails + EXPECT_FALSE(file.Read(u8_buffer)); + EXPECT_EQ(file.Tell(), 0); + + // Seeking past the end works. + EXPECT_TRUE(file.Seek(u16_data_offset, File::SeekOrigin::Begin)); + EXPECT_EQ(file.Tell(), u16_data_offset); + EXPECT_EQ(file.GetSize(), u8_test_data.size()); // no change in size + + static constexpr u64 final_file_size = u16_data_offset + sizeof(u16_test_data); + + // Size changes after write. + EXPECT_TRUE(file.Write(Common::AsU8Span(u16_test_data))); + EXPECT_EQ(file.GetSize(), final_file_size); + + // Seek before begin fails. + EXPECT_FALSE(file.Seek(-1, File::SeekOrigin::Begin)); + EXPECT_EQ(file.Tell(), file.GetSize()); // unchanged position + + EXPECT_TRUE(file.Close()); + EXPECT_FALSE(file.IsOpen()); + + EXPECT_EQ(file.Tell(), 0); + EXPECT_EQ(file.GetSize(), 0); + + EXPECT_FALSE(file.Close()); // Double close fails. + + // Open file for reading. + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Read, File::OpenMode::Always)); + EXPECT_TRUE(file.Close()); + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Read)); + + static constexpr int big_offset = 999; + + // We can seek beyond the end. + EXPECT_TRUE(file.Seek(big_offset, File::SeekOrigin::End)); + EXPECT_EQ(file.Tell(), file.GetSize() + big_offset); + + // Resize through handle fails when in read mode. + EXPECT_FALSE(Resize(file, big_offset)); + EXPECT_EQ(file.GetSize(), final_file_size); + + constexpr size_t thread_count = 4; + + File::DirectIOFile closed_file; + + std::latch do_reads{1}; + + EXPECT_TRUE(file.Seek(u16_data_offset, File::SeekOrigin::Begin)); + + // Concurrent access with copied handles works. + std::vector threads(thread_count); + for (auto& t : threads) + { + t = std::thread{[&do_reads, file, closed_file]() mutable { + EXPECT_FALSE(closed_file.IsOpen()); // a copied closed file is still closed + + EXPECT_EQ(file.Tell(), u16_data_offset); // current position is copied + + std::array one_byte{}; + + constexpr u64 small_offset = 3; + + do_reads.wait(); + + EXPECT_TRUE(file.Seek(small_offset, File::SeekOrigin::Begin)); + EXPECT_EQ(file.Tell(), small_offset); + + // writes fail in read mode. + EXPECT_FALSE(file.Write(u8_test_data)); + EXPECT_FALSE(file.OffsetWrite(0, u8_test_data.data(), 0)); + EXPECT_EQ(file.Tell(), small_offset); + + EXPECT_TRUE(file.Read(one_byte.data(), 0)); // read of zero succeeds. + EXPECT_EQ(file.Tell(), small_offset); // unchanged position + + // Reading at an explicit offset doesn't change the position + EXPECT_TRUE(file.OffsetRead(u8_test_data.size() - 1, one_byte)); + EXPECT_EQ(file.Tell(), small_offset); + EXPECT_EQ(one_byte[0], u8_test_data.back()); + + EXPECT_EQ(file.GetSize(), final_file_size); + EXPECT_TRUE(file.Seek(-int(sizeof(u16_test_data)), File::SeekOrigin::End)); + + // We can't read beyond the end of the file. + EXPECT_FALSE(file.OffsetRead(big_offset, one_byte)); + // A read of zero beyond the end works. + EXPECT_TRUE(file.OffsetRead(big_offset, one_byte.data(), 0)); + + // Reading the previously written data works. + std::array u16_buffer = {}; + EXPECT_TRUE(file.Read(Common::AsWritableU8Span(u16_buffer))); + EXPECT_TRUE(std::ranges::equal(u16_buffer, u16_test_data)); + + // We can't read at the end of the file. + EXPECT_FALSE(file.Read(one_byte)); + EXPECT_EQ(file.Tell(), file.GetSize()); // The position is unchanged. + }}; + } + + // We may close the file on our end before the other threads use it. + file.Close(); + + // ReadAndWrite does not truncate existing files. + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::ReadAndWrite)); + EXPECT_EQ(file.GetSize(), final_file_size); + file.Close(); + + // Write doesn't truncate when the mode is changed. + EXPECT_TRUE(file.Open(m_file_path, File::AccessMode::Write, File::OpenMode::Existing)); + EXPECT_EQ(file.GetSize(), final_file_size); + + // Open a new handle to the same file. + File::DirectIOFile second_handle(m_file_path, File::AccessMode::Write, File::OpenMode::Always); + EXPECT_TRUE(second_handle.IsOpen()); + + const std::string destination_path_1 = m_file_path + ".dest_1"; + + // Rename through handle works when opened elsewhere. + EXPECT_TRUE(Rename(file, m_file_path, destination_path_1)); + EXPECT_FALSE(File::Exists(m_file_path)); + EXPECT_TRUE(File::Exists(destination_path_1)); + + const std::string destination_path_2 = m_file_path + ".dest_2"; + + // Note: Windows fails the next `Rename` if this file is kept open. + // I don't know if there is a nice way to make that work. + { + File::DirectIOFile another_file{destination_path_2, File::AccessMode::Write}; + EXPECT_TRUE(another_file.IsOpen()); + } + + // Rename overwrites existing files. + EXPECT_TRUE(Rename(file, destination_path_1, destination_path_2)); + EXPECT_FALSE(File::Exists(destination_path_1)); + EXPECT_EQ(File::GetSize(destination_path_2), final_file_size); + + const std::string destination_path_3 = m_file_path + ".dest_3"; + + // Truncate fails in Read mode. + File::Copy(destination_path_2, destination_path_3); + EXPECT_EQ(File::GetSize(destination_path_3), final_file_size); + { + File::DirectIOFile tmp{destination_path_3, File::AccessMode::Read, File::OpenMode::Truncate}; + EXPECT_FALSE(tmp.IsOpen()); + EXPECT_EQ(File::GetSize(destination_path_3), final_file_size); + } + + // Truncate works with ReadAndWrite. + EXPECT_EQ(File::GetSize(destination_path_3), final_file_size); + { + File::DirectIOFile tmp{destination_path_3, File::AccessMode::ReadAndWrite, + File::OpenMode::Truncate}; + EXPECT_EQ(tmp.GetSize(), 0); + Delete(tmp, destination_path_3); + } + + // Truncate works with Write. + File::Copy(destination_path_2, destination_path_3); + EXPECT_EQ(File::GetSize(destination_path_3), final_file_size); + { + File::DirectIOFile tmp{destination_path_3, File::AccessMode::Write, File::OpenMode::Truncate}; + EXPECT_EQ(tmp.GetSize(), 0); + Delete(tmp, destination_path_3); + } + + // Delete through handle works. + EXPECT_TRUE(Delete(file, destination_path_2)); + EXPECT_FALSE(File::Exists(destination_path_2)); + + // The threads can read even after deleting everything. + do_reads.count_down(); + std::ranges::for_each(threads, &std::thread::join); + + file.Close(); + EXPECT_TRUE(file.Open(destination_path_1, File::AccessMode::Read, File::OpenMode::Always)); + + // Required on Windows for the below to succeed. + second_handle.Close(); + + file.Close(); + EXPECT_TRUE(file.Open(destination_path_2, File::AccessMode::Write, File::OpenMode::Always)); +}