From 79a4489e036087529d34e265756178e460e705b2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 13:12:26 +0200 Subject: [PATCH] test: replace final fixed-delay oracles --- .../2026-08-18-r3-test-truth-ledger.md | 46 +++++++++++++++++-- .../BakePublicationGuardContract.cs | 4 +- .../Installation/InstallerTransactionLease.cs | 4 +- .../Installation/LauncherInstaller.cs | 14 ++++-- .../HeadlessPluginSessionTests.cs | 38 +++++++++++---- .../Installation/LauncherInstallerTests.cs | 16 +++++-- 6 files changed, 101 insertions(+), 21 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 4c75a90e..191e3263 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 / 17 | -| Cancellable-infinite-only / elapsed-time methods | 5 / 27 | +| Methods directly using `Thread.Sleep` / `Task.Delay` | 15 / 14 | +| Cancellable-infinite-only / elapsed-time methods | 5 / 24 | | 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; 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-019 remaining explicit waits | resolved in batches M–O | All nine fixed-delay negative oracles now use observed monitor/thread/lease/process state. The remaining 24 elapsed-time methods are the reviewed cancellation, real-time protocol, cooperative-yield, bounded-polling, and positive-timeout categories from Batch H. | | 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; 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. | +| Fixed delay used to prove another operation is still blocked | 9 originally; 0 remain | Batches M–O replace all nine with observed monitor/thread/lease/process state. | | 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: @@ -696,3 +696,41 @@ Batch N verification: Three fixed-delay negative oracles remain: headless concurrent late-subscriber replay and the two launcher installer/recovery contracts. + +## Batch O final fixed-delay negative oracles + +Batch O removes the last three timing-based absence-of-completion tests: + +- `HeadlessPluginSessionTests.LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder` + now runs registration on a dedicated thread and observes it enter + `WaitSleepJoin` while Runtime's exact replay read lease is held; +- `LauncherInstallerTests.IndependentInstallersSerializeAndWaitingCancellationTouchesNothing` + now waits until the second installer has actually encountered the held + transaction lease before cancelling it; and +- both rows of + `LauncherInstallerTests.OrphanBakeCanNeverPublishAfterRestartRecovery` now + observe publication-lock contention where applicable and wait for the exact + orphan child process to exit before checking that it cannot publish later. + +The launcher observation points are internal callbacks on the existing +transaction/publication lease retry paths. They are unset in production, add no +new public API, and do not change lock, retry, cancellation, or publication +behavior. The former 150/200 ms delays and post-exit settle delay are gone. + +Batch O verification: + +- the one focused Headless fact and three focused Launcher cases pass; +- 25 fresh-process Headless iterations and 25 fresh-process Launcher iterations + pass, totaling 100/100 exercised cases; +- the refreshed inventory reduces direct `Task.Delay` methods from 17 to 14 + and elapsed-time methods from 27 to 24; +- none of Batch H's nine fixed-delay negative-oracle methods contains a direct + sleep or delay; +- 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. + +The 24 remaining elapsed-time methods retain their reviewed Batch H +classification: cancellation fakes, intentional real-time protocol behavior, +cooperative yields, bounded integration polling, or a positive timeout guard. diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs index a3f409c6..3057d797 100644 --- a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs +++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs @@ -15,7 +15,8 @@ internal static class BakePublicationGuardContract internal static async ValueTask AcquireAsync( string outputPath, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Action? contentionObserved = null) { string lockPath = BakePublicationGuardPaths.GetPublishLockPath( outputPath); @@ -39,6 +40,7 @@ internal static class BakePublicationGuardContract } catch (IOException) { + contentionObserved?.Invoke(); await Task.Delay(RetryDelay, cancellationToken) .ConfigureAwait(false); } diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs index 1fc7ec8e..e9f13157 100644 --- a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs +++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs @@ -22,7 +22,8 @@ internal sealed class InstallerTransactionLease : IAsyncDisposable internal static async ValueTask AcquireAsync( string dataDirectory, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Action? contentionObserved = null) { string lockPath = GetLockPath(dataDirectory); Directory.CreateDirectory( @@ -46,6 +47,7 @@ internal sealed class InstallerTransactionLease : IAsyncDisposable } catch (IOException) { + contentionObserved?.Invoke(); await Task.Delay(RetryDelay, cancellationToken) .ConfigureAwait(false); } diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index f8de24a7..4c8d4d0d 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -80,6 +80,11 @@ public sealed class LauncherInstaller : ILauncherInstaller private LauncherInstallRecord? _verifiedRecord; + /// Test-only observation points for cross-process lease + /// contention. Production leaves both callbacks unset. + internal Action? TransactionLeaseContentionObservedForTest { get; set; } + internal Action? PublicationLeaseContentionObservedForTest { get; set; } + public LauncherInstaller( ApplicationPathSet paths, string bakeExecutablePath, @@ -118,7 +123,8 @@ public sealed class LauncherInstaller : ILauncherInstaller await using InstallerTransactionLease lease = await InstallerTransactionLease.AcquireAsync( _recordStore.DataDirectory, - cancellationToken) + cancellationToken, + TransactionLeaseContentionObservedForTest) .ConfigureAwait(false); InstallRecordVerification verification = await RecoverExistingUnderPublicationGuardAsync( @@ -152,7 +158,8 @@ public sealed class LauncherInstaller : ILauncherInstaller await using InstallerTransactionLease lease = await InstallerTransactionLease.AcquireAsync( _recordStore.DataDirectory, - cancellationToken) + cancellationToken, + TransactionLeaseContentionObservedForTest) .ConfigureAwait(false); return await InstallCoreAsync( datDirectory, @@ -440,7 +447,8 @@ public sealed class LauncherInstaller : ILauncherInstaller await using BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( outputPath, - cancellationToken) + cancellationToken, + PublicationLeaseContentionObservedForTest) .ConfigureAwait(false); // Any child whose parent died before it acquired this lock is now // irrevocably stale. A child already holding the lock must finish its diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index 44ace1c9..8e63eec3 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -212,24 +212,44 @@ public sealed class HeadlessPluginSessionTests Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); using var registrationStarted = new ManualResetEventSlim(); - Task registration = Task.Run(() => + Exception? registrationError = null; + var registrationThread = new Thread(() => { registrationStarted.Set(); - _ = session.Runtime.EntityObjects.RegisterEntity( - Spawn(0x50000002u, 2f)); - }); - Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10))); + try + { + _ = session.Runtime.EntityObjects.RegisterEntity( + Spawn(0x50000002u, 2f)); + } + catch (Exception error) + { + registrationError = error; + } + }) + { + IsBackground = true, + Name = "Headless plugin concurrent registration contract", + }; + registrationThread.Start(); + bool registrationStartedInTime = registrationStarted.Wait( + TimeSpan.FromSeconds(10)); + bool registrationBlockedOnReplay = registrationStartedInTime && + SpinWait.SpinUntil( + () => (registrationThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(10)); try { - await Task.Delay(TimeSpan.FromMilliseconds(100)); - Assert.False(registration.IsCompleted); + Assert.True( + registrationBlockedOnReplay, + "registration never waited for the active replay read lease"); } finally { releaseReplay.Set(); } - await Task.WhenAll(subscribe, registration) - .WaitAsync(TimeSpan.FromSeconds(10)); + await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(registrationThread.Join(TimeSpan.FromSeconds(10))); + Assert.Null(registrationError); host.Events.EntitySpawned -= handler; Assert.Equal([1_000_000u, 1_000_001u], observed); diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 4e48b069..2a88e20b 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -340,6 +340,10 @@ public sealed class LauncherInstallerTests : IDisposable _bakeExecutable, recordStore: new LauncherInstallRecordStore(_paths), processRunner: runnerB); + var leaseContended = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + installerB.TransactionLeaseContentionObservedForTest = + () => leaseContended.TrySetResult(); Task operationA = installerA.InstallAsync(_dats, 1); @@ -351,7 +355,8 @@ public sealed class LauncherInstallerTests : IDisposable _dats, 1, cancellationToken: cancellationB.Token); - await Task.Delay(150); + await leaseContended.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(operationB.IsCompleted); cancellationB.Cancel(); await Assert.ThrowsAnyAsync(() => operationB); @@ -537,6 +542,7 @@ public sealed class LauncherInstallerTests : IDisposable orphanPid = int.Parse( await File.ReadAllTextAsync(childPid), System.Globalization.CultureInfo.InvariantCulture); + using Process orphan = Process.GetProcessById(orphanPid); Assert.True(File.Exists( LauncherInstallRecordStore.GetBackupPath( store.PreparedAssetPath))); @@ -551,12 +557,16 @@ public sealed class LauncherInstallerTests : IDisposable _paths, _bakeExecutable, recordStore: new LauncherInstallRecordStore(_paths)); + var publicationContended = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + restarted.PublicationLeaseContentionObservedForTest = + () => publicationContended.TrySetResult(); Task recovery = restarted.LoadExistingAsync(); InstallRecordVerification recovered; if (schedule == "holds") { - await Task.Delay(200); + await publicationContended.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(recovery.IsCompleted); File.WriteAllText(release, "release"); recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15)); @@ -593,7 +603,7 @@ public sealed class LauncherInstallerTests : IDisposable await File.ReadAllTextAsync(childExit + ".error"), StringComparison.OrdinalIgnoreCase); } - await Task.Delay(200); + await orphan.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(15)); Assert.Equal( canonicalAfterRecovery,