4769044f88
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.
Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.
Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.
⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.
Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.
It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.
Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.
A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.
Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.
Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.
Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
262 lines
11 KiB
C#
262 lines
11 KiB
C#
using Google.Protobuf;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// Round-trip tests for <see cref="MxValueDecoder"/>. Each scenario constructs a
|
|
/// gateway-style <see cref="MxValue"/>, decodes, and asserts the boxed CLR value
|
|
/// matches the expected type and value.
|
|
/// </summary>
|
|
public sealed class MxValueDecoderTests
|
|
{
|
|
/// <summary>Verifies that decoding null returns null.</summary>
|
|
[Fact]
|
|
public void Decode_Null_ReturnsNull()
|
|
{
|
|
MxValueDecoder.Decode(null).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that decoding IsNull flag returns null.</summary>
|
|
[Fact]
|
|
public void Decode_IsNullFlag_ReturnsNull()
|
|
{
|
|
var v = new MxValue { IsNull = true };
|
|
MxValueDecoder.Decode(v).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that bool values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_Bool() => MxValueDecoder.Decode(new MxValue { BoolValue = true }).ShouldBe(true);
|
|
|
|
/// <summary>Verifies that Int32 values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_Int32() => MxValueDecoder.Decode(new MxValue { Int32Value = -42 }).ShouldBe(-42);
|
|
|
|
/// <summary>Verifies that Int64 values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_Int64() => MxValueDecoder.Decode(new MxValue { Int64Value = 123456789012L }).ShouldBe(123456789012L);
|
|
|
|
/// <summary>Verifies that float values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_Float() => MxValueDecoder.Decode(new MxValue { FloatValue = 3.14f }).ShouldBe(3.14f);
|
|
|
|
/// <summary>Verifies that double values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_Double() => MxValueDecoder.Decode(new MxValue { DoubleValue = 2.71828 }).ShouldBe(2.71828);
|
|
|
|
/// <summary>Verifies that string values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_String() => MxValueDecoder.Decode(new MxValue { StringValue = "hello" }).ShouldBe("hello");
|
|
|
|
/// <summary>Verifies that timestamp values decode to UTC datetime.</summary>
|
|
[Fact]
|
|
public void Decode_Timestamp_ReturnsUtcDateTime()
|
|
{
|
|
var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc);
|
|
var v = new MxValue { TimestampValue = Timestamp.FromDateTime(when) };
|
|
MxValueDecoder.Decode(v).ShouldBe(when);
|
|
}
|
|
|
|
/// <summary>Verifies that bool arrays decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_BoolArray()
|
|
{
|
|
var v = new MxValue
|
|
{
|
|
ArrayValue = new MxArray
|
|
{
|
|
BoolValues = new BoolArray(),
|
|
},
|
|
};
|
|
v.ArrayValue.BoolValues.Values.AddRange(new[] { true, false, true });
|
|
|
|
var decoded = MxValueDecoder.Decode(v);
|
|
decoded.ShouldBe(new[] { true, false, true });
|
|
}
|
|
|
|
/// <summary>Verifies that double arrays decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_DoubleArray()
|
|
{
|
|
var v = new MxValue
|
|
{
|
|
ArrayValue = new MxArray { DoubleValues = new DoubleArray() },
|
|
};
|
|
v.ArrayValue.DoubleValues.Values.AddRange(new[] { 1.0, 2.0, 3.5 });
|
|
|
|
var decoded = MxValueDecoder.Decode(v);
|
|
decoded.ShouldBe(new[] { 1.0, 2.0, 3.5 });
|
|
}
|
|
|
|
/// <summary>Verifies that string arrays decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_StringArray()
|
|
{
|
|
var v = new MxValue
|
|
{
|
|
ArrayValue = new MxArray { StringValues = new StringArray() },
|
|
};
|
|
v.ArrayValue.StringValues.Values.AddRange(new[] { "a", "b" });
|
|
|
|
var decoded = MxValueDecoder.Decode(v);
|
|
decoded.ShouldBe(new[] { "a", "b" });
|
|
}
|
|
|
|
/// <summary>Verifies that raw byte values decode correctly.</summary>
|
|
[Fact]
|
|
public void Decode_RawValue_ReturnsBytes()
|
|
{
|
|
var bytes = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
|
|
var v = new MxValue { RawValue = ByteString.CopyFrom(bytes) };
|
|
|
|
var decoded = (byte[])MxValueDecoder.Decode(v)!;
|
|
decoded.ShouldBe(bytes);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Sparse arrays (MxGateway 0.2.0).
|
|
//
|
|
// The gateway contract makes these write-only — it expands a sparse write into a full
|
|
// MxArray and "the worker never receives or produces it" — so inbound is a contract
|
|
// violation rather than an expected path. These pin the behaviour if one ever arrives:
|
|
// the correct dense array, never a silent null and never a throw. The fill semantics
|
|
// mirror the gateway's own SparseArrayExpander, so a value that round-trips through a
|
|
// write and back reads as the same CLR array.
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
private static MxValue Sparse(MxDataType elementType, uint totalLength, params (uint Index, MxValue Value)[] elements)
|
|
{
|
|
var sparse = new MxSparseArray { ElementDataType = elementType, TotalLength = totalLength };
|
|
foreach (var (index, value) in elements)
|
|
{
|
|
sparse.Elements.Add(new MxSparseElement { Index = index, Value = value });
|
|
}
|
|
return new MxValue { SparseArrayValue = sparse };
|
|
}
|
|
|
|
/// <summary>Verifies unmentioned indices are reset to the element default, not dropped.</summary>
|
|
[Fact]
|
|
public void Decode_SparseInt32Array_FillsUnmentionedIndicesWithTheDefault()
|
|
{
|
|
var v = Sparse(MxDataType.Integer, 5,
|
|
(1, new MxValue { Int32Value = 42 }),
|
|
(4, new MxValue { Int32Value = -7 }));
|
|
|
|
MxValueDecoder.Decode(v).ShouldBe(new[] { 0, 42, 0, 0, -7 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies an Integer sparse array widens to long[] when any element carries an int64.
|
|
/// The gateway picks its Int32/Int64 sub-array the same way — a sparse payload has no CLR
|
|
/// array to infer the width from — so decoding narrow here would change the published
|
|
/// element type across a write round-trip.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Decode_SparseIntegerArray_WidensToInt64WhenAnyElementIs64Bit()
|
|
{
|
|
var v = Sparse(MxDataType.Integer, 3,
|
|
(0, new MxValue { Int32Value = 1 }),
|
|
(2, new MxValue { Int64Value = 8_000_000_000L }));
|
|
|
|
MxValueDecoder.Decode(v).ShouldBe(new[] { 1L, 0L, 8_000_000_000L });
|
|
}
|
|
|
|
/// <summary>Verifies bool / float / double / string sparse arrays fill with their own defaults.</summary>
|
|
[Fact]
|
|
public void Decode_SparseArrays_UseThePerTypeDefaultForUnmentionedIndices()
|
|
{
|
|
MxValueDecoder.Decode(Sparse(MxDataType.Boolean, 3, (1, new MxValue { BoolValue = true })))
|
|
.ShouldBe(new[] { false, true, false });
|
|
|
|
MxValueDecoder.Decode(Sparse(MxDataType.Float, 2, (0, new MxValue { FloatValue = 1.5f })))
|
|
.ShouldBe(new[] { 1.5f, 0f });
|
|
|
|
MxValueDecoder.Decode(Sparse(MxDataType.Double, 2, (1, new MxValue { DoubleValue = 2.5d })))
|
|
.ShouldBe(new[] { 0d, 2.5d });
|
|
|
|
// string defaults to empty, NOT null — matching the expander, so a consumer never
|
|
// has to null-check individual elements of an array it was handed whole.
|
|
MxValueDecoder.Decode(Sparse(MxDataType.String, 3, (2, new MxValue { StringValue = "z" })))
|
|
.ShouldBe(new[] { string.Empty, string.Empty, "z" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a Time sparse array defaults to the Unix epoch — the expander fills
|
|
/// <c>Timestamp{0,0}</c>, and default(DateTime) would decode a different instant.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Decode_SparseTimeArray_DefaultsToTheUnixEpochNotDefaultDateTime()
|
|
{
|
|
var when = new DateTime(2026, 8, 12, 3, 4, 5, DateTimeKind.Utc);
|
|
var v = Sparse(MxDataType.Time, 2, (1, new MxValue { TimestampValue = Timestamp.FromDateTime(when) }));
|
|
|
|
var decoded = (DateTime[])MxValueDecoder.Decode(v)!;
|
|
decoded[0].ShouldBe(DateTime.UnixEpoch);
|
|
decoded[0].ShouldNotBe(default);
|
|
decoded[1].ShouldBe(when);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a malformed sparse payload degrades instead of throwing. The decoder runs
|
|
/// inside the pump's dispatch loop, where an escaping exception kills dispatch for the
|
|
/// life of the pump — so an out-of-range index must be skipped, not raised.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Decode_SparseArray_WithAnOutOfRangeIndex_SkipsItRatherThanThrowing()
|
|
{
|
|
var v = Sparse(MxDataType.Integer, 2,
|
|
(0, new MxValue { Int32Value = 5 }),
|
|
(99, new MxValue { Int32Value = 6 }));
|
|
|
|
object? decoded = null;
|
|
Should.NotThrow(() => decoded = MxValueDecoder.Decode(v));
|
|
decoded.ShouldBe(new[] { 5, 0 });
|
|
}
|
|
|
|
/// <summary>Verifies an unsupported element type and a zero length report as unrepresentable.</summary>
|
|
[Fact]
|
|
public void TryDecode_SparseArray_WithAnUnsupportedShape_ReportsUnrepresentable()
|
|
{
|
|
// A struct element type is not a scalar the expander supports either — there is no
|
|
// array to build, so this must NOT masquerade as a successfully decoded null.
|
|
MxValueDecoder.TryDecode(Sparse(MxDataType.QualifiedStruct, 2), out var structDecoded).ShouldBeFalse();
|
|
structDecoded.ShouldBeNull();
|
|
|
|
MxValueDecoder.TryDecode(Sparse(MxDataType.Integer, 0), out _).ShouldBeFalse();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Representable-vs-null. Decode() alone collapses both to null, which is how a value
|
|
// kind this driver cannot represent would be published as a good null and lost.
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies a genuine null decodes successfully rather than reporting failure.</summary>
|
|
[Fact]
|
|
public void TryDecode_DistinguishesAGenuineNullFromAnUnrepresentableValue()
|
|
{
|
|
MxValueDecoder.TryDecode(null, out var fromNullRef).ShouldBeTrue();
|
|
fromNullRef.ShouldBeNull();
|
|
|
|
MxValueDecoder.TryDecode(new MxValue { IsNull = true }, out var fromIsNull).ShouldBeTrue();
|
|
fromIsNull.ShouldBeNull();
|
|
|
|
// An MxValue with no kind set at all is not a null value — it is a payload this
|
|
// driver cannot interpret, and the two must not report identically.
|
|
MxValueDecoder.TryDecode(new MxValue(), out var fromEmpty).ShouldBeFalse();
|
|
fromEmpty.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies an array whose element oneof is unset reports as unrepresentable.</summary>
|
|
[Fact]
|
|
public void TryDecode_ArrayWithNoElementsOneofSet_ReportsUnrepresentable()
|
|
{
|
|
MxValueDecoder.TryDecode(new MxValue { ArrayValue = new MxArray() }, out var decoded).ShouldBeFalse();
|
|
decoded.ShouldBeNull();
|
|
}
|
|
}
|