diff --git a/docs/reviews/2026-08-18-r3-test-truth-ledger.md b/docs/reviews/2026-08-18-r3-test-truth-ledger.md index abea0ba2..45b7161f 100644 --- a/docs/reviews/2026-08-18-r3-test-truth-ledger.md +++ b/docs/reviews/2026-08-18-r3-test-truth-ledger.md @@ -134,7 +134,7 @@ identifiable in the lane report. | T-011 51 output-only methods | classified in batch C | The reviewed current set is 51 methods / 70 cases. All carry `Purpose=Diagnostic`, preserving the apparatus while removing it from release pass totals. Contract-shaped names remain explicitly flagged until a stable oracle exists. | | T-012 source-text freezes | requires semantic replacement map | Retain whole-tree dependency rules; remove exact-text freezes only when an equivalent semantic/behavioral guard is identified. | | T-013 controller self-comparison | high-confidence cleanup batch A | Capture the first controller next to the first body and compare every retry with that reference. | -| T-014 seven load-sensitive tests | active; first mechanism reproduced | `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray` reported 6,904 B only while three App test hosts ran concurrently, then passed in an isolated run. Preserve both observations and remove ambient process-allocation sensitivity rather than loosening the zero-allocation oracle. | +| T-014 seven load-sensitive tests | six mechanisms repaired in batch F; one product defect classified | Virtual/wall-clock mixing, tiered-JIT allocation noise, a live controller clock, and a ThreadPool-start timing oracle were removed without weakening behavioral contracts. `DatSoundCache` #321 is a real in-flight-entry race and now runs as `Status=KnownFailure` pending a product fix. | | T-015 four non-prerequisite skips | resolved in batches A/B | PVS scaffold deleted with rationale preserved; redundant chat/radar generators deleted in favor of the comprehensive Manual lane; tower oracle is `Status=KnownFailure`. | | T-016 historical test taxonomy | open | Rename/re-home only after each test's durable owner and oracle are established. | | T-017 Avalonia ownership | reopened and closed in batch D | The full gate exposed the same compositor ownership class between six newer `MainWindowViewTests` facts. Their six named assertion phases now run in one owned Avalonia application session; 25 fresh-process stress iterations and the complete gate pass. | @@ -330,3 +330,43 @@ recognition. The public production factory remains wired directly to The focused class passes 4/4 in 17 ms with exact 10 ms and 600 ms virtual intervals. The complete Release build then passed with 0 warnings/errors, and the no-retry hermetic gate passed 14,392/14,392 with zero skips or failures. + +## Batch F load-sensitive contracts + +The seven cases in T-014 have now been separated into six unreliable test +mechanisms and one real product defect: + +- #308 `NakEmissionTests.LossSoak...` no longer mixes its virtual transport + clock with a 60-second `DateTime.UtcNow` cutoff and fixed sleeps. Its two + convergence phases each permit exactly 120 half-second virtual steps and + yield to the background receiver. The full 10,000-message oracle is intact; + 25 fresh-process repetitions pass. +- #302 and #346, the two `PortalProjectionTests` allocation contracts, now + cross tiered-JIT/PGO thresholds before measuring and take the minimum of five + warmed 1,000-operation batches. A linear result-array regression would + allocate in every batch and still fail far above the existing ceilings. +- #336 `RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` + now earns the word "warmed": it performs one complete 10,000-refresh warmup + and requires at least one of five subsequent 10,000-refresh batches to + allocate exactly zero bytes. It passes in 25 fresh processes. +- #340 `StreamingWorkBudgetTests.DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget` + now supplies a frozen meter clock through an internal test-only constructor. + The public production constructor still supplies `Stopwatch.GetTimestamp` + and `Stopwatch.Frequency` exactly as before. +- #402 `LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` now + starts a dedicated thread and observes it in `WaitSleepJoin` on the held + monitor before checking that no DAT read occurred. It no longer treats a + ThreadPool start within two seconds or an arbitrary 100 ms wait as evidence. +- #321 `DatSoundCacheTests.GetWave_ConcurrentSameId_PublishesOneCanonicalWaveAndDecodesOnce` + exposes a real race. A caller can pass the resident-cache check, pause, and + reach `_inflight` after the winning caller has admitted the wave and removed + its `Lazy`, producing a second decode. The assertion and rationale are + preserved under `Status=KnownFailure`; R3 makes no cache behavior change. + +The four changed App cases pass 100/100 across 25 fresh processes. The Runtime +allocation case passes 25/25, and the Core.Net loss soak passes 25/25. Complete +batch verification then passed: the 44-project Release build reports zero +warnings/errors, the refreshed inventory reports two explicit known-failure +methods and 15 remaining direct `Thread.Sleep` methods, and the no-retry +hermetic gate passed 14,391/14,391 with zero skips or failures. The one-pass +reduction is exactly #321 leaving the release lane. diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 9cd398cf..30bddb95 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -63,6 +63,8 @@ public sealed class StreamingController _isPublicationBlockedByRetirement; private readonly StreamingWorkBudgetOptions _configuredWorkBudgetOptions; private StreamingWorkBudget _workBudget; + private readonly Func _workTimestamp; + private readonly long _workTimestampFrequency; private StreamingWorkMeter? _activeWorkMeter; private StreamingWorkMeterSnapshot _lastWorkMeter; private readonly StreamingCompletionQueue _completionQueue = new(); @@ -376,6 +378,37 @@ public sealed class StreamingController LandblockPresentationPipeline presentationPipeline, Action? clearPendingLoads = null, StreamingWorkBudgetOptions? workBudgetOptions = null) + : this( + enqueueLoad, + enqueueUnload, + completionSource, + state, + nearRadius, + farRadius, + presentationPipeline, + Stopwatch.GetTimestamp, + Stopwatch.Frequency, + clearPendingLoads, + workBudgetOptions) + { + } + + /// + /// Deterministic meter-clock seam for scheduler policy tests. Production + /// construction always uses the public overload and . + /// + internal StreamingController( + Action enqueueLoad, + Action enqueueUnload, + ILandblockCompletionSource completionSource, + GpuWorldState state, + int nearRadius, + int farRadius, + LandblockPresentationPipeline presentationPipeline, + Func workTimestamp, + long workTimestampFrequency, + Action? clearPendingLoads = null, + StreamingWorkBudgetOptions? workBudgetOptions = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; @@ -390,6 +423,11 @@ public sealed class StreamingController _configuredWorkBudgetOptions = workBudgetOptions ?? StreamingWorkBudgetOptions.Default; _workBudget = _configuredWorkBudgetOptions.ToBudget(); + _workTimestamp = workTimestamp + ?? throw new ArgumentNullException(nameof(workTimestamp)); + if (workTimestampFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(workTimestampFrequency)); + _workTimestampFrequency = workTimestampFrequency; if (!_presentation.MatchesState(_state)) { throw new ArgumentException( @@ -679,6 +717,8 @@ public sealed class StreamingController _configuredWorkBudgetOptions .HoldDestinationCeilingMilliseconds) : _workBudget, + _workTimestamp, + _workTimestampFrequency, destinationReservationActive: destinationHold); _activeWorkMeter = meter; try diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index b6557ecb..294cec22 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -478,25 +478,44 @@ public class PortalProjectionTests Assert.Equal(4, warm.Count); } - long before = GC.GetAllocatedBytesForCurrentThread(); - int totalVertices = 0; - for (int i = 0; i < 1_000; i++) + // Cross the tiered-JIT/PGO thresholds before measuring. A single + // warm call does not make a 1,000-call loop a warmed hot path. + for (int i = 0; i < 2_000; i++) { using PortalProjection.ClipPolygonLease lease = PortalProjection.ProjectToClipLease( opening, Matrix4x4.Identity, viewProjection); - totalVertices += lease.Count; + _ = lease.Count; } - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(4_000, totalVertices); + int totalVertices = 0; + long minimumAllocated = long.MaxValue; + for (int sample = 0; sample < 5; sample++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + using PortalProjection.ClipPolygonLease lease = + PortalProjection.ProjectToClipLease( + opening, + Matrix4x4.Identity, + viewProjection); + totalVertices += lease.Count; + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + minimumAllocated = Math.Min(minimumAllocated, allocated); + } + + Assert.Equal(20_000, totalVertices); // A tiered-JIT/ArrayPool bookkeeping transition can contribute a few hundred fixed bytes - // to the first measured batch on some runtimes. Keep the ceiling far below the former - // per-call result-array regression (~448 KB / 1,000 calls), while accepting that fixed - // process noise so this test measures linear hot-path allocation rather than JIT timing. - Assert.True(allocated <= 1_024, $"pooled projections allocated {allocated:N0} bytes"); + // to an individual batch on some runtimes. At least one of five warmed batches must stay + // far below the former per-call result-array regression (~448 KB / 1,000 calls), which + // measures linear hot-path allocation without treating JIT timing as product allocation. + Assert.True( + minimumAllocated <= 1_024, + $"best warmed pooled-projection batch allocated {minimumAllocated:N0} bytes"); } [Fact] @@ -529,20 +548,31 @@ public class PortalProjectionTests Assert.Equal(expected, firstSnapshot); Assert.Equal(expected, second); - long before = GC.GetAllocatedBytesForCurrentThread(); - float checksum = 0f; - for (int i = 0; i < 1_000; i++) + for (int i = 0; i < 2_000; i++) { store.ResetUsage(); - Vector2[] reused = PortalProjection.ClipToRegion( - subject.AsSpan(), region, store); - checksum += reused[0].X; + _ = PortalProjection.ClipToRegion(subject.AsSpan(), region, store); + } + + float checksum = 0f; + long minimumAllocated = long.MaxValue; + for (int sample = 0; sample < 5; sample++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + store.ResetUsage(); + Vector2[] reused = PortalProjection.ClipToRegion( + subject.AsSpan(), region, store); + checksum += reused[0].X; + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + minimumAllocated = Math.Min(minimumAllocated, allocated); } - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(float.IsFinite(checksum)); - Assert.True(allocated <= 4_096, - $"frame-owned portal clipping allocated {allocated:N0} bytes"); + Assert.True(minimumAllocated <= 4_096, + $"best warmed frame-owned clip batch allocated {minimumAllocated:N0} bytes"); } [Fact] diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs index 64cd75d1..bbd67b22 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs @@ -193,32 +193,57 @@ public sealed class LandblockBuildFactoryTests } [Fact] - public async Task Build_UsesTheSuppliedSharedReaderGate() + public void Build_UsesTheSuppliedSharedReaderGate() { var dat = CreateDat(out RecordingDatProxy proxy); proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); object gate = new(); var factory = Factory(dat, gate); using var started = new ManualResetEventSlim(); + LandblockBuild? result = null; + Exception? workerError = null; + var worker = new Thread(() => + { + started.Set(); + try + { + result = factory.Build(Request(LandblockStreamJobKind.LoadFar)); + } + catch (Exception error) + { + workerError = error; + } + }) + { + IsBackground = true, + Name = "LandblockBuildFactory shared-gate contract", + }; Monitor.Enter(gate); - Task build; + bool startedInTime; + bool blockedOnGate; + bool readWhileBlocked; try { - build = Task.Run(() => - { - started.Set(); - return factory.Build(Request(LandblockStreamJobKind.LoadFar)); - }); - Assert.True(started.Wait(TimeSpan.FromSeconds(2))); - Assert.False(proxy.ReadObserved.Wait(TimeSpan.FromMilliseconds(100))); + worker.Start(); + startedInTime = started.Wait(TimeSpan.FromSeconds(5)); + blockedOnGate = startedInTime && SpinWait.SpinUntil( + () => (worker.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); + readWhileBlocked = proxy.ReadObserved.IsSet; } finally { Monitor.Exit(gate); } - Assert.NotNull(await build); + bool joined = worker.Join(TimeSpan.FromSeconds(10)); + Assert.True(startedInTime, "the dedicated build thread did not start"); + Assert.True(blockedOnGate, "the build thread never blocked on the supplied gate"); + Assert.False(readWhileBlocked, "the DAT read bypassed the supplied gate"); + Assert.True(joined, "the build thread did not finish after the gate was released"); + Assert.Null(workerError); + Assert.NotNull(result); Assert.True(proxy.ReadObserved.IsSet); } diff --git a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs index 71d15663..a1a5d73b 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs @@ -961,6 +961,8 @@ public sealed class StreamingWorkBudgetTests nearRadius: 1, farRadius: 1, presentationPipeline: presentation, + workTimestamp: static () => 0, + workTimestampFrequency: 1_000, workBudgetOptions: options); } diff --git a/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs index 62ea2300..7d6cd9a0 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs @@ -759,7 +759,7 @@ public sealed class NakEmissionTests /// retail-faithful steady state). The session survives the whole run. /// [Fact] - public void LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge() + public async Task LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge() { var transport = new FakeAceTransport(); var session = new WorldSession( @@ -808,7 +808,7 @@ public sealed class NakEmissionTests session.Tick(); if ((i & 15) == 0) - Thread.Sleep(1); + await Task.Yield(); if (i % 500 == 0) { @@ -837,16 +837,18 @@ public sealed class NakEmissionTests // recoverable once further sequenced C2S traffic arrives. Real // clients keep talking; the trickle models that. int trickle = 0; - DateTime deadline = DateTime.UtcNow.AddSeconds(60); - while (DateTime.UtcNow < deadline - && (s2cReceived != MessagesEachWay - || c2sDispatched != MessagesEachWay)) + const int MaxConvergenceSteps = 120; // 60 virtual seconds at 0.5 s/step + for (int step = 0; + step < MaxConvergenceSteps + && (s2cReceived != MessagesEachWay + || c2sDispatched != MessagesEachWay); + step++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); session.SendTalk($"trickle {trickle++}"); transport.PumpServer(); session.Tick(); - Thread.Sleep(1); + await Task.Yield(); } // Convergence phase 2: quiet drain. Acks keep flowing on the @@ -856,7 +858,7 @@ public sealed class NakEmissionTests // traffic (two arrivals) before ACE's gap detection can fire — // an occasional healer send, only while the cache is stuck. int quietIterations = 0; - while (DateTime.UtcNow < deadline) + for (int step = 0; step < MaxConvergenceSteps; step++) { transport.Clock.Advance(TimeSpan.FromMilliseconds(500)); if (session.Transport!.Outbound.CacheDepth > 1 @@ -867,7 +869,7 @@ public sealed class NakEmissionTests transport.PumpServer(); session.Tick(); - Thread.Sleep(1); + await Task.Yield(); if (s2cReceived == MessagesEachWay && c2sDispatched == MessagesEachWay diff --git a/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs b/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs index 803c49f4..9a16ca5a 100644 --- a/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs +++ b/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs @@ -139,8 +139,14 @@ public sealed class DatSoundCacheTests } [Fact] + [Trait("Status", "KnownFailure")] public void GetWave_ConcurrentSameId_PublishesOneCanonicalWaveAndDecodesOnce() { + // #321: a stale caller can pass the resident-cache check, pause, and + // reach _inflight after the winning caller has decoded, admitted, and + // removed its Lazy. That second Lazy performs a duplicate decode. + // R3 preserves this product defect as an explicit known-failure lane; + // fixing the cache algorithm is outside the test-only cleanup scope. var dats = new FakeDatObjectSource(); dats.AddWave(1, MakePcmWave(1, dataBytes: 1000)); var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs index f23ea6f7..01447435 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs @@ -2372,13 +2372,24 @@ public sealed class RuntimeCollisionReportingStateTests Collisions(target.Key!.Value.LocalEntityId); Assert.False(Handle(lifetime, owner, 1d, collision)); - _ = GC.GetAllocatedBytesForCurrentThread(); - long before = GC.GetAllocatedBytesForCurrentThread(); + // One call does not cross tiered-JIT/PGO thresholds for this stack. + // Warm the same steady-contact path at the measured batch size. for (int index = 0; index < 10_000; index++) _ = Handle(lifetime, owner, 1.1d, collision); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.Equal(0, allocated); + long minimumAllocated = long.MaxValue; + for (int sample = 0; sample < 5; sample++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < 10_000; index++) + _ = Handle(lifetime, owner, 1.1d, collision); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + minimumAllocated = Math.Min(minimumAllocated, allocated); + } + + // A real per-refresh regression allocates in every batch; a one-time + // tier transition does not invalidate the warmed steady-state claim. + Assert.Equal(0, minimumAllocated); Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() .TrackedObjectCount); }