From f3bb3e8a29e28770eeee308e5d61664a8d6c74a0 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:25:33 +0200 Subject: [PATCH 1/4] feat(rawcan): add callback-style Subscribe(onNext, predicate) convenience API A colleague asked for a way to subscribe to incoming CAN frames with a callback instead of driving the ISubscription.Frames async-enumerable manually. Rather than a bigger cross-protocol unification (the protocol layers already have their own event-based APIs -- J1939's MessageReceived, ISO-TP's DatagramReceived, CANopen's PDO/SDO events -- and unifying those under one generic signature would either duplicate them for one line of savings or flatten their typed payloads to a lowest common denominator), this adds a small, additive-only extension method scoped to the raw-CAN layer, where the underlying complexity (per-subscription bounded buffering, backpressure) is real enough to be worth hiding. CanBusServiceExtensions.Subscribe(onNext, predicate, bufferCapacity) is built entirely on the existing ISubscription pull API, so it inherits the same FR-RAW-011 guarantee for free: a slow onNext only falls behind and drops its own subscription's oldest frames -- it can never delay delivery to other subscriptions or to the bus's own FrameObserved event, because the dispatch hot path never waits on a subscriber's consumer. A throwing onNext is isolated per frame and routed through the existing BackgroundExceptionOccurred fault channel via a new small internal CanBusService.RaiseBackgroundException helper (also used to de-duplicate the identical inline try/catch OnFrameObserved already had). Four new tests cover: filtering, the slow-handler-does-not-block invariant (mirroring the existing raw-subscription FR-RAW-011 test), Dispose stopping delivery, and handler-exception routing + delivery continuing. Public API surface changed (new type in CanKit.Pro.RawCan) -- approval baseline updated in the same commit. Verified: full build + 414/414 tests green on net10.0. --- src/CanKit.Pro.RawCan/CanBusService.cs | 16 ++- .../CanBusServiceExtensions.cs | 104 +++++++++++++++ .../CanKit.Pro.RawCan.approved.txt | 2 + .../TestCases/RawCanSubscriptionTests.cs | 123 ++++++++++++++++++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs diff --git a/src/CanKit.Pro.RawCan/CanBusService.cs b/src/CanKit.Pro.RawCan/CanBusService.cs index 0881b27..06660bd 100644 --- a/src/CanKit.Pro.RawCan/CanBusService.cs +++ b/src/CanKit.Pro.RawCan/CanBusService.cs @@ -179,8 +179,7 @@ private void OnFrameObserved(object? sender, CanReceiveDataView e) } catch (Exception ex) { - try { BackgroundExceptionOccurred?.Invoke(this, ex); } - catch { /* a fault listener must not break dispatch either */ } + RaiseBackgroundException(ex); } } } @@ -188,6 +187,19 @@ private void OnFrameObserved(object? sender, CanReceiveDataView e) /// public event EventHandler? BackgroundExceptionOccurred; + /// + /// Routes through , + /// isolating a misbehaving listener from the caller. Internal (not part of + /// ) because events can only be raised from their declaring + /// type; exposed so can report a failing + /// callback handler through this same fault channel instead of a second, parallel one. + /// + internal void RaiseBackgroundException(Exception ex) + { + try { BackgroundExceptionOccurred?.Invoke(this, ex); } + catch { /* a fault listener must not break dispatch either */ } + } + /// public void Dispose() { diff --git a/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs new file mode 100644 index 0000000..139073b --- /dev/null +++ b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CanKit.Abstractions.API.Can.Definitions; + +namespace CanKit.Pro.RawCan +{ + /// + /// Callback-style convenience layer over + /// for callers who want "filter + handler" instead of driving the async-enumerable + /// stream themselves. + /// (对 的回调式便捷封装, + /// 面向只想要"过滤 + 处理函数"、不想自己驱动 异步流的调用方。) + /// + public static class CanBusServiceExtensions + { + /// + /// Registers a subscription and invokes for every frame it + /// accepts, on a dedicated background task. Disposing the returned handle stops delivery + /// and lets that task end. + /// (注册一路订阅,对其接收到的每一帧在专用后台任务上调用 ; + /// 释放返回的句柄会停止投递并使该任务结束。) + /// + /// + /// Built entirely on the existing pull API, so the same + /// per-subscription bounded, drop-oldest buffer applies (FR-RAW-011): a slow + /// only ever falls behind and drops its own oldest frames -- it + /// can never delay delivery to other subscriptions or to the bus's own + /// FrameObserved event, because the dispatch hot path never waits on a + /// subscriber's consumer. An exception thrown by is isolated + /// per frame -- delivery continues with the next frame -- and, when + /// is a , routed through + /// , the same fault channel every + /// other background failure in this service uses. + /// (完全基于现有的 拉取式 API 构建,因此同样适用逐订阅有界、 + /// 丢弃最旧的缓冲区(FR-RAW-011):迟缓的 只会自己落后并丢弃自己最旧的帧—— + /// 因为分发热路径从不等待订阅方的消费者,它永远不会延迟向其他订阅或总线自身 FrameObserved + /// 事件的投递。 抛出的异常按帧隔离——投递会以下一帧继续——当 + /// 时,异常会经由 + /// 上抛,与本服务其余后台故障共用同一通道。) + /// + /// The service to subscribe on. + /// Invoked for each accepted frame, in arrival order. + /// Per-frame filter, or null to accept all frames. + /// + /// Bounded buffer capacity for the underlying subscription; null uses + /// . + /// + /// Disposing this stops the subscription and the background delivery task. + public static IDisposable Subscribe( + this ICanBusService service, + Action onNext, + Func? predicate = null, + int? bufferCapacity = null) + { + if (service is null) throw new ArgumentNullException(nameof(service)); + if (onNext is null) throw new ArgumentNullException(nameof(onNext)); + + var subscription = service.Subscribe(predicate, bufferCapacity); + var pumpTask = Task.Run(async () => + { + await foreach (var frame in subscription.Frames.ConfigureAwait(false)) + { + try + { + onNext(frame); + } + catch (Exception ex) + { + if (service is CanBusService concrete) + concrete.RaiseBackgroundException(ex); + } + } + }); + + return new CallbackSubscription(subscription, pumpTask); + } + + private sealed class CallbackSubscription : IDisposable + { + private readonly ISubscription _subscription; + private readonly Task _pumpTask; + private int _disposed; + + public CallbackSubscription(ISubscription subscription, Task pumpTask) + { + _subscription = subscription; + _pumpTask = pumpTask; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; // idempotent + // Completes the channel, which ends the pump task's `await foreach` gracefully + // (no exception -- see Subscription.Dispose/ReadAsync). + _subscription.Dispose(); + // Best-effort bounded join so a caller who disposes and then immediately tears + // down surrounding state doesn't race the last in-flight onNext call; matches the + // same dispose-teardown idiom used for background readers throughout this codebase. + try { _pumpTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* best-effort */ } + } + } + } +} diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.RawCan.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.RawCan.approved.txt index 1af0208..6f0713f 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.RawCan.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.RawCan.approved.txt @@ -10,6 +10,8 @@ class CanKit.Pro.RawCan.CanBusService method System.Void Dispose() prop CanKit.Abstractions.API.Can.ICanBus Bus {get} prop System.Int32 SubscriptionCount {get} +class CanKit.Pro.RawCan.CanBusServiceExtensions + method System.IDisposable Subscribe(CanKit.Pro.RawCan.ICanBusService service, System.Action onNext, System.Func predicate, System.Nullable bufferCapacity) struct CanKit.Pro.RawCan.CanIdFilter method CanKit.Pro.RawCan.CanIdFilter Mask(System.UInt32 accCode, System.UInt32 accMask, CanKit.Abstractions.API.Common.Definitions.CanFilterIDType idType) method CanKit.Pro.RawCan.CanIdFilter Range(System.UInt32 from, System.UInt32 to, CanKit.Abstractions.API.Common.Definitions.CanFilterIDType idType) diff --git a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs index 72e9273..e2d4fa8 100644 --- a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs @@ -113,6 +113,129 @@ public async Task Slow_Subscription_Does_Not_Block_Others_Or_The_Bus_Event() Volatile.Read(ref busEventCount).Should().Be(n); } + // Callback-style Subscribe(onNext, predicate): the handler is invoked for matching frames + // only, in arrival order. + [Fact] + public async Task Callback_Subscribe_Invokes_Handler_For_Matching_Frames_Only() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + var received = new List(); + var lastReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = service.Subscribe( + frame => + { + lock (received) + { + received.Add(frame.ID); + if (received.Count >= 2) lastReceived.TrySetResult(true); + } + }, + predicate: f => f.ID == 0x123); + + sender.Transmit(CanFrame.Classic(0x123, new byte[] { 1 })); + sender.Transmit(CanFrame.Classic(0x456, new byte[] { 2 })); // filtered out + sender.Transmit(CanFrame.Classic(0x123, new byte[] { 3 })); + + await lastReceived.Task.WaitAsync(ShortTimeout); + lock (received) received.Should().Equal(0x123, 0x123); + } + + // Callback-style Subscribe must uphold the same FR-RAW-011 guarantee as the raw pull API: a + // slow/blocking onNext only ever falls behind its own subscription, never the bus event or a + // second, actively-draining subscription. + [Fact] + public async Task Callback_Subscribe_Slow_Handler_Does_Not_Block_Others_Or_The_Bus_Event() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + var busEventCount = 0; + receiver.FrameObserved += (_, _) => Interlocked.Increment(ref busEventCount); + + var block = new SemaphoreSlim(0); // never released: the slow handler blocks forever + using var slow = service.Subscribe(_ => block.Wait(), bufferCapacity: 1); + + var fastCount = 0; + var allFastReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + const int n = 200; + using var fast = service.Subscribe(_ => + { + if (Interlocked.Increment(ref fastCount) >= n) allFastReceived.TrySetResult(true); + }, bufferCapacity: 512); + + for (var i = 0; i < n; i++) + sender.Transmit(CanFrame.Classic(0x300 + (i & 0x0F), new byte[] { (byte)i })); + + await allFastReceived.Task.WaitAsync(ShortTimeout); + Volatile.Read(ref busEventCount).Should().Be(n); + } + + // Disposing the callback handle stops delivery: no further onNext calls after Dispose returns. + [Fact] + public async Task Callback_Subscribe_Dispose_Stops_Delivery() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + var count = 0; + var firstReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var subscription = service.Subscribe(_ => + { + Interlocked.Increment(ref count); + firstReceived.TrySetResult(true); + }); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 1 })); + await firstReceived.Task.WaitAsync(ShortTimeout); + + subscription.Dispose(); + var countAfterDispose = Volatile.Read(ref count); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 2 })); + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 3 })); + await Task.Delay(200); + + Volatile.Read(ref count).Should().Be(countAfterDispose); + } + + // A handler exception is isolated per frame -- delivery continues -- and surfaced via the + // service's existing fault channel, the same as a throwing predicate. + [Fact] + public async Task Callback_Subscribe_Handler_Exception_Is_Surfaced_And_Delivery_Continues() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + var observed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + service.BackgroundExceptionOccurred += (_, ex) => observed.TrySetResult(ex); + + var secondReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var subscription = service.Subscribe(_ => + { + if (Interlocked.Increment(ref calls) == 1) + throw new InvalidOperationException("boom"); + secondReceived.TrySetResult(true); + }); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 1 })); + var ex = await observed.Task.WaitAsync(ShortTimeout); + ex.Should().BeOfType().Which.Message.Should().Be("boom"); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 2 })); + await secondReceived.Task.WaitAsync(ShortTimeout); + } + // FR-RAW-012: creating and disposing N subscriptions leaves no entries in the service registry. [Fact] public void Disposing_Subscriptions_Leaves_No_Registry_Entries() From be23113719b277a7dcf5acd246097fd0c033801d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 10:35:07 +0000 Subject: [PATCH 2/4] fix(rawcan): skip pump join when disposing callback Subscribe from onNext CallbackSubscription.Dispose waited on the pump task that invokes onNext, so disposing the handle from inside the callback deadlocked until the two-second timeout. Skip that join when Dispose runs on the pump, matching ProtocolActor. --- .../CanBusServiceExtensions.cs | 44 ++++++++++--------- .../TestCases/RawCanSubscriptionTests.cs | 23 ++++++++++ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs index 139073b..1474e93 100644 --- a/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs +++ b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs @@ -56,36 +56,39 @@ public static IDisposable Subscribe( if (service is null) throw new ArgumentNullException(nameof(service)); if (onNext is null) throw new ArgumentNullException(nameof(onNext)); - var subscription = service.Subscribe(predicate, bufferCapacity); - var pumpTask = Task.Run(async () => - { - await foreach (var frame in subscription.Frames.ConfigureAwait(false)) - { - try - { - onNext(frame); - } - catch (Exception ex) - { - if (service is CanBusService concrete) - concrete.RaiseBackgroundException(ex); - } - } - }); - - return new CallbackSubscription(subscription, pumpTask); + return new CallbackSubscription(service.Subscribe(predicate, bufferCapacity), service, onNext); } private sealed class CallbackSubscription : IDisposable { private readonly ISubscription _subscription; private readonly Task _pumpTask; + private readonly AsyncLocal _isOnPump = new(); private int _disposed; - public CallbackSubscription(ISubscription subscription, Task pumpTask) + public CallbackSubscription(ISubscription subscription, ICanBusService service, Action onNext) { _subscription = subscription; - _pumpTask = pumpTask; + _pumpTask = Task.Run(async () => + { + // Marks the entire pump (including onNext) so Dispose can skip the join + // below when invoked from the callback itself -- waiting on this task from + // inside it would deadlock until the timeout. Same reentrancy guard as + // ProtocolActor.Dispose / _isOnLoop. + _isOnPump.Value = true; + await foreach (var frame in _subscription.Frames.ConfigureAwait(false)) + { + try + { + onNext(frame); + } + catch (Exception ex) + { + if (service is CanBusService concrete) + concrete.RaiseBackgroundException(ex); + } + } + }); } public void Dispose() @@ -94,6 +97,7 @@ public void Dispose() // Completes the channel, which ends the pump task's `await foreach` gracefully // (no exception -- see Subscription.Dispose/ReadAsync). _subscription.Dispose(); + if (_isOnPump.Value) return; // Best-effort bounded join so a caller who disposes and then immediately tears // down surrounding state doesn't race the last in-flight onNext call; matches the // same dispose-teardown idiom used for background readers throughout this codebase. diff --git a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs index e2d4fa8..89925bb 100644 --- a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs @@ -206,6 +206,29 @@ public async Task Callback_Subscribe_Dispose_Stops_Delivery() Volatile.Read(ref count).Should().Be(countAfterDispose); } + // Disposing the handle from inside onNext must not join the pump that is currently + // invoking that callback: a self-wait can only finish via the two-second timeout. + [Fact] + public async Task Callback_Subscribe_Dispose_From_Inside_Handler_Does_Not_Hang() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + IDisposable? subscription = null; + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + subscription = service.Subscribe(_ => + { + subscription!.Dispose(); + disposed.TrySetResult(true); + }); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 1 })); + // The pump join timeout is 2s; completing well under that proves the self-wait was skipped. + await disposed.Task.WaitAsync(TimeSpan.FromMilliseconds(500)); + } + // A handler exception is isolated per frame -- delivery continues -- and surfaced via the // service's existing fault channel, the same as a throwing predicate. [Fact] From e44f3cccafc69ae37848c6c9de449a96640d8ad1 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:39:30 +0200 Subject: [PATCH 3/4] test(rawcan): dispose the SemaphoreSlim in the slow-handler test CodeQL flagged it (cs/local-not-disposed) on the PR. Genuine gap, not a by-design pattern like the rest of this session's CodeQL triage. --- tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs index 89925bb..56ae487 100644 --- a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs @@ -158,7 +158,7 @@ public async Task Callback_Subscribe_Slow_Handler_Does_Not_Block_Others_Or_The_B var busEventCount = 0; receiver.FrameObserved += (_, _) => Interlocked.Increment(ref busEventCount); - var block = new SemaphoreSlim(0); // never released: the slow handler blocks forever + using var block = new SemaphoreSlim(0); // never released: the slow handler blocks forever using var slow = service.Subscribe(_ => block.Wait(), bufferCapacity: 1); var fastCount = 0; From fa548741abf1fbdd948d95b9bef2ec53b4b72ee7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 10:47:23 +0000 Subject: [PATCH 4/4] fix(rawcan): stop callback pump after self-dispose Completing the subscription channel does not drop already-buffered frames, so disposing from onNext skipped the pump join and still delivered the remainder after Dispose returned. Stop the pump on the disposed flag instead. --- .../CanBusServiceExtensions.cs | 8 ++++ .../TestCases/RawCanSubscriptionTests.cs | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs index 1474e93..d871c13 100644 --- a/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs +++ b/src/CanKit.Pro.RawCan/CanBusServiceExtensions.cs @@ -78,6 +78,12 @@ public CallbackSubscription(ISubscription subscription, ICanBusService service, _isOnPump.Value = true; await foreach (var frame in _subscription.Frames.ConfigureAwait(false)) { + // Completing the channel writer (Subscription.Dispose) does not drop + // items already buffered. Without these checks a self-dispose from + // onNext would skip the pump join and still deliver every remaining + // queued frame after Dispose has returned. + if (Volatile.Read(ref _disposed) != 0) break; + try { onNext(frame); @@ -87,6 +93,8 @@ public CallbackSubscription(ISubscription subscription, ICanBusService service, if (service is CanBusService concrete) concrete.RaiseBackgroundException(ex); } + + if (Volatile.Read(ref _disposed) != 0) break; } }); } diff --git a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs index 56ae487..db783e3 100644 --- a/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/RawCanSubscriptionTests.cs @@ -229,6 +229,48 @@ public async Task Callback_Subscribe_Dispose_From_Inside_Handler_Does_Not_Hang() await disposed.Task.WaitAsync(TimeSpan.FromMilliseconds(500)); } + // Completing the channel writer does not drop items already queued. Disposing from inside + // onNext skips the pump join, so the pump must stop on the disposed flag rather than drain + // the remainder -- otherwise further onNext calls run after Dispose has returned. + [Fact] + public async Task Callback_Subscribe_Dispose_From_Inside_Handler_Does_Not_Deliver_Buffered_Frames() + { + var session = NewSession(); + using var sender = Open(session, 0); + using var receiver = Open(session, 1); + using var service = new CanBusService(receiver); + + IDisposable? subscription = null; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var proceed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var count = 0; + subscription = service.Subscribe(_ => + { + if (Interlocked.Increment(ref count) == 1) + { + entered.TrySetResult(true); + proceed.Task.GetAwaiter().GetResult(); + subscription!.Dispose(); + disposed.TrySetResult(true); + } + }); + + sender.Transmit(CanFrame.Classic(0x100, new byte[] { 1 })); + await entered.Task.WaitAsync(ShortTimeout); + + // Burst into the bounded buffer while onNext is blocked; these would otherwise be + // delivered after Dispose returns if the pump kept draining the completed channel. + for (var i = 0; i < 16; i++) + sender.Transmit(CanFrame.Classic(0x100, new byte[] { (byte)i })); + + proceed.TrySetResult(true); + await disposed.Task.WaitAsync(ShortTimeout); + await Task.Delay(200); + + Volatile.Read(ref count).Should().Be(1); + } + // A handler exception is isolated per frame -- delivery continues -- and surfaced via the // service's existing fault channel, the same as a throwing predicate. [Fact]