From ad7ebe9425b72da0b84ef16de1dfdc897a5661b8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 13:03:30 +0200 Subject: [PATCH] test: observe landblock worker joins --- .../2026-08-18-r3-test-truth-ledger.md | 39 +++++++++++-- .../Streaming/LandblockStreamerPoolTests.cs | 30 ++++++++-- .../Streaming/LandblockStreamerTests.cs | 55 ++++++++++++++++--- 3 files changed, 107 insertions(+), 17 deletions(-) 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 615ed4de..4c75a90e 100644 --- a/docs/reviews/2026-08-18-r3-test-truth-ledger.md +++ b/docs/reviews/2026-08-18-r3-test-truth-ledger.md @@ -53,8 +53,8 @@ inventory refreshed for Batch L reports: | Constant-truth assertion methods / sites | 0 / 0 | | Reviewed syntactic self-comparison methods / sites | 4 / 5 | | Reviewed diagnostic methods | 57 | -| Methods directly using `Thread.Sleep` / `Task.Delay` | 15 / 19 | -| Cancellable-infinite-only / elapsed-time methods | 5 / 29 | +| Methods directly using `Thread.Sleep` / `Task.Delay` | 15 / 17 | +| Cancellable-infinite-only / elapsed-time methods | 5 / 27 | | Methods directly reading environment variables | 47 | | Methods directly reading `.cs` source text | 63 | @@ -147,7 +147,7 @@ identifiable in the lane report. | T-016 historical test taxonomy | first descriptive-identity batch active in batch J | Remove opaque AP/R/J/K/Slice codes from current test/type names while preserving provenance in comments and ledgers. The 47 `Issue###` files remain an explicit user-decision set because their IDs still connect tests to retail evidence. | | 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. | | T-018 stderr observer race | resolved in R2 | Live reader shares writes/deletes; 25 focused repetitions plus the complete gate. | -| T-019 remaining explicit waits | classified in batch H; four fixed-delay negative oracles resolved in batch M, five remain | Five methods are deterministic cancellation stubs, not wall-clock tests. Preserve intentional real-time contracts and bounded integration polling. The remaining cleanup owners are landblock streamer/pool disposal, headless late-subscriber replay, and two launcher installer/recovery contracts. | +| T-019 remaining explicit waits | classified in batch H; six fixed-delay negative oracles resolved in batches M/N, three remain | Five methods are deterministic cancellation stubs, not wall-clock tests. Preserve intentional real-time contracts and bounded integration polling. The remaining cleanup owners are headless late-subscriber replay and two launcher installer/recovery contracts. | | T-020 exact duplicate bodies/data | resolved in batch K | Four redundant executions were removed. The remaining 11 body-equivalent groups are intentionally split theories with disjoint, meaningfully named datasets; the inventory enforces zero repeated rows within a theory or across body-equivalent theories. | | T-021 suspicious assertion shapes | resolved in batch L | The inventory enforces zero `Assert.True(true)` / `Assert.False(false)` sites and reports syntactic self-comparisons for review. All five current self-comparisons assert meaningful determinism or stable identity. | @@ -448,7 +448,7 @@ invocation instead of treating both method-level booleans as equivalent. The | Intentional real-time protocol/timeout contract | 4 | Keep with explicit bounds: two Live handshake race delays, the one-second net-probe cadence, and continuous unrelated shutdown drain. | | Cooperative yield while virtual-clock/background transport work drains | 8 | Retain for now; prefer an observable receiver/worker signal when that seam exists. The virtual behavioral oracle does not derive from the sleep duration. | | Bounded completion/readiness polling | 11 | Retain as integration polling with a terminal assertion and deadline; improve opportunistically with events, not by busy-spinning. | -| Fixed delay used to prove another operation is still blocked | 9 originally; 5 remain | Batch M replaces four with observed thread wait state. Replace the remaining elapsed-time absence-of-completion checks with an observed wait state or test seam. | +| Fixed delay used to prove another operation is still blocked | 9 originally; 3 remain | Batches M/N replace six with observed thread wait state. Replace the remaining elapsed-time absence-of-completion checks with an observed wait state or test seam. | | Positive completion timeout guard | 1 | Keep. The two-second `WhenAny` in `RuntimeCharacterStateTests` fails only if the operation does not complete; it does not delay a passing run. | The original nine fixed-delay negative oracles are: @@ -665,3 +665,34 @@ Batch M verification: Five fixed-delay negative oracles remain: the landblock streamer and pool disposal contracts, headless concurrent late-subscriber replay, and the two launcher installer/recovery contracts. + +## Batch N observable landblock-worker joins + +The two landblock disposal contracts used 50–100 ms delays to infer that +`LandblockStreamer.Dispose` was waiting for its dedicated worker threads. +Batch N replaces those delays with direct observation of the disposer threads: + +- `LandblockStreamerTests.DisposeAndConcurrentDisposeWaitForInFlightLoad` + observes the primary disposer blocked in a worker `Join`, then observes the + concurrent disposer blocked behind the disposal monitor; and +- `LandblockStreamerPoolTests.Dispose_JoinsEveryWorkerInThePool` observes the + disposer blocked while all three controlled worker lanes remain in their + loader callbacks. + +Both tests then release the loaders, require every disposal thread to join +within the existing bound, and retain their exception and post-disposal +inertness checks. Production code is unchanged. + +Batch N verification: + +- both focused contracts pass; +- 25 fresh-process iterations of each pass, for 50/50 observed-wait cases; +- the refreshed inventory reduces direct `Task.Delay` methods from 19 to 17 + and elapsed-time methods from 29 to 27; +- the complete 44-project Release build reports zero warnings and zero errors; + and +- the no-retry complete hermetic Release gate remains 14,382/14,382 with zero + skips or failures across all 12 test assemblies. + +Three fixed-delay negative oracles remain: headless concurrent late-subscriber +replay and the two launcher installer/recovery contracts. diff --git a/tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs index 75caac17..4a36610b 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs @@ -377,7 +377,7 @@ public sealed class LandblockStreamerPoolTests } [Fact] - public async Task Dispose_JoinsEveryWorkerInThePool() + public void Dispose_JoinsEveryWorkerInThePool() { const int workerCount = 3; using var release = new ManualResetEventSlim(); @@ -408,13 +408,31 @@ public sealed class LandblockStreamerPoolTests Assert.True(entered.Wait(TimeSpan.FromSeconds(5))); Assert.Equal(workerCount, loaderThreads.Count); - Task dispose = Task.Run(streamer.Dispose); - await Task.Delay(100); - // Dispose must be blocked on the still-building workers. - Assert.False(dispose.IsCompleted); + Exception? disposeError = null; + var disposeThread = new Thread(() => + { + try + { + streamer.Dispose(); + } + catch (Exception error) + { + disposeError = error; + } + }) + { + IsBackground = true, + Name = "LandblockStreamer pool dispose contract", + }; + disposeThread.Start(); + Assert.True(SpinWait.SpinUntil( + () => (disposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)), + "dispose never waited for the still-building workers"); release.Set(); - await dispose.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(disposeThread.Join(TimeSpan.FromSeconds(5))); + Assert.Null(disposeError); Assert.Throws( () => streamer.EnqueueLoad(0x1234FFFFu, LandblockStreamJobKind.LoadFar)); diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index 03a466f9..b36995a9 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -342,7 +342,7 @@ public class LandblockStreamerTests } [Fact] - public async Task DisposeAndConcurrentDisposeWaitForInFlightLoad() + public void DisposeAndConcurrentDisposeWaitForInFlightLoad() { using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); @@ -359,14 +359,55 @@ public class LandblockStreamerTests streamer.EnqueueLoad(0x12340000u); Assert.True(entered.Wait(TimeSpan.FromSeconds(2))); - Task firstDispose = Task.Run(streamer.Dispose); - Task secondDispose = Task.Run(streamer.Dispose); - await Task.Delay(50); - Assert.False(firstDispose.IsCompleted); - Assert.False(secondDispose.IsCompleted); + Exception? firstError = null; + var firstDisposeThread = new Thread(() => + { + try + { + streamer.Dispose(); + } + catch (Exception error) + { + firstError = error; + } + }) + { + IsBackground = true, + Name = "LandblockStreamer primary dispose contract", + }; + firstDisposeThread.Start(); + Assert.True(SpinWait.SpinUntil( + () => (firstDisposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(2)), + "the primary dispose never waited for the in-flight load"); + + Exception? secondError = null; + var secondDisposeThread = new Thread(() => + { + try + { + streamer.Dispose(); + } + catch (Exception error) + { + secondError = error; + } + }) + { + IsBackground = true, + Name = "LandblockStreamer concurrent dispose contract", + }; + secondDisposeThread.Start(); + Assert.True(SpinWait.SpinUntil( + () => (secondDisposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(2)), + "the concurrent dispose never waited for primary disposal"); release.Set(); - await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(firstDisposeThread.Join(TimeSpan.FromSeconds(2))); + Assert.True(secondDisposeThread.Join(TimeSpan.FromSeconds(2))); + Assert.Null(firstError); + Assert.Null(secondError); } finally {