From 8df0c4b25a9519acf46253469dfdae002f6cc814 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 23:23:31 +0000 Subject: [PATCH] Soft flush: pin the host and node passthroughs with a held record (absent writes, readable skips, unreadable refuses); XML-doc the one meaning on the three FlushAsync(bool soft) overloads Co-Authored-By: Claude Sonnet 5.5 --- src/GameCult.Caching/CultCache.cs | 5 ++ src/GameCult.Mesh/CultMesh.cs | 5 +- src/GameCult.Networking/CultNetLocal.cs | 5 +- .../NetworkingTests.cs | 64 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 91dfa9ac..8b6ed01b 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2547,6 +2547,11 @@ namespace GameCult.Caching return true; }); + /// + /// Flushes every dirty backing store. With the flush has one meaning, decided by each store's + /// own read: a store with nothing at its path is written; a store that reads (empty included) is skipped and its + /// staged writes stay staged; a store that cannot be read refuses with . + /// public Task FlushAsync(bool soft = false) { FlushAllBackingStores(soft); diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 6ea48e0d..32b45e1f 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -428,7 +428,10 @@ namespace GameCult.Mesh } /// - /// Flushes the durable cache. + /// Flushes the durable cache. With the flush has one meaning, decided by the store's own + /// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads + /// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with + /// and is left untouched. /// public Task FlushAsync(bool soft = false) { diff --git a/src/GameCult.Networking/CultNetLocal.cs b/src/GameCult.Networking/CultNetLocal.cs index e884cd8d..60937574 100644 --- a/src/GameCult.Networking/CultNetLocal.cs +++ b/src/GameCult.Networking/CultNetLocal.cs @@ -96,7 +96,10 @@ namespace GameCult.Networking } /// - /// Flushes the durable cache. + /// Flushes the durable cache. With the flush has one meaning, decided by the store's own + /// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads + /// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with + /// and is left untouched. /// public Task FlushAsync(bool soft = false) { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index b566dca3..1aee870d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -7490,6 +7490,70 @@ namespace GameCult.Networking.Tests Assert.That(File.ReadAllBytes(cachePath), Is.Not.EqualTo(before), "a plain node flush after a skipped soft flush did not land the staged write"); } + // The cache HOLDS a record in every step: a passthrough that decides soft from the cache's memory instead of + // handing the store the flag fails the absent step (it skips and writes nothing) or the unreadable step (it + // never reaches the reader that refuses). + [Test] + public async Task CultNetLocal_Host_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-soft-held-{Guid.NewGuid():N}.msgpack"); + CultNetHost? host = null; + try + { + host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "First" }); + Assert.That(File.Exists(filePath), Is.False); + + await host.FlushAsync(soft: true); + Assert.That(File.Exists(filePath), Is.True, "a soft host flush over an absent store, with a record held, wrote nothing"); + var written = File.ReadAllBytes(filePath); + + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "Second" }); + await host.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(written), "a soft host flush rewrote a readable store"); + + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + File.WriteAllBytes(filePath, junk); + Assert.That(async () => await host.FlushAsync(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(junk)); + } + finally + { + host?.Dispose(); + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task CultMeshNode_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-soft-held", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var cachePath = Path.Combine(rootPath, "world.ccmp"); + var documents = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument()); + using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions + { + StartServer = false, + DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "soft-held-local", DocumentRegistry = documents } + }); + await node.Database.PutAsync(new CultRecordKey("note:one"), new MeshQuickstartNote { NoteId = "note:one", Body = "one" }); + Assert.That(File.Exists(cachePath), Is.False); + + await node.FlushAsync(soft: true); + Assert.That(File.Exists(cachePath), Is.True, "a soft node flush over an absent store, with a record held, wrote nothing"); + var written = File.ReadAllBytes(cachePath); + + await node.Database.PutAsync(new CultRecordKey("note:two"), new MeshQuickstartNote { NoteId = "note:two", Body = "two" }); + await node.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(written), "a soft node flush rewrote a readable store"); + + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + File.WriteAllBytes(cachePath, junk); + Assert.That(async () => await node.FlushAsync(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(junk)); + } + [Test] public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server() {