diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index f916a13b..aeec2e21 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -681,14 +681,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore stream.Flush(true); } - if (File.Exists(path)) - { - ReplaceExistingFile(tempPath, path); - } - else - { - File.Move(tempPath, path); - } + StoreReplace.Replace(tempPath, path); } finally { @@ -768,26 +761,4 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return held; } } - - private static void ReplaceExistingFile(string sourcePath, string destinationPath) - { - for (var attempt = 0; attempt < 3; attempt++) - { - try - { - File.Replace(sourcePath, destinationPath, null); - return; - } - catch (IOException) when (attempt < 2) - { - Thread.Sleep(10 * (attempt + 1)); - } - catch (UnauthorizedAccessException) when (attempt < 2) - { - Thread.Sleep(10 * (attempt + 1)); - } - } - - File.Replace(sourcePath, destinationPath, null); - } } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index c461bc67..76010354 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -5219,14 +5219,7 @@ namespace GameCult.Caching stream.Flush(true); } - if (File.Exists(path)) - { - File.Replace(tempPath, path, null); - } - else - { - File.Move(tempPath, path); - } + StoreReplace.Replace(tempPath, path); } finally { diff --git a/src/GameCult.Caching/StoreReplace.cs b/src/GameCult.Caching/StoreReplace.cs new file mode 100644 index 00000000..5f16322c --- /dev/null +++ b/src/GameCult.Caching/StoreReplace.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; + +namespace GameCult.Caching +{ + /// The kernel32 calls a Windows replace needs, each answering 0 or a Win32 error code. + internal interface IReplaceKernel + { + /// CreateFileW(staged, DELETE|SYNCHRONIZE), FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, CloseHandle. + int RenamePosix(string staged, string destination); + + /// MoveFileExW(staged, destination, flags). + int MoveFileEx(string staged, string destination, uint flags); + + void Sleep(int milliseconds); + } + + /// + /// Owns how a staged file becomes the store, for the single-file and directory stores. Windows tries a POSIX-semantics + /// rename first (which makes a moment with the path absent rare for a lock-free reader, not impossible: a stalled rename was + /// measured leaving it absent for 5-47 ms), falls back to write-through MoveFileExW where that is unsupported or does not + /// resolve, falls back to the portable File.Replace if MoveFileExW itself does not resolve, and retries transient sharing + /// errors; other OSes keep rename. Mirrors cultcache-ts store-file.ts + /// replaceStoreFile (ruling variants:ruling:windows-replace-posix-first). + /// + internal static class StoreReplace + { + private const int ErrorAccessDenied = 5; + private const int ErrorSharingViolation = 32; + private const int ErrorLockViolation = 33; + private const int ErrorNotSupported = 50; + private const int ErrorInvalidParameter = 87; + private const int ReplaceAttempts = 25; + private const int ReplaceRetryMs = 20; + + /// MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH. + internal const uint MoveFileReplaceWriteThrough = 0x9; + + internal static readonly IReplaceKernel WindowsKernel = new Kernel32Replace(); + + public static void Replace(string staged, string destination) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ReplaceWindows(staged, destination, WindowsKernel); + } + else + { + ReplacePortable(staged, destination); + } + } + + private static void ReplacePortable(string staged, string destination) + { + if (File.Exists(destination)) + { + File.Replace(staged, destination, null); + } + else + { + File.Move(staged, destination); + } + } + + private static bool Unresolved(Exception ex) => ex is DllNotFoundException || ex is EntryPointNotFoundException; + + internal static void ReplaceWindows(string staged, string destination, IReplaceKernel kernel) + { + var posix = true; + var lastCode = 0; + for (var attempt = 1; attempt <= ReplaceAttempts; attempt++) + { + int code; + try + { + code = posix ? RenamePosixOrUnsupported(kernel, staged, destination) : kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough); + if (posix && (code == 1 || code == ErrorNotSupported || code == ErrorInvalidParameter)) + { + // POSIX semantics are unsupported here (FAT, exFAT, before Windows 10 1607): the rest of this call is write-through. + posix = false; + code = kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough); + } + } + catch (Exception ex) when (Unresolved(ex)) + { + // kernel32's MoveFileExW does not resolve here (a sandboxed player): the runtime-owned replace is all that is left. + ReplacePortable(staged, destination); + return; + } + + if (code == 0) + { + return; + } + + if (code != ErrorAccessDenied && code != ErrorSharingViolation && code != ErrorLockViolation) + { + throw new IOException($"CultCache could not replace the store: Win32 error {code}."); + } + + lastCode = code; + kernel.Sleep(ReplaceRetryMs); + } + + throw new IOException($"CultCache could not replace the store after {ReplaceAttempts} attempts: last Win32 error {lastCode}."); + } + + private static int RenamePosixOrUnsupported(IReplaceKernel kernel, string staged, string destination) + { + try + { + return kernel.RenamePosix(staged, destination); + } + catch (Exception ex) when (Unresolved(ex)) + { + // The POSIX call does not resolve: the same answer as "unsupported", so the replace falls back to MoveFileExW. + return ErrorNotSupported; + } + } + + private sealed class Kernel32Replace : IReplaceKernel + { + private const uint DeleteSynchronize = 0x00110000; + private const uint ShareAll = 0x7; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x80; + private const int FileRenameInfoEx = 22; + private const uint RenameReplacePosix = 0x3; + + public int RenamePosix(string staged, string destination) + { + var handle = CreateFileW(staged, DeleteSynchronize, ShareAll, IntPtr.Zero, OpenExisting, FileAttributeNormal, IntPtr.Zero); + if (handle == new IntPtr(-1)) + { + return Marshal.GetLastWin32Error(); + } + + try + { + // FILE_RENAME_INFO: Flags u32 at 0, RootDirectory handle, FileNameLength u32, FileName (UTF-16, offsets for this pointer size). + var name = System.Text.Encoding.Unicode.GetBytes(Path.GetFullPath(destination)); + var lengthOffset = 2 * IntPtr.Size; + var nameOffset = lengthOffset + 4; + var size = nameOffset + name.Length + 2; + var info = Marshal.AllocHGlobal(size); + try + { + for (var i = 0; i < size; i++) Marshal.WriteByte(info, i, 0); + Marshal.WriteInt32(info, 0, (int)RenameReplacePosix); + Marshal.WriteInt32(info, lengthOffset, name.Length); + Marshal.Copy(name, 0, info + nameOffset, name.Length); + return SetFileInformationByHandle(handle, FileRenameInfoEx, info, (uint)size) ? 0 : Marshal.GetLastWin32Error(); + } + finally { Marshal.FreeHGlobal(info); } + } + finally { CloseHandle(handle); } + } + + public int MoveFileEx(string staged, string destination, uint flags) + => MoveFileExW(staged, destination, flags) ? 0 : Marshal.GetLastWin32Error(); + + public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW(string fileName, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(IntPtr handle, int informationClass, IntPtr information, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW(string existing, string target, uint flags); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreReplaceTests.cs b/tests/GameCult.Caching.Tests/StoreReplaceTests.cs new file mode 100644 index 00000000..5f71b4a3 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreReplaceTests.cs @@ -0,0 +1,268 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public class StoreReplaceTests + { + private sealed class FakeKernel : IReplaceKernel + { + private readonly Queue _posix; + private readonly Queue _moveEx; + public readonly List Log = new List(); + public readonly List Sleeps = new List(); + public Exception? PosixThrows; + public Exception? MoveExThrows; + + public FakeKernel(int[] posix, int[] moveEx) + { + _posix = new Queue(posix); + _moveEx = new Queue(moveEx); + } + + public int RenamePosix(string staged, string destination) + { + Log.Add("posix"); + if (PosixThrows != null) throw PosixThrows; + return _posix.Count > 0 ? _posix.Dequeue() : 0; + } + + public int MoveFileEx(string staged, string destination, uint flags) + { + Log.Add("moveex:" + flags); + if (MoveExThrows != null) throw MoveExThrows; + return _moveEx.Count > 0 ? _moveEx.Dequeue() : 0; + } + + public void Sleep(int milliseconds) => Sleeps.Add(milliseconds); + } + + private static IOException Fails(FakeKernel kernel) + => Assert.Throws(() => StoreReplace.ReplaceWindows("a", "b", kernel))!; + + [Test] + public void APosixRenameThatWorksIsTheWholeReplace() + { + var kernel = new FakeKernel(new[] { 0 }, new int[0]); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix" })); + Assert.That(kernel.Sleeps, Is.Empty); + } + + [TestCase(1)] + [TestCase(50)] + [TestCase(87)] + public void WhereAPosixRenameIsUnsupportedTheReplaceFallsBackToWriteThroughMoveFileEx(int unsupported) + { + var kernel = new FakeKernel(new[] { unsupported }, new[] { 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + [Test] + public void OnceThePosixRenameIsUnsupportedTheRestOfTheCallStaysOnMoveFileEx() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 32, 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9", "moveex:9" })); + Assert.That(kernel.Sleeps, Is.EqualTo(new[] { 20 })); + } + + [TestCase(5)] + [TestCase(32)] + [TestCase(33)] + public void ATransientErrorRetriesTheSamePrimitiveAfterTwentyMilliseconds(int transient) + { + var kernel = new FakeKernel(new[] { transient, transient, 0 }, new int[0]); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "posix", "posix" })); + Assert.That(kernel.Sleeps, Is.EqualTo(new[] { 20, 20 })); + } + + [TestCase(1)] + [TestCase(50)] + [TestCase(87)] + public void AnUnsupportedCodeAfterTheFallbackIsAnErrorNotAnotherFallback(int unsupported) + { + var kernel = new FakeKernel(new[] { 87 }, new[] { unsupported }); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("Win32 error " + unsupported)); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + private static IEnumerable Unresolved() + { + yield return new DllNotFoundException("kernel32.dll"); + yield return new EntryPointNotFoundException("SetFileInformationByHandle"); + } + + [TestCaseSource(nameof(Unresolved))] + public void APosixCallThatDoesNotResolveTakesTheUnsupportedFallback(Exception unresolved) + { + var kernel = new FakeKernel(new int[0], new[] { 0 }) { PosixThrows = unresolved }; + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + [TestCaseSource(nameof(Unresolved))] + public void AMoveFileExThatDoesNotResolveDegradesToThePortableReplace(Exception unresolved) + { + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + var kernel = new FakeKernel(new[] { 87 }, new int[0]) { MoveExThrows = unresolved }; + StoreReplace.ReplaceWindows(staged, destination, kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + Assert.That(File.Exists(staged), Is.False); + + // Both calls unresolved: the same portable replace, here onto an absent destination. + File.Delete(destination); + File.WriteAllBytes(staged, new byte[] { 3 }); + var both = new FakeKernel(new int[0], new int[0]) { PosixThrows = unresolved, MoveExThrows = unresolved }; + StoreReplace.ReplaceWindows(staged, destination, both); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 3 })); + } + finally { Directory.Delete(dir, true); } + } + + [Test] + public void TheFallbackRunsInTheSameAttemptEvenOnTheLastOne() + { + var posix = new int[25].Fill(32); + posix[24] = 87; + var kernel = new FakeKernel(posix, new[] { 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log.Count, Is.EqualTo(26)); + Assert.That(kernel.Log[24], Is.EqualTo("posix")); + Assert.That(kernel.Log[25], Is.EqualTo("moveex:9")); + } + + [Test] + public void ARetriedMoveFileExThatAnswersUnsupportedFailsAfterOneCallNotTwo() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 32, 87 }); + Assert.That(Fails(kernel).Message, Does.Contain("Win32 error 87")); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9", "moveex:9" })); + } + + [Test] + public void ARetryThatNeverSucceedsGivesUpAfterTwentyFiveAttemptsNamingTheCode() + { + var kernel = new FakeKernel(new int[30].Fill(32), new int[0]); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("25 attempts").And.Contain("last Win32 error 32")); + Assert.That(kernel.Log.Count, Is.EqualTo(25)); + Assert.That(kernel.Sleeps.Count, Is.EqualTo(25)); + } + + [Test] + public void AnyOtherCodeThrowsAtOnceNamingTheCode() + { + var kernel = new FakeKernel(new[] { 2 }, new int[0]); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("Win32 error 2")); + Assert.That(kernel.Log.Count, Is.EqualTo(1)); + Assert.That(kernel.Sleeps, Is.Empty); + } + + [Test] + public void AFallbackThatFailsOtherwiseThrowsItsCode() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 3 }); + Assert.That(Fails(kernel).Message, Does.Contain("Win32 error 3")); + } + + [Test] + public void ReplaceOverAnExistingAndThenAnAbsentDestinationLeavesTheStagedBytes() + { + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + StoreReplace.Replace(staged, destination); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + Assert.That(File.Exists(staged), Is.False); + + File.Delete(destination); + File.WriteAllBytes(staged, new byte[] { 3 }); + StoreReplace.Replace(staged, destination); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 3 })); + } + finally { Directory.Delete(dir, true); } + } + + [Test] + public void OnWindowsAReplaceOverADestinationAReaderHoldsOpenSucceedsOnTheFirstAttempt() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Ignore("The real kernel runs on Windows only."); + + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + using (new FileStream(destination, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + var kernel = new CountingKernel(StoreReplace.WindowsKernel); + StoreReplace.ReplaceWindows(staged, destination, kernel); + Assert.That(kernel.PosixCalls, Is.EqualTo(1)); + Assert.That(kernel.MoveExCalls, Is.EqualTo(0)); + Assert.That(kernel.Sleeps, Is.EqualTo(0)); + Assert.That(kernel.LastCode, Is.EqualTo(0)); + } + + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + } + finally { Directory.Delete(dir, true); } + } + + private sealed class CountingKernel : IReplaceKernel + { + private readonly IReplaceKernel _inner; + public int PosixCalls, MoveExCalls, Sleeps, LastCode; + public CountingKernel(IReplaceKernel inner) { _inner = inner; } + + public int RenamePosix(string staged, string destination) + { + PosixCalls++; + return LastCode = _inner.RenamePosix(staged, destination); + } + + public int MoveFileEx(string staged, string destination, uint flags) + { + MoveExCalls++; + return LastCode = _inner.MoveFileEx(staged, destination, flags); + } + + public void Sleep(int milliseconds) { Sleeps++; _inner.Sleep(milliseconds); } + } + } + + internal static class ArrayFill + { + public static int[] Fill(this int[] array, int value) + { + for (var i = 0; i < array.Length; i++) array[i] = value; + return array; + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs index 7e210081..19c08a4b 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -364,11 +365,11 @@ public sealed class CultMeshSingleFileStoreTests [Test] public void ACacheOpeningDuringMeshWritesNeverSeesAnEmptyStore() { - // Windows replaces with ReplaceFile, which leaves a moment with nothing at the path for a lock-free reader: 1,722 empty - // stores in 17,313 opens at b1ff2dcb. That is the store's replace, not Mesh's, and it is the same for a cache flush beside a - // cache open; the fork is in the cut report. - if (OperatingSystem.IsWindows()) - Assert.Ignore("Windows File.Replace is not atomic to a lock-free reader; see cut report fork mesh-reader-windows-replace-window."); + // Windows replaces with a POSIX-semantics rename first (StoreReplace), which makes a lock-free reader finding the path + // absent rare, not impossible: a stalled rename was measured leaving it absent for 5-47 ms on Windows. So on Windows a + // brief not-found (a read throwing, or a cache opening onto an absent path) is tolerated and reported; elsewhere rename + // is atomic and none is allowed. + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var path = Path.Combine(_root, "race.cc"); CultMesh.WriteSingleFileDocument(path, Key, Publication()); @@ -386,7 +387,7 @@ public sealed class CultMeshSingleFileStoreTests Volatile.Write(ref stop, 1); }); - int opens = 0, empty = 0, reads = 0; + int opens = 0, empty = 0, reads = 0, absent = 0; var clock = Stopwatch.StartNew(); while (Volatile.Read(ref stop) == 0 && clock.Elapsed < TimeSpan.FromSeconds(60)) { @@ -397,6 +398,10 @@ public sealed class CultMeshSingleFileStoreTests CultMesh.ReadSingleFileDocumentPayload(path, Key, SchemaId()); reads++; } + catch (Exception ex) when (windows && (ex is FileNotFoundException || ex is DirectoryNotFoundException)) + { + absent++; + } catch (Exception ex) { errors.Enqueue("read: " + ex.GetType().Name + ": " + ex.Message); @@ -404,9 +409,10 @@ public sealed class CultMeshSingleFileStoreTests } writer.Wait(TimeSpan.FromSeconds(60)).Should().BeTrue(); - TestContext.Out.WriteLine($"mesh writes={writes} cache opens={opens} empty stores={empty} mesh reads ok={reads} errors={errors.Count}"); + TestContext.Out.WriteLine($"mesh writes={writes} cache opens={opens} empty stores={empty} not-found reads={absent} mesh reads ok={reads} errors={errors.Count}"); errors.Take(3).Should().BeEmpty(); - empty.Should().Be(0, "a cache opened in the window where nothing was at the path"); + if (!windows) + empty.Should().Be(0, "a cache opened in the window where nothing was at the path"); opens.Should().BeGreaterThan(0); }