fix(galaxy): publish an unrepresentable value as BadTypeMismatch, not a good null
v2-ci / build (push) Successful in 6m23s
v2-ci / unit-tests (push) Failing after 20m2s

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.
This commit is contained in:
Joseph Doherty
2026-08-12 03:03:51 -04:00
parent 4769044f88
commit c51c59987b
4 changed files with 110 additions and 19 deletions
@@ -222,6 +222,75 @@ public sealed class EventPumpReplayGapTests
(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.