From be91ea837a1318ba85198c74068b03f151843d34 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 19:36:41 +0200 Subject: [PATCH] Pin that a failed hold publishes what it minted and an unsubscribed observer stops A hold whose body throws after it admitted changes still publishes them, and Held now assigns its result up front so it compiles under Stryker Safe Mode (CS0165 had dropped every mutant in it). Disposing a Watch subscription removes the observer from the cache. Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Caching/CultCache.cs | 2 +- .../CultCacheObserverTests.cs | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 245e9367..d9e8c2f8 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2391,7 +2391,7 @@ namespace GameCult.Caching throw new InvalidOperationException( "A cache hold was entered inside another cache's hold; a thread holds one cache's gate at a time."); var mine = _held = new List<(Change Change, bool Loaded)>(); - T result; + var result = default(T)!; try { lock (_gate) diff --git a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs index 7fde01e7..fa87ea4f 100644 --- a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs +++ b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs @@ -122,6 +122,61 @@ namespace GameCult.Caching.Tests }); } + [Test] + public void AnUnsubscribedObserverStopsReceiving() + { + using var cache = new CultCache(Registry); + var seen = new List(); + var subscription = cache.Watch().Subscribe(change => seen.Add(change.Document!.Text)); + Send(cache, "one"); + + subscription.Dispose(); + Send(cache, "two"); + + Assert.That(seen, Is.EqualTo(new[] { "one" })); + } + + // A store that admits a load and then fails: the hold's body throws after the changes were minted. + private sealed class LoadThenFailStore : CacheBackingStore + { + public bool Armed; + + public override void PullAll() => Held(() => + { + if (!Armed) + return; + var record = new CultStoredDocument( + KeyOf("loaded"), "2026-01-01T00:00:00.0000000+00:00", Registry.GetRequired(typeof(ObservedPing)), new ObservedPing { Text = "loaded" }); + Loaded!(new[] { record }, Array.Empty()); + throw new InvalidOperationException("store failed after the load"); + }); + + public override void Push(CultStoredDocument entry) { } + public override void Delete(CultStoredDocument entry) { } + public override void PushAll() { } + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; + } + + [Test] + public void AHoldWhoseBodyThrowsStillPublishesTheChangesItMinted() + { + using var cache = new CultCache(Registry); + var store = new LoadThenFailStore(); + cache.AddBackingStore(store); + var seen = new List(); + using var subscription = cache.Watch().Subscribe(change => seen.Add(change.Sequence)); + store.Armed = true; + + var thrown = Assert.Throws(() => store.PullAll())!; + + Assert.Multiple(() => + { + Assert.That(thrown.Message, Is.EqualTo("store failed after the load"), "the body's own exception wins"); + Assert.That(cache.Get(KeyOf("loaded")), Is.Not.Null, "the load landed"); + Assert.That(seen, Is.EqualTo(new[] { 1L }), "and reached its observers"); + }); + } + [Test] public void DisposeCompletesObserversAndACompletedCacheCompletesNewOnes() {