Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpReplayGapTests.cs
T
Joseph Doherty c51c59987b
v2-ci / build (push) Successful in 6m23s
v2-ci / unit-tests (push) Failing after 20m2s
fix(galaxy): publish an unrepresentable value as BadTypeMismatch, not a good null
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.
2026-08-12 03:03:51 -04:00

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