From 90de38910df92cddeed46004e8dc8ee2e0a9796d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 10:00:44 -0500 Subject: [PATCH] test(galaxy): shutdown stream-cancel signal from the iterator's exit, not a token registration (#856) Found while load-verifying #856: ShutdownAsync_cancels_the_event_stream_and_releases_the_pump timed out 7/700 under parallel load. The fake's StreamCancelled was set by a CancellationToken registration scoped by `using` inside the async iterator. On Cancel() the newer ReadAllAsync registration runs first and resumes the iterator on a pool thread, whose `using` can dispose our registration while Cancel() is still walking the callback list - so it never runs. Signal from the iterator's finally (when the token is cancelled) instead, which also proves the stream actually ended. 0/700 after. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- .../Runtime/GalaxyDriverShutdownTests.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverShutdownTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverShutdownTests.cs index 5c2b9b94f..6c2b45481 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverShutdownTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverShutdownTests.cs @@ -68,10 +68,22 @@ public sealed class GalaxyDriverShutdownTests public async IAsyncEnumerable StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { Interlocked.Increment(ref StreamsOpened); - using var _ = cancellationToken.Register(() => StreamCancelled.TrySetResult()); - await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + // Signal from the iterator's own exit, not from a token registration: a registration made + // here is disposed by the `using` when the cancelled ReadAllAsync resumes the body on a + // pool thread — which can happen while Cancel() on the pump's thread is still walking its + // callback list, unregistering ours before it runs (CTS runs callbacks newest-first, and + // ReadAllAsync's is newer). Observed ~1% under parallel load as a 10 s timeout. A finally + // also proves the stronger fact: the stream actually ended because it was cancelled. + try { - yield return ev; + await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return ev; + } + } + finally + { + if (cancellationToken.IsCancellationRequested) StreamCancelled.TrySetResult(); } }