From fa4bdfe89f0d6e14b0bafcbd9e4753b3bc87814d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 12:56:25 +0200 Subject: [PATCH] test: observe monitor waits without delays --- .../2026-08-18-r3-test-truth-ledger.md | 45 +++++++++++++++-- .../Net/LiveSessionCommandRouterTests.cs | 35 +++++++++---- .../Rendering/HostQuiescenceGateTests.cs | 32 +++++++++--- .../SilkWindowCallbackBindingTests.cs | 50 +++++++++++++------ 4 files changed, 127 insertions(+), 35 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 843f71ae..615ed4de 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 / 23 | -| Cancellable-infinite-only / elapsed-time methods | 5 / 33 | +| Methods directly using `Thread.Sleep` / `Task.Delay` | 15 / 19 | +| Cancellable-infinite-only / elapsed-time methods | 5 / 29 | | 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; nine fixed-delay negative oracles need cleanup | Five of the 38 methods are deterministic cancellation stubs, not wall-clock tests. Preserve four intentional real-time contracts and bounded integration polling; replace the nine tests that infer “still blocked” from a fixed delay with observable synchronization state. | +| 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-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,10 +448,10 @@ 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 | Cleanup priority. Replace elapsed-time absence-of-completion with an observed wait state or test seam. | +| 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. | | 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 nine fixed-delay negative oracles are: +The original nine fixed-delay negative oracles are: 1. `LiveSessionCommandRouterTests.ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert`; 2. `HostQuiescenceGateTests.ExternalStopWaitsForAdmittedCallbackToReturn`; @@ -630,3 +630,38 @@ Batch L verification: The five-case reduction from Batch K is exact: five formerly hermetic diagnostic facts are now excluded by `Purpose=Diagnostic`; the sixth was already outside the portable lane because its class requires installed DATs. + +## Batch M observable monitor-wait contracts + +Four concurrency tests used a 50–100 ms delay and then asserted that disposal +had not completed. That was only indirect evidence: a delayed or saturated +ThreadPool could satisfy the assertion before the disposal path had attempted +to enter its production monitor. + +Batch M runs each competing disposal/stop operation on a named dedicated +thread, waits with a bounded `SpinWait` until the runtime reports +`ThreadState.WaitSleepJoin`, and then verifies the thread joins after the +controlled callback, transport send, or physical detach is released. This +changes no production code and preserves every existing completion, inertness, +and propagated-failure assertion. The converted contracts are: + +- `HostQuiescenceGateTests.ExternalStopWaitsForAdmittedCallbackToReturn`; +- `LiveSessionCommandRouterTests.ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert`; +- `SilkWindowCallbackBindingTests.ConcurrentDisposeWaitsForPhysicalDetachToComplete`; + and +- `SilkWindowCallbackBindingTests.ConcurrentDisposeCannotHidePhysicalDetachFailure`. + +Batch M verification: + +- the four focused contracts pass; +- 25 fresh-process focused iterations pass, for 100/100 observed-wait cases; +- the refreshed inventory reduces direct `Task.Delay` methods from 23 to 19 + and elapsed-time methods from 33 to 29; +- 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. + +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. diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 054c6e96..7ffde711 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -427,21 +427,38 @@ public sealed class LiveSessionCommandRouterTests router.Publish(new SendServerCommandCmd("@active"))); Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5))); using var disposeStarted = new ManualResetEventSlim(); - Task dispose = Task.Run(() => + Exception? disposeError = null; + var disposeThread = new Thread(() => { disposeStarted.Set(); - router.Dispose(); - }); - Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); - Task firstCompletion = await Task.WhenAny( - dispose, - Task.Delay(TimeSpan.FromMilliseconds(100))); - Assert.NotSame(dispose, firstCompletion); + try + { + router.Dispose(); + } + catch (Exception error) + { + disposeError = error; + } + }) + { + IsBackground = true, + Name = "LiveSessionCommandRouter concurrent dispose contract", + }; + disposeThread.Start(); + bool disposeStartedInTime = disposeStarted.Wait(TimeSpan.FromSeconds(5)); + bool disposeBlockedOnTransport = disposeStartedInTime && SpinWait.SpinUntil( + () => (disposeThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); releaseSend.Set(); - await Task.WhenAll(publish, dispose); + await publish; + bool disposeJoined = disposeThread.Join(TimeSpan.FromSeconds(5)); router.Publish(new SendServerCommandCmd("@after")); + Assert.True(disposeStartedInTime, "the dedicated dispose thread did not start"); + Assert.True(disposeBlockedOnTransport, "dispose never waited for the in-flight send"); + Assert.True(disposeJoined, "dispose did not finish after the send returned"); + Assert.Null(disposeError); Assert.Equal(["@active"], sent); Assert.False(router.IsActive); } diff --git a/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs index 04602aa3..0f262d71 100644 --- a/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs +++ b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs @@ -20,20 +20,38 @@ public sealed class HostQuiescenceGateTests Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5))); using var stopStarted = new ManualResetEventSlim(false); - Task stop = Task.Run(() => + Exception? stopError = null; + var stopThread = new Thread(() => { stopStarted.Set(); - gate.StopAccepting(); - }); - Assert.True(stopStarted.Wait(TimeSpan.FromSeconds(5))); - await Task.Delay(50); - Assert.False(stop.IsCompleted); + try + { + gate.StopAccepting(); + } + catch (Exception error) + { + stopError = error; + } + }) + { + IsBackground = true, + Name = "HostQuiescenceGate external stop contract", + }; + stopThread.Start(); + bool stopStartedInTime = stopStarted.Wait(TimeSpan.FromSeconds(5)); + bool stopBlockedOnCallback = stopStartedInTime && SpinWait.SpinUntil( + () => (stopThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); releaseCallback.Set(); await callback; - await stop; + bool stopJoined = stopThread.Join(TimeSpan.FromSeconds(5)); gate.Invoke(() => calls++); + Assert.True(stopStartedInTime, "the dedicated stop thread did not start"); + Assert.True(stopBlockedOnCallback, "stop never waited for the admitted callback"); + Assert.True(stopJoined, "stop did not finish after the callback returned"); + Assert.Null(stopError); Assert.Equal(1, calls); Assert.False(gate.IsAccepting); } diff --git a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs index 0be84c18..982e3e11 100644 --- a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs @@ -270,19 +270,30 @@ public sealed class SilkWindowCallbackBindingTests Task first = Task.Run(() => Record.Exception(binding.Dispose)); Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); using var secondEntered = new ManualResetEventSlim(false); - Task second = Task.Run(() => + Exception? secondError = null; + var secondThread = new Thread(() => { secondEntered.Set(); - return Record.Exception(binding.Dispose); - }); - Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); - await Task.Delay(50); - Assert.False(second.IsCompleted); + secondError = Record.Exception(binding.Dispose); + }) + { + IsBackground = true, + Name = "Silk callback binding concurrent dispose contract", + }; + secondThread.Start(); + bool secondStartedInTime = secondEntered.Wait(TimeSpan.FromSeconds(5)); + bool secondBlockedOnDetach = secondStartedInTime && SpinWait.SpinUntil( + () => (secondThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); surface.ContinueRemove.Set(); Assert.Null(await first); - Assert.Null(await second); + bool secondJoined = secondThread.Join(TimeSpan.FromSeconds(5)); + Assert.True(secondStartedInTime, "the second dispose thread did not start"); + Assert.True(secondBlockedOnDetach, "the second dispose never waited for physical detach"); + Assert.True(secondJoined, "the second dispose did not finish after detach completed"); + Assert.Null(secondError); Assert.True(binding.IsDetached); } @@ -306,19 +317,30 @@ public sealed class SilkWindowCallbackBindingTests Task first = Task.Run(() => Record.Exception(binding.Dispose)); Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); using var secondEntered = new ManualResetEventSlim(false); - Task second = Task.Run(() => + Exception? secondError = null; + var secondThread = new Thread(() => { secondEntered.Set(); - return Record.Exception(binding.Dispose); - }); - Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); - await Task.Delay(50); - Assert.False(second.IsCompleted); + secondError = Record.Exception(binding.Dispose); + }) + { + IsBackground = true, + Name = "Silk callback binding detach failure contract", + }; + secondThread.Start(); + bool secondStartedInTime = secondEntered.Wait(TimeSpan.FromSeconds(5)); + bool secondBlockedOnDetach = secondStartedInTime && SpinWait.SpinUntil( + () => (secondThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); surface.ContinueRemove.Set(); Assert.IsType(await first); - Assert.IsType(await second); + bool secondJoined = secondThread.Join(TimeSpan.FromSeconds(5)); + Assert.True(secondStartedInTime, "the second dispose thread did not start"); + Assert.True(secondBlockedOnDetach, "the second dispose never waited for physical detach"); + Assert.True(secondJoined, "the second dispose did not finish after detach failed"); + Assert.IsType(secondError); Assert.False(binding.IsDetached); surface.PersistentRemoveFailures.Clear();