diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3966bc5..1d0168f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,12 @@ permissions: jobs: build-test: - runs-on: ubuntu-latest + strategy: + # Let both legs finish so a Windows-only failure doesn't hide the Linux result (or the reverse). + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/tests/EDNexus.App.Tests/PluginServiceExitTests.cs b/tests/EDNexus.App.Tests/PluginServiceExitTests.cs index 3b6c809..3951f96 100644 --- a/tests/EDNexus.App.Tests/PluginServiceExitTests.cs +++ b/tests/EDNexus.App.Tests/PluginServiceExitTests.cs @@ -148,7 +148,7 @@ public async Task ShutdownForExit_WhenAHandlerNeverReturns_GivesUpAtTheBound_And Assert.False(finishedInTime); Assert.False(latches.HandlerReturned.IsSet); - Assert.Null(service.Runtime); + Assert.True(SpinWait.SpinUntil(() => service.Runtime is null, Ceiling)); Assert.Empty(_errors); latches.HandlerGate.Set(); @@ -166,7 +166,7 @@ public async Task Detach_WithABound_WhenAHandlerNeverReturns_ReturnsFalse_AndThe Assert.False(Bounded(() => service.Detach(ShortBound), "Detach(timeout)")); - Assert.Null(service.Runtime); + Assert.True(SpinWait.SpinUntil(() => service.Runtime is null, Ceiling)); latches.HandlerGate.Set(); Assert.True(latches.HandlerReturned.Wait(Ceiling)); } diff --git a/tests/EDNexus.App.Tests/PluginServiceLifecycleTests.cs b/tests/EDNexus.App.Tests/PluginServiceLifecycleTests.cs index 3122b25..65f9ffe 100644 --- a/tests/EDNexus.App.Tests/PluginServiceLifecycleTests.cs +++ b/tests/EDNexus.App.Tests/PluginServiceLifecycleTests.cs @@ -159,11 +159,14 @@ public async Task ASecondDetach_Joins_AndReturnsOnceTheFirstHasFinished() service.Attach(bus, state); await service.StartAsync(); - var first = Task.Run(service.Detach); + // Dedicated threads and a blocking probe: both detaches block, and with the pool starved (seen on + // the Windows runner) a WaitAsync timeout can't fire until the host's 5 s shutdown bound has let + // the first one finish, so the "not done yet" check would pass or fail on scheduling, not on the join. + var first = Task.Factory.StartNew(() => service.Detach(), TaskCreationOptions.LongRunning); Assert.True(latches.Entered.Wait(Ceiling)); - var second = Task.Run(() => service.Detach(Ceiling)); + var second = Task.Factory.StartNew(() => service.Detach(Ceiling), TaskCreationOptions.LongRunning); // Joined to the first, which is parked: it cannot be done yet. - await Assert.ThrowsAsync(() => second.WaitAsync(TimeSpan.FromMilliseconds(200))); + Assert.False(SpinWait.SpinUntil(() => second.IsCompleted, TimeSpan.FromMilliseconds(200))); latches.Gate.Set(); diff --git a/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs b/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs index 5e91fb4..0724a6b 100644 --- a/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs +++ b/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs @@ -125,7 +125,7 @@ public void Shutdown() { {{latches.Set("returned")}} } Assert.False(Bounded(() => service.Detach(ShortBound), "Detach(timeout)")); - Assert.Null(service.Runtime); // detached, but its teardown is still waiting for the load pass + Assert.True(SpinWait.SpinUntil(() => service.Runtime is null, Ceiling)); // detached, but its teardown is still waiting for the load pass Assert.False(stuck.Gate.IsSet); service.Enablement.SetDisabled(stuckId, true); return (service, stuck); @@ -154,7 +154,9 @@ public async Task Attach_WhileAnOlderTeardownIsStillRunning_DoesNotWaitForIt_And var finishedInTime = Bounded(() => service.ShutdownForExit(ShortBound), "ShutdownForExit"); Assert.False(finishedInTime); // the older teardown is still running - Assert.True(fresh.Returned.IsSet); // but the new plugin's Shutdown ran: it is not left running at exit + // But the new plugin's Shutdown runs: it is not left running at exit. ShutdownForExit bounds the + // whole teardown, so on a slow machine it can return first; wait for it rather than assume it. + Assert.True(fresh.Returned.Wait(Ceiling)); Assert.Null(service.Runtime); var (bus3, state3) = Engine(); Assert.False(service.Attach(bus3, state3)); // closed for good diff --git a/tests/EDNexus.Plugins.Hosting.Tests/PluginContentBindingTests.cs b/tests/EDNexus.Plugins.Hosting.Tests/PluginContentBindingTests.cs index 138e163..f095823 100644 --- a/tests/EDNexus.Plugins.Hosting.Tests/PluginContentBindingTests.cs +++ b/tests/EDNexus.Plugins.Hosting.Tests/PluginContentBindingTests.cs @@ -318,8 +318,10 @@ private static string Hash(string dir, string? entry = null) [Fact] public void FolderHash_IsDeterministic_AndIndependentOfCreationOrder() { - var a = Tree("order-a", ("b.txt", "2"), ("a.txt", "1"), ("sub/z.txt", "3"), ("sub/y.txt", "4"), ("A.txt", "5")); - var b = Tree("order-b", ("A.txt", "5"), ("sub/y.txt", "4"), ("sub/z.txt", "3"), ("a.txt", "1"), ("b.txt", "2")); + // "C.txt" (not "A.txt"): an upper-case name that ordinal order puts first, without colliding + // with "a.txt" on a case-insensitive file system (Windows, macOS), where the two are one file. + var a = Tree("order-a", ("b.txt", "2"), ("a.txt", "1"), ("sub/z.txt", "3"), ("sub/y.txt", "4"), ("C.txt", "5")); + var b = Tree("order-b", ("C.txt", "5"), ("sub/y.txt", "4"), ("sub/z.txt", "3"), ("a.txt", "1"), ("b.txt", "2")); Assert.Equal(Hash(a), Hash(a)); Assert.Equal(Hash(a), Hash(b)); diff --git a/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs b/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs index 33825b0..58e9d2d 100644 --- a/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs +++ b/tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs @@ -772,6 +772,10 @@ public async Task A_clear_for_a_closed_game_that_fails_is_queued_and_dropped_onc Assert.Equal(EbsCleanupKind.ClearCard, pending.Kind); Assert.Equal("ebs-token", pending.Token); + // Stop the service first: its pump keeps retrying the failed clear (up to MaxPublishRetries), and + // each retry re-enqueues the entry while its own clear is in flight, so a count taken during one + // would see it. What is under test here is the queue's retry alone, as after an app restart. + service.Dispose(); client.RespondToClear = () => StreamStatePublishResult.ClearedOk; Assert.Equal(0, await env.Queue.RetryPendingAsync()); Assert.Empty(env.Store.Load().Twitch.PendingCleanups);