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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user