c51c59987b
Reverses the conservative call from the previous commit at the user's direction, and they were right: "surface it on a counter first" left the wire lying. An MXAccess event can carry Good quality while its payload is a kind MxValueDecoder has no mapping for, so the old behaviour published null-under-Good — which a client cannot tell apart from a genuine null reading. A value the gateway DID send would be consumed as a value it did not, with the only evidence in a log nobody is tailing. Bad data is the honest wire representation of lost data. BadTypeMismatch is an existing StatusCodeMap constant, deliberately reused rather than written inline: StatusCodeParityTests finds status constants by reflecting over Good/Uncertain/Bad-prefixed uint fields and checking them against the pinned SDK, so an inline literal would be invisible to that guard (Gitea #497, where 16 wrong constants had accumulated across 6 drivers exactly this way). Logging is now once per DISTINCT value kind rather than once per pump. The set is bounded by the MxValue oneof enum so a broken producer still cannot flood, but a second unrepresentable kind appearing later gets its own line — warn-once-per-pump would have silently swallowed it, which is the same class of bug as the null this commit fixes. The test's fixture matters more than its assertion: the undecodable event carries Quality = 192 (OPC DA Good), so a decoder that gave up silently would publish Good and fail. Paired with a positive control — a decodable value at the same quality must still publish Good — so it cannot be satisfied by a pump that marks everything Bad. Falsified by reverting the status to Good: the test reds, and only that test. Verified: 331 Galaxy tests green, Core.Abstractions 298 (the parity guard), solution builds 0 errors. Docs corrected in the two places that recorded the old decision as deliberate.
330 lines
16 KiB
C#
330 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Coverage for the two <see cref="EventPump"/> signals that carry no item payload and so
|
|
/// have no natural home in the data-change fan-out: the gateway's replay-gap sentinel
|
|
/// (MxGateway 0.2.0) and a stream the gateway completes without an error.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ⚠️ <b>The replay-gap sentinel is unreachable in the shipped configuration.</b>
|
|
/// <c>GatewayGalaxySubscriber</c> opens the stream with <c>after_worker_sequence = 0</c>,
|
|
/// which the gateway treats as "fresh stream, not a resume — plain attach, no replay, no
|
|
/// sentinel". These tests therefore pin a contract obligation rather than a live path: the
|
|
/// sentinel carries <c>family = UNSPECIFIED</c>, so before this work it fell straight
|
|
/// through the pump's family switch into the default drop, and enabling resume later would
|
|
/// have silently ignored a documented MUST.
|
|
/// </para>
|
|
/// <para>
|
|
/// The clean-completion half, by contrast, IS live: a gateway that closes or evicts a
|
|
/// session ends the RPC normally, which raises nothing at all.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class EventPumpReplayGapTests
|
|
{
|
|
private const int WaitMs = 2_000;
|
|
|
|
private static MxEvent GapSentinel(ulong requestedAfter, ulong oldestAvailable) => new()
|
|
{
|
|
// Shaped exactly as the gateway emits it: no family, no body, no per-item fields.
|
|
Family = MxEventFamily.Unspecified,
|
|
ReplayGap = new ReplayGap
|
|
{
|
|
RequestedAfterSequence = requestedAfter,
|
|
OldestAvailableSequence = oldestAvailable,
|
|
},
|
|
};
|
|
|
|
/// <summary>Verifies the sentinel reaches the callback carrying both sequence bounds.</summary>
|
|
[Fact]
|
|
public async Task A_replay_gap_sentinel_invokes_the_callback_with_both_sequences()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var observed = new TaskCompletionSource<ReplayGap>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, new SubscriptionRegistry(), channelCapacity: 8, clientName: "GapTest",
|
|
onReplayGap: gap => observed.TrySetResult(gap));
|
|
pump.Start();
|
|
|
|
await subscriber.EmitAsync(GapSentinel(requestedAfter: 100, oldestAvailable: 250));
|
|
|
|
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
|
|
"a replay-gap sentinel must reach the driver so it can re-snapshot");
|
|
var gap = await observed.Task;
|
|
gap.RequestedAfterSequence.ShouldBe(100UL);
|
|
gap.OldestAvailableSequence.ShouldBe(250UL);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the sentinel is consumed as a control signal and leaves the stream working.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The "not dispatched" half is cheap insurance rather than the point: a sentinel carries
|
|
/// item handle 0, and <see cref="SubscriptionRegistry"/> never binds a non-positive handle
|
|
/// (that is its marker for a failed gateway subscribe), so it could not have reached a
|
|
/// subscriber even before it was intercepted. What this actually pins is that consuming
|
|
/// the sentinel off the read loop does not disturb the events around it — the load-bearing
|
|
/// assertion is the positive control that follows it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_replay_gap_sentinel_is_not_dispatched_as_a_data_change()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var registry = new SubscriptionRegistry();
|
|
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
|
|
|
var dispatched = 0;
|
|
var gapSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var realValue = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
|
|
onReplayGap: _ => gapSeen.TrySetResult());
|
|
pump.OnDataChange += (_, args) => { Interlocked.Increment(ref dispatched); realValue.TrySetResult(args); };
|
|
pump.Start();
|
|
|
|
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
|
|
(await Task.WhenAny(gapSeen.Task, Task.Delay(WaitMs))).ShouldBe(gapSeen.Task);
|
|
|
|
// A positive control: follow the sentinel with a real event on the same handle. Without
|
|
// it, "nothing was dispatched" would also pass on a pump that dispatches nothing at all.
|
|
await subscriber.EmitAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ItemHandle = 7,
|
|
Value = new MxValue { DoubleValue = 12.5 },
|
|
Quality = 192,
|
|
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
});
|
|
|
|
(await Task.WhenAny(realValue.Task, Task.Delay(WaitMs))).ShouldBe(realValue.Task,
|
|
"the pump must keep dispatching real events after a sentinel");
|
|
(await realValue.Task).Snapshot.Value.ShouldBe(12.5);
|
|
Volatile.Read(ref dispatched).ShouldBe(1, "only the real event may be dispatched, never the sentinel");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the sentinel survives a full event channel. This is the reason it is handled
|
|
/// on the read loop rather than in the dispatch loop: everything that goes through the
|
|
/// channel is subject to newest-dropped pressure, and "you missed events" is the one
|
|
/// signal whose loss cannot be recovered from later — the pump would carry on serving
|
|
/// stale values with no indication anything was wrong.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_replay_gap_sentinel_survives_a_full_event_channel()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var registry = new SubscriptionRegistry();
|
|
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
|
|
|
using var blockDispatch = new ManualResetEventSlim(false);
|
|
var observed = new TaskCompletionSource<ReplayGap>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, registry, channelCapacity: 1, clientName: "GapTest",
|
|
onReplayGap: gap => observed.TrySetResult(gap));
|
|
// Wedge the dispatch loop so the bounded channel fills and stays full.
|
|
pump.OnDataChange += (_, _) => blockDispatch.Wait(TimeSpan.FromSeconds(10));
|
|
pump.Start();
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
await subscriber.EmitAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ItemHandle = 7,
|
|
Value = new MxValue { DoubleValue = i },
|
|
Quality = 192,
|
|
});
|
|
}
|
|
|
|
await subscriber.EmitAsync(GapSentinel(requestedAfter: 5, oldestAvailable: 900));
|
|
|
|
(await Task.WhenAny(observed.Task, Task.Delay(WaitMs))).ShouldBe(observed.Task,
|
|
"the sentinel must not be droppable by channel back-pressure");
|
|
(await observed.Task).OldestAvailableSequence.ShouldBe(900UL);
|
|
|
|
blockDispatch.Set();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a throwing gap callback cannot take the stream down with it. The callback
|
|
/// runs on the read loop, so an escaping exception would land in RunAsync's catch and be
|
|
/// reported as a transport fault — tearing down a stream that is perfectly healthy.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_throwing_replay_gap_callback_does_not_fault_the_stream()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var registry = new SubscriptionRegistry();
|
|
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
|
|
|
var faulted = false;
|
|
var resumed = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, registry, channelCapacity: 8, clientName: "GapTest",
|
|
onStreamFault: _ => faulted = true,
|
|
onReplayGap: _ => throw new InvalidOperationException("re-snapshot could not be scheduled"));
|
|
pump.OnDataChange += (_, args) => resumed.TrySetResult(args);
|
|
pump.Start();
|
|
|
|
await subscriber.EmitAsync(GapSentinel(requestedAfter: 1, oldestAvailable: 2));
|
|
await subscriber.EmitAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ItemHandle = 7,
|
|
Value = new MxValue { DoubleValue = 99.0 },
|
|
Quality = 192,
|
|
});
|
|
|
|
(await Task.WhenAny(resumed.Task, Task.Delay(WaitMs))).ShouldBe(resumed.Task,
|
|
"a failed re-snapshot must not stop the stream that is still delivering");
|
|
faulted.ShouldBeFalse("a throwing gap callback is not a transport fault");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a stream the GATEWAY completes is reported as a fault. A closed or evicted
|
|
/// session ends the RPC normally, so nothing throws: before this, the pump exited
|
|
/// silently, the supervisor was never told, and <c>GetHealth()</c> kept reporting
|
|
/// Healthy — which also means the #527 comms-loss watchdog never tripped, since it
|
|
/// requires a non-Healthy state. The driver went permanently deaf with no signal at all.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The falsification control for this lives in
|
|
/// <see cref="EventPumpStreamFaultTests.CleanShutdown_DoesNotInvokeOnStreamFault"/>: our
|
|
/// own disposal also ends the stream, and must NOT be reported.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task A_stream_the_gateway_completes_without_an_error_is_reported_as_a_fault()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var faultObserved = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, new SubscriptionRegistry(), channelCapacity: 4, clientName: "GapTest",
|
|
onStreamFault: ex => faultObserved.TrySetResult(ex));
|
|
pump.Start();
|
|
|
|
subscriber.CompleteStream();
|
|
|
|
(await Task.WhenAny(faultObserved.Task, Task.Delay(WaitMs))).ShouldBe(faultObserved.Task,
|
|
"a server-completed stream is as fatal as a faulted one and must reach the supervisor");
|
|
(await faultObserved.Task).Message.ShouldContain("closed or evicted");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a value kind this driver cannot represent is published as <c>Bad</c> rather
|
|
/// than as a null carrying the event's own quality.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The event below carries <c>Quality = 192</c> (OPC DA Good), so a decoder that gave up
|
|
/// silently would publish <c>null</c> under a <b>Good</b> status — indistinguishable from
|
|
/// a genuine null reading, which is how a value the gateway did send would be consumed as
|
|
/// a value it didn't. The Good quality is the point of the fixture: with a Bad quality on
|
|
/// the event the assertion would hold no matter what the decoder did.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task An_unrepresentable_value_is_published_as_bad_not_as_a_good_null()
|
|
{
|
|
var subscriber = new ControllableSubscriber();
|
|
var registry = new SubscriptionRegistry();
|
|
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
|
|
|
var received = new List<DataChangeEventArgs>();
|
|
var bothSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
await using var pump = new EventPump(
|
|
subscriber, registry, channelCapacity: 8, clientName: "GapTest");
|
|
pump.OnDataChange += (_, args) =>
|
|
{
|
|
lock (received)
|
|
{
|
|
received.Add(args);
|
|
if (received.Count == 2) bothSeen.TrySetResult();
|
|
}
|
|
};
|
|
pump.Start();
|
|
|
|
// An MxValue with no kind set at all — a payload we cannot interpret — under Good quality.
|
|
await subscriber.EmitAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ItemHandle = 7,
|
|
Value = new MxValue(),
|
|
Quality = 192,
|
|
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
});
|
|
|
|
// Positive control: a decodable value on the same handle and the same Good quality must
|
|
// still publish Good, so the assertion above cannot be satisfied by a pump that simply
|
|
// marks everything Bad.
|
|
await subscriber.EmitAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ItemHandle = 7,
|
|
Value = new MxValue { DoubleValue = 42.0 },
|
|
Quality = 192,
|
|
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
});
|
|
|
|
(await Task.WhenAny(bothSeen.Task, Task.Delay(WaitMs))).ShouldBe(bothSeen.Task);
|
|
|
|
lock (received)
|
|
{
|
|
received[0].Snapshot.Value.ShouldBeNull();
|
|
received[0].Snapshot.StatusCode.ShouldBe(StatusCodeMap.BadTypeMismatch,
|
|
"an undecodable payload must not reach a client wearing the event's Good quality");
|
|
|
|
received[1].Snapshot.Value.ShouldBe(42.0);
|
|
received[1].Snapshot.StatusCode.ShouldBe(StatusCodeMap.Good,
|
|
"a decodable value on the same quality must still publish Good");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="IGalaxySubscriber"/> fake whose stream can be fed arbitrary events and
|
|
/// completed cleanly, so both control-signal paths can be driven directly.
|
|
/// </summary>
|
|
private sealed class ControllableSubscriber : IGalaxySubscriber
|
|
{
|
|
private readonly Channel<MxEvent> _stream =
|
|
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
|
|
|
/// <summary>Subscribes to multiple tags (test stub).</summary>
|
|
/// <param name="fullReferences">The tag references to subscribe to.</param>
|
|
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
|
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
|
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
|
|
|
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
|
|
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <summary>Streams events asynchronously (test stub).</summary>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
|
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
|
|
|
/// <summary>Pushes one event onto the stream.</summary>
|
|
/// <param name="ev">The event to emit.</param>
|
|
public ValueTask EmitAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
|
|
|
/// <summary>Ends the stream the way a gateway closing the session does — with no error.</summary>
|
|
public void CompleteStream() => _stream.Writer.TryComplete();
|
|
}
|
|
}
|