// Copyright Epic Games, Inc. All Rights Reserved. #include "UnsyncFile.h" #include "UnsyncCore.h" #include "UnsyncMemory.h" #include "UnsyncThread.h" #include "UnsyncScheduler.h" #include "UnsyncFilter.h" #include #if UNSYNC_PLATFORM_UNIX # include # include # include # include #endif // UNSYNC_PLATFORM_UNIX #if UNSYNC_PLATFORM_WINDOWS UNSYNC_THIRD_PARTY_INCLUDES_START #include UNSYNC_THIRD_PARTY_INCLUDES_END #endif // UNSYNC_PLATFORM_WINDOWS namespace unsync { bool GForceBufferedFiles = false; // Windows epoch : 1601-01-01T00:00:00Z // Unix epoch : 1970-01-01T00:00:00Z static constexpr uint64 SECONDS_BETWEEN_WINDOWS_AND_UNIX = 11'644'473'600ull; static constexpr uint64 NANOS_PER_WINDOWS_TICK = 100ull; static constexpr uint64 WINDOWS_TICKS_PER_SECOND = 1'000'000'000ull / NANOS_PER_WINDOWS_TICK; // each tick is 100ns // Returns extended absolute path of a form \\?\D:\verylongpath or \\?\UNC\servername\verylongpath // Expects an absolute path input. Returns original path on non-Windows. // https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation FPath MakeExtendedAbsolutePath(const FPath& InAbsolutePath) { if (InAbsolutePath.empty()) { return FPath(); } #if UNSYNC_PLATFORM_WINDOWS UNSYNC_ASSERTF(InAbsolutePath.is_absolute(), L"Input path '%ls' must be absolute", InAbsolutePath.wstring().c_str()); const std::wstring& InFilenameString = InAbsolutePath.native(); if (InFilenameString.starts_with(L"\\\\?\\")) { return InAbsolutePath; } else if (InFilenameString.starts_with(L"\\\\")) { return std::wstring(L"\\\\?\\UNC\\") + InFilenameString.substr(2); } else { return std::wstring(L"\\\\?\\") + InFilenameString; } #else // UNSYNC_PLATFORM_WINDOWS return InAbsolutePath; #endif // UNSYNC_PLATFORM_WINDOWS } // Removes \\, \\?\UNC\, \\.\UNC\, \\.\ or \\?\ prefix from a path. // \\server\foo\bar -> server\foo\bar // \\?\d:\foo\bar -> d:\foo\bar // d:\foo\bar -> d:\foo\bar // Returns original path on non-Windows. static inline FPathStringView RemoveUNCPrefix(const FPath& InPath) { FPathStringView InPathString = InPath.native(); #if UNSYNC_PLATFORM_WINDOWS if (InPathString.starts_with(L"\\\\?\\UNC\\")) { return InPathString.substr(8); } else if (InPathString.starts_with(L"\\\\?\\")) { return InPathString.substr(4); } else if (InPathString.starts_with(L"\\\\")) { return InPathString.substr(2); } else #endif { return InPathString; } } FPath RemoveExtendedPathPrefix(const FPath& InPath) { FPathStringView InPathString = InPath.native(); #if UNSYNC_PLATFORM_WINDOWS if (InPathString.starts_with(L"\\\\?\\UNC\\")) { FPathStringView Remainder = InPathString.substr(8); std::wstring Result; Result.reserve(Remainder.length() + 2); Result += L"\\\\"; Result += Remainder; return FPath(Result); } else if (InPathString.starts_with(L"\\\\?\\")) { return InPathString.substr(4); } else { return InPathString; } #else // UNSYNC_PLATFORM_WINDOWS return InPathString; #endif // UNSYNC_PLATFORM_WINDOWS } std::filesystem::file_time_type FromWindowsFileTime(uint64 Ticks) { using FileTimeDuration = std::filesystem::file_time_type::duration; uint64 RawSeconds = Ticks / WINDOWS_TICKS_PER_SECOND; uint64 RawSubsecondTicks = Ticks - (RawSeconds * WINDOWS_TICKS_PER_SECOND); uint64 RawSubsecondNanos = RawSubsecondTicks * NANOS_PER_WINDOWS_TICK; #if UNSYNC_PLATFORM_WINDOWS FileTimeDuration Seconds = std::chrono::duration_cast(std::chrono::seconds(RawSeconds)); #else // UNSYNC_PLATFORM_WINDOWS FileTimeDuration Seconds = std::chrono::seconds(RawSeconds - SECONDS_BETWEEN_WINDOWS_AND_UNIX); #endif // UNSYNC_PLATFORM_WINDOWS FileTimeDuration SubsecondNanos = std::chrono::duration_cast(std::chrono::nanoseconds(RawSubsecondNanos)); FileTimeDuration DurationFromNativeEpoch = Seconds + SubsecondNanos; std::filesystem::file_time_type Result(DurationFromNativeEpoch); return Result; } FPath GetRelativePath(const FPath& Path, const FPath& Base) { FPathStringView ResultView = GetRelativePathView(Path, Base); return ResultView; } FPathStringView GetRelativePathView(const FPath& Path, const FPath& Base) { // Try a trivial case first, without touching the filesystem FPathStringView PathView = RemoveUNCPrefix(Path); FPathStringView BaseView = RemoveUNCPrefix(Base); if (PathView.starts_with(BaseView)) { FPathStringView PathViewRemainder = PathView.substr(BaseView.length()); if (PathViewRemainder.starts_with(FPath::preferred_separator)) { FPathStringView RelativePath = PathView.substr(BaseView.length()); while (RelativePath.starts_with(FPath::preferred_separator)) { RelativePath = RelativePath.substr(1); } return RelativePath; } } return {}; } void ConvertDirectorySeparatorsToNative(std::string& Path) { std::replace_if( Path.begin(), Path.end(), [](char C) { return C == '/' || C == '\\'; }, PATH_SEPARATOR); } void ConvertDirectorySeparatorsToUnix(std::string& Path) { std::replace_if( Path.begin(), Path.end(), [](char C) { return C == '\\'; }, char('/')); } void ConvertDirectorySeparatorsToNative(std::wstring& Path) { std::replace_if( Path.begin(), Path.end(), [](wchar_t C) { return C == '/' || C == '\\'; }, wchar_t(FPath::preferred_separator)); } void ConvertDirectorySeparatorsToUnix(std::wstring& Path) { std::replace_if( Path.begin(), Path.end(), [](wchar_t C) { return C == '\\'; }, wchar_t('/')); } std::error_code CopyFileIfNewer(const FPath& Source, const FPath& Target) { FFileAttributes SourceAttr = GetFileAttrib(Source); FFileAttributes TargetAttr = GetFileAttrib(Target); std::error_code Ec; if (SourceAttr.Size != TargetAttr.Size || SourceAttr.Mtime != TargetAttr.Mtime) { FileCopyOverwrite(Source, Target, Ec); } return Ec; } bool IsNonCaseSensitiveFileSystem(const FPath& ExistingPath) { UNSYNC_ASSERTF(PathExists(ExistingPath), L"IsCaseSensitiveFileSystem must be called with a path that exists on disk"); // Assume file system is case-sensitive if all-upper and all-lower versions of the path exist and resolve to the same FS entry. // This is not 100% robust due to symlinks, but is good enough for most practical purposes. FPath PathUpper = StringToUpper(ExistingPath.wstring()); FPath PathLower = StringToLower(ExistingPath.wstring()); if (PathExists(PathUpper) && PathExists(PathLower)) { return std::filesystem::equivalent(ExistingPath, PathUpper) && std::filesystem::equivalent(PathLower, PathUpper); } else { return false; } } bool IsCaseSensitiveFileSystem(const FPath& ExistingPath) { return !IsNonCaseSensitiveFileSystem(ExistingPath); } FFileAttributes GetCachedFileAttrib(const FPath& Path, FFileAttributeCache& AttribCache) { FFileAttributes Result; FPath ExtendedPath = MakeExtendedAbsolutePath(Path); auto It = AttribCache.Map.find(ExtendedPath); if (It != AttribCache.Map.end()) { Result = It->second; } return Result; } #if UNSYNC_PLATFORM_WINDOWS inline uint64 MakeU64(FILETIME Ft) { return MakeU64(Ft.dwHighDateTime, Ft.dwLowDateTime); } struct FCreateFileInfo { DWORD FileAccess = 0; DWORD Share = 0; DWORD Disposition = 0; DWORD Protection = 0; DWORD MapAccess = 0; DWORD FileFlags = FILE_ATTRIBUTE_NORMAL; FCreateFileInfo(EFileMode Mode) { switch (Mode & EFileMode::CommonModeMask) { default: case EFileMode::ReadOnly: case EFileMode::ReadOnlyUnbuffered: FileAccess = GENERIC_READ; Share = FILE_SHARE_READ; Disposition = OPEN_EXISTING; Protection = PAGE_READONLY; MapAccess = FILE_MAP_READ; break; case EFileMode::CreateReadWrite: case EFileMode::CreateWriteOnly: UNSYNC_ASSERT(!GDryRun || EnumHasAnyFlags(Mode, EFileMode::IgnoreDryRun)); FileAccess = GENERIC_READ | GENERIC_WRITE; Share = FILE_SHARE_WRITE; Disposition = CREATE_ALWAYS; Protection = PAGE_READWRITE; MapAccess = FILE_MAP_ALL_ACCESS; break; } } }; FWindowsFile::FWindowsFile(const FPath& InFilename, EFileMode InMode, uint64 InSize) : Mode(InMode) { Filename = MakeExtendedAbsolutePath(InFilename); bool bOpenedOk = OpenFileHandle(InMode); if (bOpenedOk) { if (IsReadOnly(InMode)) { LARGE_INTEGER LiFileSize = {}; bool bSizeOk = GetFileSizeEx(FileHandle, &LiFileSize); if (!bSizeOk) { LastError = GetLastError(); return; } FileSize = LiFileSize.QuadPart; } else if (IsWritable(InMode) && InSize) { DWORD BytesReturned = 0; BOOL SparseFileOk = DeviceIoControl(FileHandle, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &BytesReturned, nullptr); if (!SparseFileOk) { UNSYNC_WARNING(L"Failed to mark file '%ls' as sparse.", Filename.wstring().c_str()); } LARGE_INTEGER LiFileSize = {}; LiFileSize.QuadPart = InSize; BOOL SizeOk = SetFilePointerEx(FileHandle, LiFileSize, nullptr, FILE_BEGIN); if (!SizeOk) { CloseHandle(FileHandle); LastError = GetLastError(); return; } BOOL EndOfFileOk = SetEndOfFile(FileHandle); if (!EndOfFileOk) { CloseHandle(FileHandle); LastError = GetLastError(); return; } FileSize = LiFileSize.QuadPart; } else if (IsWritable(InMode) && (InSize == 0)) { // nothing to do when creating an empty file } else { UNSYNC_ERROR(L"Unexpected file mode %d", (int)InMode); } for (uint32 I = 0; I < NUM_QUEUES; ++I) { OverlappedEvents[I] = CreateEvent(nullptr, true, true, nullptr); Commands[I].Overlapped.hEvent = OverlappedEvents[I]; } } } FWindowsFile::~FWindowsFile() { Close(); } bool FWindowsFile::OpenFileHandle(EFileMode InMode) { FCreateFileInfo Info(InMode); Info.FileFlags |= FILE_FLAG_OVERLAPPED; if (EnumHasAnyFlags(InMode, EFileMode::Unbuffered) && !GForceBufferedFiles) { Info.FileFlags |= FILE_FLAG_NO_BUFFERING; } FileHandle = CreateFileW(Filename.c_str(), Info.FileAccess, Info.Share, nullptr, Info.Disposition, Info.FileFlags, nullptr); if (FileHandle == INVALID_HANDLE_VALUE) { LastError = GetLastError(); return false; } else { return true; } } bool FWindowsFile::IsValid() { std::lock_guard LockGuard(Mutex); return FileHandle != INVALID_HANDLE_VALUE; } void FWindowsFile::Close() { InternalFlushAll(); if (FileHandle != INVALID_HANDLE_VALUE) { CloseHandle(FileHandle); FileHandle = INVALID_HANDLE_VALUE; } for (uint32 I = 0; I < NUM_QUEUES; ++I) { if (OverlappedEvents[I]) { CloseHandle(OverlappedEvents[I]); OverlappedEvents[I] = 0; } } } uint32 FWindowsFile::CompleteReadCommand(Command& Cmd) { // Expects that Mutex is locked UNSYNC_ASSERT(Cmd.bActive); const uint32 MaxAttempts = 100; DWORD ReadBytes = 0; bool bRecoveredFromError = false; const uint64 ExpectedReadEnd = std::min(FileSize, Cmd.SourceOffset + Cmd.ReadSize); const uint64 ExpectedReadBytes = ExpectedReadEnd - Cmd.SourceOffset; for (uint32 Attempt = 0; Attempt < MaxAttempts; ++Attempt) { BOOL OverlappedResultOk = GetOverlappedResult(FileHandle, &Cmd.Overlapped, &ReadBytes, true); LastError = 0; if (!OverlappedResultOk) { LastError = GetLastError(); } if (ReadBytes == ExpectedReadBytes || ReadBytes == Cmd.ReadSize) { break; } else { UNSYNC_WARNING(L"FNativeFile expected to read %lld bytes, but read %lld. %hs", (uint64)ExpectedReadBytes, (uint64)ReadBytes, FormatSystemErrorMessage(LastError).c_str()); UNSYNC_LOG(L"Trying to recover from error (attempt %d of %d)", Attempt + 1, MaxAttempts); SchedulerSleep(1000); // Cancel any active overlapped IO commands for (Command& It : Commands) { if (It.bActive && (&It != &Cmd)) { CancelIoEx(FileHandle, &It.Overlapped); WaitForSingleObject(It.Overlapped.hEvent, INFINITE); } } // Re-open the file handle CloseHandle(FileHandle); bool bOpenedOk = OpenFileHandle(Mode); if (!bOpenedOk) { LastError = GetLastError(); UNSYNC_ERROR(L"Failed to re-open the file. %hs", FormatSystemErrorMessage(LastError).c_str()); break; } // Try reading this block again ResetEvent(Cmd.Overlapped.hEvent); ReadFile(FileHandle, Cmd.Buffer.GetMemory(), CheckedNarrow(Cmd.ReadSize), nullptr, &Cmd.Overlapped); bRecoveredFromError = true; } } const uint64 ReadBytesClamped = std::min(Cmd.Buffer.GetSize(), ReadBytes); if (Cmd.Callback) { Cmd.Callback(std::move(Cmd.Buffer), Cmd.SourceOffset, ReadBytesClamped, Cmd.UserData); } Cmd.bActive = false; if (bRecoveredFromError) { // Re-issue the read requests using new file handle for (Command& It : Commands) { if (It.bActive) { ResetEvent(It.Overlapped.hEvent); ReadFile(FileHandle, It.Buffer.GetMemory(), CheckedNarrow(It.ReadSize), nullptr, &It.Overlapped); } } } return CheckedNarrow(ReadBytesClamped); } uint64 FWindowsFile::Write(const void* Data, uint64 DestOffset, uint64 TotalSize) { // TODO: !!!!! fire-and-forget asynchronous writes !!!!! std::lock_guard LockGuard(Mutex); UNSYNC_ASSERT(IsWritable(Mode)); if (!IsWriteOnly(Mode)) { InternalFlushAll(); // flush any outstanding read requests before writing } LARGE_INTEGER Pos; Pos.QuadPart = DestOffset; uint64 WrittenBytes = 0; static constexpr uint64 ChunkSize = 128_MB; uint64 NumChunks = DivUp(TotalSize, ChunkSize); uint64 SourceOffset = 0; for (uint64 I = 0; I < NumChunks; ++I) { int32 ThisChunkSize = CheckedNarrow(CalcChunkSize(I, ChunkSize, TotalSize)); OVERLAPPED Overlapped = {}; Overlapped.Offset = Pos.LowPart; Overlapped.OffsetHigh = Pos.HighPart; BOOL WriteOk = WriteFile(FileHandle, reinterpret_cast(Data) + SourceOffset, ThisChunkSize, nullptr, &Overlapped); if (!WriteOk && GetLastError() != ERROR_IO_PENDING) { LastError = GetLastError(); return 0; } DWORD ChunkWrittenBytes = 0; BOOL OverlappedResultOk = TRUE; uint32 MaxAttempts = 100000; uint32 Attempt = 0; for (Attempt = 0; Attempt < MaxAttempts; ++Attempt) { OverlappedResultOk = GetOverlappedResult(FileHandle, &Overlapped, &ChunkWrittenBytes, true); if (!OverlappedResultOk || ChunkWrittenBytes != 0) { break; } if (ChunkWrittenBytes == 0) { SchedulerSleep(1); } } if (Attempt == MaxAttempts) { UNSYNC_ERROR(L"Overlapped file write timed out"); } if (!OverlappedResultOk) { LastError = GetLastError(); break; } WrittenBytes += ChunkWrittenBytes; Pos.QuadPart += ChunkWrittenBytes; SourceOffset += ChunkWrittenBytes; } return WrittenBytes; } uint64 FWindowsFile::Read(void* Dest, uint64 SourceOffset, uint64 ReadSize) { std::lock_guard LockGuard(Mutex); UNSYNC_ASSERTF((Mode & EFileMode::Unbuffered) == 0, L"Unbuffered files only support ReadAsync"); UNSYNC_ASSERT(IsReadable(Mode)); LARGE_INTEGER Pos; Pos.QuadPart = SourceOffset; uint64 ReadBytes = 0; static constexpr uint64 ChunkSize = 128_MB; uint64 NumChunks = DivUp(ReadSize, ChunkSize); uint64 DestOffset = 0; for (uint64 I = 0; I < NumChunks; ++I) { uint32 ThisChunkSize = CheckedNarrow(CalcChunkSize(I, ChunkSize, ReadSize)); OVERLAPPED Overlapped = {}; Overlapped.Offset = Pos.LowPart; Overlapped.OffsetHigh = Pos.HighPart; BOOL ReadOk = ReadFile(FileHandle, reinterpret_cast(Dest) + DestOffset + I * ChunkSize, ThisChunkSize, nullptr, &Overlapped); if (!ReadOk && GetLastError() != ERROR_IO_PENDING) { LastError = GetLastError(); return 0; } DWORD ChunkReadBytes = 0; BOOL OverlappedResultOk = GetOverlappedResult(FileHandle, &Overlapped, &ChunkReadBytes, true); if (!OverlappedResultOk) { LastError = GetLastError(); break; } ReadBytes += ChunkReadBytes; Pos.QuadPart += ChunkReadBytes; SourceOffset += ChunkReadBytes; } return ReadBytes; } bool FWindowsFile::ReadAsync(uint64 SourceOffset, uint64 Size, uint64 UserData, IOCallback Callback) { std::lock_guard LockGuard(Mutex); UNSYNC_ASSERT(IsReadable(Mode)); uint32 CmdIdx = ~0u; // find available queue or wait for one to complete DWORD WaitResult = WaitForMultipleObjects(NUM_QUEUES, OverlappedEvents, false, INFINITE); if (WaitResult >= WAIT_OBJECT_0 && WaitResult < NUM_QUEUES) { CmdIdx = WaitResult - WAIT_OBJECT_0; } UNSYNC_ASSERT(CmdIdx < NUM_QUEUES); FWindowsFile::Command& Cmd = Commands[CmdIdx]; if (Cmd.bActive) { CompleteReadCommand(Cmd); } if (EnumHasAnyFlags(Mode, EFileMode::Unbuffered)) { uint64 OriginalSize = Size; uint64 OriginalBegin = SourceOffset; uint64 OriginalEnd = SourceOffset + Size; uint64 AlignedBegin = AlignDownToMultiplePow2(OriginalBegin, UNBUFFERED_READ_ALIGNMENT); uint64 AlignedEnd = AlignUpToMultiplePow2(OriginalEnd, UNBUFFERED_READ_ALIGNMENT); SourceOffset = AlignedBegin; Size = AlignedEnd - AlignedBegin; Cmd.Buffer = FIOBuffer::Alloc(Size, L"WindowsFile::ReadAsync_aligned"); Cmd.Buffer.SetDataRange(OriginalBegin - AlignedBegin, OriginalSize); } else { Cmd.Buffer = FIOBuffer::Alloc(Size, L"WindowsFile::ReadAsync"); } LARGE_INTEGER Pos; Pos.QuadPart = SourceOffset; Cmd.ReadSize = Size; Cmd.Overlapped.Offset = Pos.LowPart; Cmd.Overlapped.OffsetHigh = Pos.HighPart; Cmd.UserData = UserData; Cmd.SourceOffset = SourceOffset; Cmd.Callback = Callback; ResetEvent(Cmd.Overlapped.hEvent); DWORD ReadOk = ReadFile(FileHandle, Cmd.Buffer.GetMemory(), CheckedNarrow(Size), nullptr, &Cmd.Overlapped); if (!ReadOk) { LastError = GetLastError(); } Cmd.bActive = true; return true; } void FWindowsFile::InternalFlushAll() { for (uint32 I = 0; I < NUM_QUEUES; ++I) { if (Commands[I].bActive) { CompleteReadCommand(Commands[I]); } } } void FWindowsFile::FlushAll() { std::lock_guard LockGuard(Mutex); InternalFlushAll(); } void FWindowsFile::FlushOne() { std::lock_guard LockGuard(Mutex); uint32 NumValidEvents = 0; HANDLE Events[NUM_QUEUES] = {}; uint32 CommandIndices[NUM_QUEUES] = {}; // todo: maintain a list of outstanding commands in read() for (uint32 I = 0; I < NUM_QUEUES; ++I) { if (Commands[I].Buffer.GetMemory()) { Events[NumValidEvents] = OverlappedEvents[I]; CommandIndices[NumValidEvents] = I; NumValidEvents++; } } if (NumValidEvents == 0) { return; } DWORD WaitResult = WaitForMultipleObjects(NumValidEvents, OverlappedEvents, false, INFINITE); uint32 CmdIdx = ~0u; if (WaitResult >= WAIT_OBJECT_0 && WaitResult < NUM_QUEUES) { CmdIdx = CommandIndices[WaitResult - WAIT_OBJECT_0]; } UNSYNC_ASSERT(CmdIdx < NUM_QUEUES); FWindowsFile::Command& Cmd = Commands[CmdIdx]; uint32 ReadBytes = CompleteReadCommand(Cmd); if (ReadBytes != Cmd.ReadSize) { return; } UNSYNC_ASSERT(Cmd.Buffer.GetMemory()); UNSYNC_ASSERT(ReadBytes); Cmd.ReadSize = 0; Cmd.UserData = 0; Cmd.Buffer = {}; Cmd.Callback = {}; } FFileAttributes GetFileAttrib(const FPath& Path, FFileAttributeCache* AttribCache) { FFileAttributes Result; FPath ExtendedPath = MakeExtendedAbsolutePath(Path); if (AttribCache) { auto It = AttribCache->Map.find(ExtendedPath); if (It != AttribCache->Map.end()) { Result = It->second; return Result; } } WIN32_FILE_ATTRIBUTE_DATA AttributeData; BOOL Ok = GetFileAttributesExW(ExtendedPath.c_str(), GetFileExInfoStandard, &AttributeData); if (Ok) { Result.bDirectory = !!(AttributeData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY); Result.Size = MakeU64(AttributeData.nFileSizeHigh, AttributeData.nFileSizeLow); Result.Mtime = MakeU64(AttributeData.ftLastWriteTime); Result.bReadOnly = (AttributeData.dwFileAttributes & FILE_ATTRIBUTE_READONLY); Result.bValid = true; } return Result; } uint64 ToWindowsFileTime(const std::filesystem::file_time_type& T) { return T.time_since_epoch().count(); } uint64 GetAvailableDiskSpace(const FPath& Path) { ULARGE_INTEGER AvailableBytes = {}; ULARGE_INTEGER TotalBytes = {}; ULARGE_INTEGER FreeBytes = {}; BOOL bOk = GetDiskFreeSpaceExW(Path.native().c_str(), &AvailableBytes, &TotalBytes, &FreeBytes); if (bOk) { return AvailableBytes.QuadPart; } else { return ~0ull; } } #endif // UNSYNC_PLATFORM_WINDOWS #if UNSYNC_PLATFORM_UNIX FUnixFile::FUnixFile(const FPath& InFilename, EFileMode InMode, uint64 in_size) : Filename(InFilename), Mode(InMode) { FileHandle = fopen(InFilename.native().c_str(), IsReadOnly(Mode) ? "rb" : "w+b"); if (FileHandle == nullptr) { return; } FileDescriptor = fileno(FileHandle); if (IsReadOnly(Mode)) { struct stat stat_buf = {}; LastError = fstat(FileDescriptor, &stat_buf); UNSYNC_ASSERT(LastError == 0); FileSize = stat_buf.st_size; } else { LastError = ftruncate(FileDescriptor, in_size); UNSYNC_ASSERT(LastError == 0); FileSize = in_size; } } FUnixFile::~FUnixFile() { Close(); } void FUnixFile::FlushAll() { // nothing to do, all file IO is synchronous } void FUnixFile::FlushOne() { // nothing to do, all file IO is synchronous } void FUnixFile::Close() { if (FileHandle) { fclose(FileHandle); FileHandle = nullptr; } } uint64 FUnixFile::Read(void* dest, uint64 SourceOffset, uint64 ReadSize) { // TODO: handle partial reads (pread returning 0 < x < ReadSize) uint64 read_bytes = pread(FileDescriptor, dest, ReadSize, SourceOffset); if (read_bytes != ReadSize) { LastError = errno; } return read_bytes; } bool FUnixFile::ReadAsync(uint64 SourceOffset, uint64 size, uint64 UserData, IOCallback callback) { return FIOReader::ReadAsync(SourceOffset, size, UserData, callback); } uint64 FUnixFile::Write(const void* data, uint64 DestOffset, uint64 WriteSize) { UNSYNC_ASSERT(IsWritable(Mode)); if (!IsWriteOnly(Mode)) { FlushAll(); // flush any outstanding read requests before writing } // TODO: handle partial writes (pwrite returning 0 < x < WriteSize) uint64 wrote_bytes = pwrite(FileDescriptor, data, WriteSize, DestOffset); if (wrote_bytes != WriteSize) { LastError = errno; } return wrote_bytes; } FFileAttributes GetFileAttrib(const FPath& Path, FFileAttributeCache* AttribCache) { FFileAttributes Result; if (AttribCache) { auto it = AttribCache->Map.find(Path); if (it != AttribCache->Map.end()) { Result = it->second; return Result; } } // TODO: could potentially use std::filesystem::directory_entry for this on all platforms std::error_code ErrorCode = {}; auto Entry = std::filesystem::directory_entry(Path, ErrorCode); if (!ErrorCode) { std::filesystem::file_status Status = Entry.status(ErrorCode); if (ErrorCode) { return Result; } Result.bDirectory = Entry.is_directory(); Result.Size = Result.bDirectory ? 0 : Entry.file_size(); Result.Mtime = ToWindowsFileTime(Entry.last_write_time()); Result.bReadOnly = IsReadOnly(Status.permissions()); Result.bValid = true; } return Result; } uint64 ToWindowsFileTime(const std::filesystem::file_time_type& FileTime) { std::chrono::duration FullDuration = FileTime.time_since_epoch(); uint64 FullSeconds = std::chrono::floor(FullDuration).count(); auto SubsecondDuration = FullDuration - std::chrono::seconds(FullSeconds); auto SubsecondNanos = std::chrono::duration_cast(SubsecondDuration).count(); uint64 Ticks = (FullSeconds + SECONDS_BETWEEN_WINDOWS_AND_UNIX) * WINDOWS_TICKS_PER_SECOND + (SubsecondNanos / NANOS_PER_WINDOWS_TICK); return Ticks; } uint64 GetAvailableDiskSpace(const FPath& Path) { return ~0ull; // TODO: query available space via statvfs() } #endif // UNSYNC_PLATFORM_UNIX bool SetFileMtime(const FPath& Path, uint64 Mtime, bool bAllowInDryRun) { UNSYNC_ASSERT(!GDryRun || bAllowInDryRun); UNSYNC_ASSERT(Mtime != 0); FPath ExtendedPath = MakeExtendedAbsolutePath(Path); std::filesystem::file_time_type FileTime = FromWindowsFileTime(Mtime); std::error_code ErrorCode; std::filesystem::last_write_time(ExtendedPath, FileTime, ErrorCode); return !ErrorCode; } bool SetFileReadOnly(const FPath& Path, bool bReadOnly) { UNSYNC_ASSERT(!GDryRun); FPath ExtendedPath = MakeExtendedAbsolutePath(Path); std::error_code ErrorCode; if (bReadOnly) { std::filesystem::permissions( ExtendedPath, std::filesystem::perms::owner_write | std::filesystem::perms::group_write | std::filesystem::perms::others_write, std::filesystem::perm_options::remove, ErrorCode); } else { std::filesystem::permissions(ExtendedPath, std::filesystem::perms::owner_write, std::filesystem::perm_options::add, ErrorCode); } return !ErrorCode; } FBuffer ReadFileToBuffer(const FPath& Filename) { FBuffer Result; FNativeFile File(Filename, EFileMode::ReadOnly); if (File.IsValid()) { Result.Resize(File.GetSize()); uint64 ReadBytes = File.Read(Result.Data(), 0, Result.Size()); Result.Resize(ReadBytes); } return Result; } bool WriteBufferToFile(const FPath& Filename, const uint8* Data, uint64 Size, EFileMode FileMode) { UNSYNC_LOG_INDENT; if (Data == nullptr) { UNSYNC_ERROR(L"WriteBufferToFile called with null buffer"); return false; } if (Size == 0) { UNSYNC_ERROR(L"WriteBufferToFile called with zero size buffer"); return false; } if (GDryRun && !EnumHasAnyFlags(FileMode, EFileMode::IgnoreDryRun)) { UNSYNC_ERROR(L"WriteBufferToFile called in dry run mode"); return false; } FNativeFile File(Filename, FileMode, Size); if (File.IsValid()) { uint64 WroteBytes = File.Write(Data, 0, Size); return WroteBytes == Size; } else { UNSYNC_ERROR(L"Failed to open file '%ls' for writing. %hs", Filename.wstring().c_str(), FormatSystemErrorMessage(File.GetError()).c_str()); return false; } } bool WriteBufferToFile(const FPath& Filename, const FBuffer& Buffer, EFileMode FileMode) { return WriteBufferToFile(Filename, Buffer.Data(), Buffer.Size(), FileMode); } bool WriteBufferToFile(const FPath& Filename, const std::string& Buffer, EFileMode FileMode) { return WriteBufferToFile(Filename, (const uint8*)Buffer.data(), Buffer.length(), FileMode); } struct FIOBufferCache { std::mutex Mutex; struct FAllocation { const wchar_t* DebugName = nullptr; uint8* Memory; uint64 Size; }; std::vector AllocatedBlocks; // todo: hash table (though current numbers are very low, so...) std::vector AvailableBlocks; uint64 CurrentCacheSize = 0; uint64 CurrentAllocatedSize = 0; static constexpr uint64 MAX_CACHED_ALLOC_SIZE = 32_MB; static constexpr uint64 MAX_TOTAL_CACHE_SIZE = 4_GB; ~FIOBufferCache() { for (FAllocation& X : AllocatedBlocks) { UnsyncFree(X.Memory); } for (FAllocation& X : AvailableBlocks) { UnsyncFree(X.Memory); } } uint8* Alloc(uint64 Size, const wchar_t* DebugName) { std::lock_guard LockGuard(Mutex); uint64 BestBlockIndex = ~0ull; uint64 BestBlockSize = ~0ull; if (Size <= MAX_CACHED_ALLOC_SIZE) { Size = NextPow2(CheckedNarrow(Size)); for (uint64 I = 0; I < AvailableBlocks.size(); ++I) { FAllocation& Candidate = AvailableBlocks[I]; if (Candidate.Size < BestBlockSize && Candidate.Size >= Size) { BestBlockSize = Candidate.Size; BestBlockIndex = I; } } } FAllocation Allocation = {}; if (BestBlockSize != ~0ull) { FAllocation Candidate = AvailableBlocks[BestBlockIndex]; AllocatedBlocks.push_back(Candidate); AvailableBlocks[BestBlockIndex] = AvailableBlocks.back(); AvailableBlocks.pop_back(); Allocation = Candidate; } else { CurrentAllocatedSize += Size; Allocation.Memory = (uint8*)UnsyncMalloc(Size); UNSYNC_ASSERT(Allocation.Memory); Allocation.Size = Size; AllocatedBlocks.push_back(Allocation); if (Size <= MAX_CACHED_ALLOC_SIZE) { CurrentCacheSize += Allocation.Size; } } return Allocation.Memory; } void Free(uint8* Ptr) { std::lock_guard LockGuard(Mutex); uint64 AllocationIndex = ~0u; for (uint64 I = 0; I < AllocatedBlocks.size(); ++I) { if (AllocatedBlocks[I].Memory == Ptr) { AllocationIndex = I; break; } } UNSYNC_ASSERTF(AllocationIndex != ~0u, L"Trying to free an unknown IOBuffer."); FAllocation FreedBlock = AllocatedBlocks[AllocationIndex]; if (FreedBlock.Size <= MAX_CACHED_ALLOC_SIZE) { AvailableBlocks.push_back(FreedBlock); } else { UnsyncFree(FreedBlock.Memory); CurrentAllocatedSize -= FreedBlock.Size; } while (CurrentCacheSize > MAX_TOTAL_CACHE_SIZE && !AvailableBlocks.empty()) { FAllocation& LastBlock = AvailableBlocks.back(); UnsyncFree(LastBlock.Memory); UNSYNC_ASSERT(CurrentCacheSize >= LastBlock.Size); CurrentCacheSize -= LastBlock.Size; CurrentAllocatedSize -= LastBlock.Size; AvailableBlocks.pop_back(); } AllocatedBlocks[AllocationIndex] = AllocatedBlocks.back(); AllocatedBlocks.pop_back(); } uint64 GetCurrentSize() { std::lock_guard LockGuard(Mutex); return CurrentCacheSize; } }; static FIOBufferCache GIoBufferCache; uint8* AllocIoBuffer(uint64 Size, const wchar_t* DebugName) { return GIoBufferCache.Alloc(Size, DebugName); } void FreeIoBuffer(uint8* Ptr) { GIoBufferCache.Free(Ptr); } uint64 GetCurrentIoCacheSize() { return GIoBufferCache.GetCurrentSize(); } FFileAttributeCache CreateFileAttributeCache(const FPath& Root, const FSyncFilter* SyncFilter) { FFileAttributeCache Result; FTimePoint NextProgressLogTime = TimePointNow() + std::chrono::seconds(1); auto ReportProgress = [&NextProgressLogTime, &Result]() { FTimePoint TimeNow = TimePointNow(); if (TimeNow >= NextProgressLogTime) { LogPrintf(ELogLevel::Debug, L"Found files: %d\r", (int)Result.Map.size()); NextProgressLogTime = TimeNow + std::chrono::seconds(1); } }; FPath ResolvedRoot = SyncFilter ? SyncFilter->Resolve(Root) : Root; for (const std::filesystem::directory_entry& Dir : RecursiveDirectoryScan(ResolvedRoot)) { if (Dir.is_directory()) { continue; } if (SyncFilter && !SyncFilter->ShouldSync(Dir.path().native())) { continue; } FFileAttributes Attr = {}; Attr.Mtime = ToWindowsFileTime(Dir.last_write_time()); Attr.Size = Dir.file_size(); Attr.bValid = true; Attr.bReadOnly = IsReadOnly(Dir.status().permissions()); Result.Map[Dir.path().native()] = Attr; ReportProgress(); } ReportProgress(); return Result; } bool IsDirectory(const FPath& Path) { FFileAttributes Attr = GetFileAttrib(Path); return Attr.bValid && Attr.bDirectory; } bool PathExists(const FPath& Path) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::exists(ExtendedPath); } bool PathExists(const FPath& Path, std::error_code& OutErrorCode) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::exists(ExtendedPath, OutErrorCode); } bool CreateDirectories(const FPath& Path) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::create_directories(ExtendedPath); } bool EnsureDirectoryExists(const FPath& Path) { return (PathExists(Path) && IsDirectory(Path)) || CreateDirectories(Path); } bool FileRename(const FPath& From, const FPath& To, std::error_code& OutErrorCode) { FPath ExtendedFrom = MakeExtendedAbsolutePath(From); FPath ExtendedTo = MakeExtendedAbsolutePath(To); std::filesystem::rename(ExtendedFrom, ExtendedTo, OutErrorCode); return OutErrorCode.value() == 0; } bool FileCopy(const FPath& From, const FPath& To, std::error_code& OutErrorCode) { FPath ExtendedFrom = MakeExtendedAbsolutePath(From); FPath ExtendedTo = MakeExtendedAbsolutePath(To); return std::filesystem::copy_file(ExtendedFrom, ExtendedTo, OutErrorCode); } bool FileCopyOverwrite(const FPath& From, const FPath& To, std::error_code& OutErrorCode) { FPath ExtendedFrom = MakeExtendedAbsolutePath(From); FPath ExtendedTo = MakeExtendedAbsolutePath(To); return std::filesystem::copy_file(ExtendedFrom, ExtendedTo, std::filesystem::copy_options::overwrite_existing, OutErrorCode); } bool FileRemove(const FPath& Path, std::error_code& OutErrorCode) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::remove(ExtendedPath, OutErrorCode); } std::filesystem::recursive_directory_iterator RecursiveDirectoryScan(const FPath& Path) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::recursive_directory_iterator(ExtendedPath); } std::filesystem::directory_iterator DirectoryScan(const FPath& Path) { FPath ExtendedPath = MakeExtendedAbsolutePath(Path); return std::filesystem::directory_iterator(ExtendedPath); } FMemReader::FMemReader(const uint8* InData, uint64 InDataSize) : Data(InData), Size(InDataSize) { } uint64 FMemReader::Read(void* Dest, uint64 SourceOffset, uint64 ReadSize) { uint64 ReadEndOffset = std::min(SourceOffset + ReadSize, Size); uint64 ClampedReadSize = ReadEndOffset - SourceOffset; memcpy(Dest, Data + SourceOffset, ClampedReadSize); return ClampedReadSize; } FMemReaderWriter::FMemReaderWriter(uint8* InData, uint64 InDataSize) : FMemReader(InData, InDataSize), DataRw(InData) { } uint64 FMemReaderWriter::Write(const void* InData, uint64 DestOffset, uint64 WriteSize) { uint64 WriteEndOffset = std::min(DestOffset + WriteSize, Size); uint64 ClampedWriteSize = WriteEndOffset - DestOffset; if (ClampedWriteSize && DataRw) { memcpy(DataRw + DestOffset, InData, ClampedWriteSize); } return ClampedWriteSize; } FIOBuffer FIOBuffer::Alloc(uint64 Size, const wchar_t* DebugName) { UNSYNC_ASSERT(Size); FIOBuffer Result; Result.MemoryPtr = AllocIoBuffer(Size, DebugName); Result.MemorySize = Size; Result.DataPtr = Result.MemoryPtr; Result.DataSize = Size; Result.DebugName = DebugName; return Result; } FIOBuffer::~FIOBuffer() { Clear(); UNSYNC_CLOBBER(Canary); } FIOBuffer::FIOBuffer(FIOBuffer&& Rhs) { UNSYNC_ASSERT(Rhs.Canary == CANARY); std::swap(MemoryPtr, Rhs.MemoryPtr); std::swap(MemorySize, Rhs.MemorySize); std::swap(DataPtr, Rhs.DataPtr); std::swap(DataSize, Rhs.DataSize); } void FIOBuffer::Clear() { UNSYNC_ASSERT(Canary == CANARY); if (MemoryPtr) { FreeIoBuffer(MemoryPtr); MemoryPtr = nullptr; MemorySize = 0; DataPtr = nullptr; DataSize = 0; } } FIOBuffer& FIOBuffer::operator=(FIOBuffer&& Rhs) { UNSYNC_ASSERT(Canary == CANARY); UNSYNC_ASSERT(Rhs.Canary == CANARY); if (this != &Rhs) { std::swap(MemoryPtr, Rhs.MemoryPtr); std::swap(MemorySize, Rhs.MemorySize); std::swap(DataPtr, Rhs.DataPtr); std::swap(DataSize, Rhs.DataSize); Rhs.Clear(); } return *this; } void TestFileTime() { UNSYNC_LOG(L"TestFileTime()"); UNSYNC_LOG_INDENT; // 20231024004826Z - 2023 October 24 12:48:26 // unix 1698108506 // windows 133425821060000000 const uint64 BaseExpectedWindowsTime = 133425821060000000ull; // Check basic conversion functionality at maximum { UNSYNC_LOG(L"File time precision estimate:"); UNSYNC_LOG_INDENT; uint64 ExpectedWindowsTime = BaseExpectedWindowsTime + 9999999; std::filesystem::file_time_type FileTime = FromWindowsFileTime(ExpectedWindowsTime); uint64 RoundTripWindowsTime = ToWindowsFileTime(FileTime); uint64 NativeCount = FileTime.time_since_epoch().count(); uint64 Delta = ExpectedWindowsTime > RoundTripWindowsTime ? ExpectedWindowsTime - RoundTripWindowsTime : RoundTripWindowsTime - ExpectedWindowsTime; UNSYNC_LOG(L"ExpectedWindowsTime = %llu", llu(ExpectedWindowsTime)); UNSYNC_LOG(L"RoundTripWindowsTime = %llu", llu(RoundTripWindowsTime)); UNSYNC_LOG(L"NativeCount = %llu, Delta = %llu", llu(NativeCount), llu(Delta)); } // Check basic conversion functionality at 1 second precision { uint64 ExpectedWindowsTime = BaseExpectedWindowsTime; std::filesystem::file_time_type FileTime = FromWindowsFileTime(ExpectedWindowsTime); uint64 RoundTripWindowsTime = ToWindowsFileTime(FileTime); uint64 NativeCount = FileTime.time_since_epoch().count(); UNSYNC_ASSERTF(RoundTripWindowsTime == ExpectedWindowsTime, L"RoundTripWindowsTime is %llu, but expected to be %llu. Native count: %llu", llu(RoundTripWindowsTime), llu(ExpectedWindowsTime), llu(NativeCount)); } } uint64 BlockingReadLarge(FIOReader& Reader, uint64 Offset, uint64 Size, uint8* OutputBuffer, uint64 OutputBufferSize) { const uint64 BytesPerRead = 2_MB; const uint64 ReadEnd = std::min(Offset + Size, Reader.GetSize()); const uint64 ClampedSize = ReadEnd - Offset; std::atomic TotalReadSize = 0; if (ClampedSize == 0) { return TotalReadSize; } FSchedulerSemaphore IoSemaphore(*GScheduler, 16); FTaskGroup CopyTasks = GScheduler->CreateTaskGroup(&IoSemaphore); uint64 NumReads = DivUp(ClampedSize, BytesPerRead); for (uint64 ReadIndex = 0; ReadIndex < NumReads; ++ReadIndex) { const uint64 ThisBatchSize = CalcChunkSize(ReadIndex, BytesPerRead, ClampedSize); const uint64 OutputOffset = BytesPerRead * ReadIndex; const uint64 ThisReadOffset = Offset + OutputOffset; auto ReadCallback = [OutputBuffer, OutputBufferSize, &TotalReadSize, &CopyTasks](FIOBuffer CmdBuffer, uint64 CmdSourceOffset, uint64 CmdReadSize, uint64 OutputOffset) { UNSYNC_ASSERT(OutputOffset + CmdReadSize <= OutputBufferSize); CopyTasks.run( [OutputBuffer, OutputOffset, CmdReadSize, CmdBuffer = MakeShared(std::move(CmdBuffer)), &TotalReadSize]() { memcpy(OutputBuffer + OutputOffset, CmdBuffer->GetData(), CmdReadSize); TotalReadSize += CmdReadSize; }); }; Reader.ReadAsync(ThisReadOffset, ThisBatchSize, OutputOffset, ReadCallback); } Reader.FlushAll(); CopyTasks.wait(); return TotalReadSize; } void TestFileAttrib() { UNSYNC_LOG(L"TestFileAttrib()"); UNSYNC_LOG_INDENT; FPath TempDirPath = std::filesystem::temp_directory_path() / "unsync_test"; CreateDirectories(TempDirPath); const bool bDirectoryExists = PathExists(TempDirPath) && IsDirectory(TempDirPath); UNSYNC_ASSERT(bDirectoryExists); const FPath TestFilename = TempDirPath / "attrib.txt"; UNSYNC_LOG(L"Test file name: %ls", TestFilename.wstring().c_str()); if (PathExists(TestFilename)) { SetFileReadOnly(TestFilename, false); } const bool bFileWritten = WriteBufferToFile(TestFilename, "unsync test file"); UNSYNC_ASSERT(bFileWritten); const uint64 ExpectedFileTime = 133425821060000000ull; const bool bMtimeSet = SetFileMtime(TestFilename, ExpectedFileTime); UNSYNC_ASSERT(bMtimeSet); const FFileAttributes FileAttrib = GetFileAttrib(TestFilename); UNSYNC_ASSERT(!FileAttrib.bReadOnly); UNSYNC_ASSERT(FileAttrib.Mtime == ExpectedFileTime); const bool bReadOnlySet = SetFileReadOnly(TestFilename, true); UNSYNC_ASSERT(bReadOnlySet); const FFileAttributes FileAttribReadOnly = GetFileAttrib(TestFilename); UNSYNC_ASSERT(FileAttribReadOnly.bReadOnly); const bool bReadOnlyReset = SetFileReadOnly(TestFilename, false); UNSYNC_ASSERT(bReadOnlyReset); const FFileAttributes FileAttribNonReadOnly = GetFileAttrib(TestFilename); UNSYNC_ASSERT(!FileAttribNonReadOnly.bReadOnly); std::error_code ErrorCode; const bool bFileDeleted = FileRemove(TestFilename, ErrorCode); UNSYNC_ASSERT(bFileDeleted); } void TestPathUtil() { #if UNSYNC_PLATFORM_WINDOWS UNSYNC_LOG(L"TestPathUtil()"); UNSYNC_LOG_INDENT; // Test path manipulation helpers { FPath Simple = FPath("\\\\?\\UNC\\server\\subdir\\a\\b\\c"); FPath Extended = MakeExtendedAbsolutePath(Simple); UNSYNC_ASSERT(Simple == Extended); } { FPath Simple = FPath("\\\\?\\d:\\local\\subdir\\a\\b\\c"); FPath Extended = MakeExtendedAbsolutePath(Simple); UNSYNC_ASSERT(Simple == Extended); } { FPath Simple = FPath("d:\\local\\subdir\\a\\b\\c"); FPath Extended = MakeExtendedAbsolutePath(Simple); FPath Stripped = RemoveExtendedPathPrefix(Extended); UNSYNC_ASSERT(Stripped == Simple); } { FPath Simple = FPath("\\\\server\\local\\subdir\\a\\b\\c"); FPath Extended = MakeExtendedAbsolutePath(Simple); FPath Stripped = RemoveExtendedPathPrefix(Extended); UNSYNC_ASSERT(Stripped == Simple); } { FPath Base = FPath("d:\\local\\subdir"); FPath Full = FPath("d:\\local\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative == FPath("a\\b\\c")); } { FPath Base = FPath("\\\\server\\subdir"); FPath Full = FPath("\\\\server\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative == FPath("a\\b\\c")); } { FPath Base = FPath("\\\\?\\d:\\local\\subdir"); FPath Full = FPath("\\\\?\\d:\\local\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative == FPath("a\\b\\c")); } { FPath Base = FPath("\\\\?\\d:\\local\\subdir"); FPath Full = FPath("d:\\local\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative == FPath("a\\b\\c")); } { FPath Base = FPath("d:\\local\\subdir"); FPath Full = FPath("\\\\?\\d:\\local\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative == FPath("a\\b\\c")); } { FPath Base = FPath("d:\\local\\subdir"); FPath Full = FPath("\\\\?\\e:\\local\\subdir\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative.empty()); } { FPath Base = FPath("d:\\local\\subdir"); FPath Full = FPath("d:\\local\\a\\b\\c"); FPath Relative = GetRelativePath(Full, Base); UNSYNC_ASSERT(Relative.empty()); } #endif // UNSYNC_PLATFORM_WINDOWS } } // namespace unsync