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()
{