From 9a504bdc25cbc730c2af89e557a7e2b6b5384c07 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 3 Jul 2026 19:32:37 +0000 Subject: [PATCH] fix(tests): gate two Windows-flaky actor tests on real readiness signals Both tests failed only on the Windows CI runner (Ubuntu + macOS green) with timeouts, not assertion-logic failures. Root cause in both: a fixed short timeout racing a persistent-actor cold start under ThreadPool starvation. - DiscordSessionBindingContractTests.Approval_response_sends_feedback: the default 3s AwaitAssert poll raced the binding actor's full cold start (recovery -> init -> hydrate -> active -> unstash -> render). Under CPU starvation the poll loop got only ~2 attempts before the deadline. Gate on `await pipeline.Created.WaitAsync(ct)` first -- a linear await on the real readiness signal, matching the Reminder_delivery_* / Stashes_messages_during_init siblings -- so the 3s poll only covers the fast in-process output tail. - SessionMemoryObserverActorTests.DistillMemories_only_persists_accepted_proposals_for_future_dedup: the 5s ExpectMsg budget was consumed by first-persistent-actor journal cold start + recovery (commands stash until RecoveryCompleted). Gate on an empty RecordAcceptedDistillationProposals Ask -- answered immediately post-recovery with no Persist and no state change, a side-effect-free readiness ack -- so the behavioral windows are measured from a warm actor. No production change: stash-until-RecoveryCompleted is the correct Akka synchronization; this is a test cold-start-budget artifact. No Thread.Sleep / Task.Delay introduced -- both fixes block on real signals. --- .../Contracts/SessionBindingContractTests.cs | 8 ++++++++ .../Sessions/SessionMemoryObserverActorTests.cs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index 5698d0657..a4b3f5e46 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -477,6 +477,14 @@ public async Task Approval_response_sends_feedback() var actor = CreateBindingActor(sid, pipeline, detector); + // Gate on pipeline creation (persistent-actor recovery + init round-trip) + // before polling for rendered output. Under CI CPU starvation the cold + // start alone can exceed the default 3s AwaitAssert budget — the poll loop + // observed only ~2 attempts before the deadline on the Windows runner — so + // a linear await on the real readiness signal removes the race. Matches the + // Reminder_delivery_* and Stashes_messages_during_init siblings. + await pipeline.Created.WaitAsync(ct); + // Wait for approval to be rendered await AwaitAssertAsync(() => { diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionMemoryObserverActorTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionMemoryObserverActorTests.cs index 83a42f847..fd7e848dd 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionMemoryObserverActorTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionMemoryObserverActorTests.cs @@ -459,6 +459,20 @@ public async Task DistillMemories_only_persists_accepted_proposals_for_future_de var observer = CreateObserver("accepted-only", client: firstClient); var replyProbe = CreateTestProbe("accepted-only-probe"); + // Gate on recovery before timing the distillation round-trip below. This is + // an early persistent actor, so its journal/snapshot plugin cold start + + // recovery otherwise races the fixed 5s ExpectMsg (observed on the Windows + // runner: "session_observer_recovery_complete" logged right at the deadline). + // Persistent actors stash commands until RecoveryCompleted; an empty + // RecordAcceptedDistillationProposals is answered immediately post-recovery + // with no Persist and no state change, so awaiting it is a side-effect-free + // readiness ack. The generous ceiling absorbs cold start without polling; the + // behavioral 5s windows are then measured from a warm actor. + await observer.Ask( + new RecordAcceptedDistillationProposals([]), + TimeSpan.FromSeconds(30), + TestContext.Current.CancellationToken); + observer.Tell(new SendUserMessage { SessionId = new SessionId("test-channel/accepted-only"),