From 08b014b28d5507f3dd73a9e3b9c71a1bc9482a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 21:30:01 +0000 Subject: [PATCH 1/6] CI: run build and tests on windows-latest as well as ubuntu Windows is the primary platform, but CI only tested on ubuntu, so the Windows branches of PluginPathRules, PluginContentHash, SettingsStore and SapiVoice never ran in CI. Add a windows-latest matrix leg with fail-fast off so each OS reports independently. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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: From 80d097a061f59407217b1b58507e1237b7e55e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 21:33:30 +0000 Subject: [PATCH 2/6] Plugin hash test: don't create a.txt and A.txt in the same folder On a case-insensitive file system (the new windows-latest CI leg) the two names are one file, so the two trees ended up with different content (a.txt=5 vs A.txt=1) and FolderHash_IsDeterministic_AndIndependentOfCreationOrder failed. Use C.txt, which still sorts ahead of the lower-case names ordinally but never collides. PluginContentHash itself is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../PluginContentBindingTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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)); From 811f682d90c8dad3c688f73bfdb98488f64312dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 21:38:14 +0000 Subject: [PATCH 3/6] Detach join test: probe on dedicated threads, not a pool-timer timeout ASecondDetach_Joins_AndReturnsOnceTheFirstHasFinished failed on the new windows-latest leg after exactly 5 s with "no exception was thrown". Both detaches block pool threads, and with the pool starved the WaitAsync(200 ms) timeout callback could not run until the host's 5 s shutdown bound let the first detach finish; by then the second had completed, so the 'not done yet' assertion saw a finished task. Run both detaches on dedicated threads (LongRunning) and probe with SpinWait.SpinUntil, which needs no pool thread. Product code unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/EDNexus.App.Tests/PluginServiceLifecycleTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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(); From a3bc90ba1aee5831432300b3449a55692916544b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 21:50:42 +0000 Subject: [PATCH 4/6] Reattach exit test: wait for the new plugin's Shutdown instead of assuming it Attach_WhileAnOlderTeardownIsStillRunning_DoesNotWaitForIt_AndTheNewRuntimeIsStillTornDownOnExit failed on windows-latest at Assert.True(fresh.Returned.IsSet). ShutdownForExit(timeout) bounds the whole teardown, including the current runtime's, so with the 150 ms bound a slow runner can return before the fresh plugin's Shutdown has run. Exit staying strictly bounded is intended; the test now waits (up to the 30 s ceiling) for Shutdown to run. App code unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/EDNexus.App.Tests/PluginServiceReattachTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs b/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs index 5e91fb4..3a14eb0 100644 --- a/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs +++ b/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs @@ -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 From a5cb0e3e6afe9be00e41b4825b24ca51b8166aea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 21:54:15 +0000 Subject: [PATCH 5/6] Bounded-detach tests: wait for the runtime to be detached instead of assuming it ShutdownForExit_WhenAHandlerNeverReturns_GivesUpAtTheBound_AndReturnsFalse failed on windows-latest at Assert.Null(service.Runtime): the bounded call ran its teardown on a worker and returned after 150 ms before that worker had even cleared the runtime. Same treatment as the reattach exit test: wait (up to the ceiling) for the runtime to be cleared. Applied to the two other tests that assert Runtime is null straight after a 150 ms bounded Detach. App code unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/EDNexus.App.Tests/PluginServiceExitTests.cs | 4 ++-- tests/EDNexus.App.Tests/PluginServiceReattachTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/PluginServiceReattachTests.cs b/tests/EDNexus.App.Tests/PluginServiceReattachTests.cs index 3a14eb0..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); From 139d4d89a57b60828faf04de4ed3ff2f2737034b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:54:32 +0000 Subject: [PATCH 6/6] Closed-game clear test: stop the service before exercising the queue's retry A_clear_for_a_closed_game_that_fails_is_queued_and_dropped_once_acknowledged failed on windows-latest: RetryPendingAsync returned 1 instead of 0. After the first clear fails, the service's pump retries it every 2x the interval (up to MaxPublishRetries). Each retry enqueues the entry again before sending, so when the test's manual RetryPendingAsync removes it, a concurrent pump retry can re-add it and the count catches it while that clear is still in flight. On a slower runner the pump is still retrying when the test gets there. The test is about the queue's own retry (as after an app restart), so dispose the service first. Product code unchanged: the transient entry during a direct clear is by design and settles on its own. Co-Authored-By: Claude Opus 5.5 --- tests/EDNexus.Tests/Twitch/TwitchStreamCardServiceTests.cs | 4 ++++ 1 file changed, 4 insertions(+) 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);