using System.Runtime.CompilerServices; using System.Threading.Channels; using Google.Protobuf.WellKnownTypes; using Shouldly; using Xunit; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Review 2026-10-21 G-10: a dispatch-loop exit left the read loop draining into a channel /// nobody read, with health Healthy and nothing telling the supervisor — a clean termination /// evading the watchdog. The exit now reports a stream fault. The fault is injected through /// the pump's handle factory, the one dispatch-side seam that runs outside the per-handler /// isolation. /// public sealed class EventPumpDispatchFaultTests { private const int WaitMs = 10_000; private sealed class ChannelSubscriber : IGalaxySubscriber { private readonly Channel _events = Channel.CreateUnbounded(); public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) => Task.FromResult>([]); public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; public async IAsyncEnumerable StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return ev; } } public ValueTask EmitAsync(int handle) => _events.Writer.WriteAsync(new MxEvent { Family = MxEventFamily.OnDataChange, ItemHandle = handle, Value = new MxValue { DoubleValue = 1 }, Quality = 192, SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow), }); } /// A dispatch-loop exception reports a stream fault carrying that exception. [Fact] public async Task Dispatch_loop_exit_reports_a_stream_fault() { var subscriber = new ChannelSubscriber(); var registry = new SubscriptionRegistry(); registry.Register(1, [new TagBinding("Tank01.Level", 7)]); var fault = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var pump = new EventPump( subscriber, registry, handleFactory: _ => throw new InvalidDataException("synthetic dispatch failure"), channelCapacity: 4, clientName: "DispatchFault", onStreamFault: ex => fault.TrySetResult(ex)); pump.Start(); await subscriber.EmitAsync(7); (await Task.WhenAny(fault.Task, Task.Delay(WaitMs))).ShouldBe(fault.Task, "a dispatch-loop exit must be reported as a stream fault"); (await fault.Task).ShouldBeOfType(); } /// Positive control: the same event with a working handle factory dispatches and reports no fault. [Fact] public async Task Same_event_with_a_working_factory_dispatches_without_a_fault() { var subscriber = new ChannelSubscriber(); var registry = new SubscriptionRegistry(); registry.Register(1, [new TagBinding("Tank01.Level", 7)]); var faulted = false; var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var pump = new EventPump( subscriber, registry, channelCapacity: 4, clientName: "DispatchFault", onStreamFault: _ => faulted = true); pump.OnDataChange += (_, e) => delivered.TrySetResult(e); pump.Start(); await subscriber.EmitAsync(7); (await Task.WhenAny(delivered.Task, Task.Delay(WaitMs))).ShouldBe(delivered.Task); (await delivered.Task).FullReference.ShouldBe("Tank01.Level"); faulted.ShouldBeFalse(); } }