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();
}
}