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
@@ -67,7 +67,13 @@ internal sealed class EventPump : IAsyncDisposable
private Task? _loop;
private Task? _dispatchLoop;
private bool _disposed;
private int _undecodableWarned;
/// <summary>
/// Value kinds already warned about. Bounded by the <c>MxValue</c> oneof enum, so a
/// broken producer cannot flood the log, while a SECOND unrepresentable kind appearing
/// later still gets its own line — warn-once-per-pump would have swallowed it.
/// </summary>
private readonly HashSet<MxValue.KindOneofCase> _undecodableKindsWarned = [];
/// <summary>Occurs when a data change event is received from the Galaxy subscriber.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -202,6 +208,23 @@ internal sealed class EventPump : IAsyncDisposable
}
}
/// <summary>
/// Logs the first occurrence of each unrepresentable value kind. The dispatch loop is
/// single-reader, so the set needs no lock — but it is only ever touched from there.
/// </summary>
private void WarnUndecodableOnce(MxEvent ev)
{
var kind = ev.Value?.KindCase ?? MxValue.KindOneofCase.None;
if (!_undecodableKindsWarned.Add(kind)) return;
_logger.LogWarning(
"Galaxy event for item handle {ItemHandle} carried a value kind this driver cannot " +
"represent ({KindCase}) — publishing BadTypeMismatch. This means the gateway is sending " +
"a wire type MxValueDecoder has no mapping for; readings of this kind are being lost. " +
"Further occurrences of {KindCase} are counted on galaxy.events.undecodable, not logged.",
ev.ItemHandle, kind, kind);
}
private void NotifyStreamFault(Exception cause)
{
if (_onStreamFault is null) return;
@@ -314,21 +337,19 @@ internal sealed class EventPump : IAsyncDisposable
{
if (!MxValueDecoder.TryDecode(ev.Value, out var value))
{
// The status is deliberately NOT downgraded here. Publishing null-under-Good is
// dishonest, but changing what an OPC UA client sees for a live shape is not a
// change to make blind: today nothing can reach this branch (every declared
// MxValue kind decodes), so it only fires on a wire type a future gateway adds.
// Surfacing it on a counter first means the next person has evidence to act on
// rather than a guess. Warn once per pump — a broken producer would flood.
// A value we cannot represent is published as Bad, NOT as a null carrying the
// event's own (typically Good) quality. A good null is indistinguishable from a
// real null reading, so a client would treat a value it never received as a
// genuine one — the failure would be invisible on the wire and survive only in a
// log nobody is reading. BadTypeMismatch says what actually happened: the payload
// arrived, and this driver has no CLR type to carry it in.
ValuesUndecodable.Add(1, _clientTag);
if (Interlocked.Exchange(ref _undecodableWarned, 1) == 0)
{
_logger.LogWarning(
"Galaxy event for item handle {ItemHandle} carried a value kind this driver cannot " +
"represent ({KindCase}); publishing null under the event's own quality. Further " +
"occurrences are counted on galaxy.events.undecodable but not logged.",
ev.ItemHandle, ev.Value?.KindCase);
}
WarnUndecodableOnce(ev);
return new DataValueSnapshot(
Value: null,
StatusCode: StatusCodeMap.BadTypeMismatch,
SourceTimestampUtc: ev.SourceTimestamp is { } undecodableTs ? undecodableTs.ToDateTime() : null,
ServerTimestampUtc: DateTime.UtcNow);
}
var statusCode = ev.Statuses.Count > 0