fix(driver-galaxy): resolve High code-review findings (Driver.Galaxy-002, Driver.Galaxy-008)
Driver.Galaxy-002 — DataTypeMap.Map had no Int64 arm though MxValueDecoder/ MxValueEncoder both fully support Int64. Galaxy attributes with the Int64 mx_data_type code fell through to the String default, creating a String address-space node while runtime reads decoded a boxed long. Added `6 => DriverDataType.Int64`, extending the contiguous 0..5 scheme so the type map agrees with the decoder/encoder on all seven Galaxy data types. Driver.Galaxy-008 — after a stream fault the EventPump's StreamEvents consumer loop exited and its channel completed; EventPump.Start() is a no-op on a completed-but-non-null loop, so a replayed subscription had no consumer and ReplayAsync never re-registered the post-reconnect item handles. ReplayAsync now recreates the EventPump (RestartEventPumpForReplay) and rebinds the SubscriptionRegistry per subscription with the fresh item handles returned by the post-reconnect SubscribeBulkAsync, via new SubscriptionRegistry.SnapshotEntries and Rebind APIs. Regression tests: DataTypeMapTests (every code incl. Int64), SubscriptionRegistry Tests (Rebind/SnapshotEntries), EventPumpStreamFaultTests (faulted pump dead, fresh pump resumes dispatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for Driver.Galaxy-002 (High): every Galaxy <c>mx_data_type</c>
|
||||
/// code in the contiguous 0..6 scheme must map to the matching <see cref="DriverDataType"/>.
|
||||
/// The Int64 arm (code 6) was missing, so an Int64 attribute fell through to the
|
||||
/// <see cref="DriverDataType.String"/> default — a String address-space node while
|
||||
/// runtime reads decoded a boxed <c>long</c>. <see cref="DataTypeMap"/> must now agree
|
||||
/// with <c>MxValueDecoder</c> / <c>MxValueEncoder</c>, which both fully support Int64.
|
||||
/// </summary>
|
||||
public sealed class DataTypeMapTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, DriverDataType.Boolean)]
|
||||
[InlineData(1, DriverDataType.Int32)]
|
||||
[InlineData(2, DriverDataType.Float32)]
|
||||
[InlineData(3, DriverDataType.Float64)]
|
||||
[InlineData(4, DriverDataType.String)]
|
||||
[InlineData(5, DriverDataType.DateTime)]
|
||||
[InlineData(6, DriverDataType.Int64)]
|
||||
public void Map_KnownCode_MapsToExpectedDriverDataType(int mxDataType, DriverDataType expected)
|
||||
{
|
||||
DataTypeMap.Map(mxDataType).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_Int64Code_DoesNotFallThroughToStringDefault()
|
||||
{
|
||||
// The bug: Int64 (code 6) used to hit the `_ => String` default.
|
||||
DataTypeMap.Map(6).ShouldBe(DriverDataType.Int64);
|
||||
DataTypeMap.Map(6).ShouldNotBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7)]
|
||||
[InlineData(99)]
|
||||
[InlineData(-1)]
|
||||
public void Map_UnknownCode_FallsBackToString(int mxDataType)
|
||||
{
|
||||
// Forward-compatibility fallback for codes the driver doesn't recognise.
|
||||
DataTypeMap.Map(mxDataType).ShouldBe(DriverDataType.String);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
@@ -74,6 +76,48 @@ public sealed class EventPumpStreamFaultTests
|
||||
supervisor.IsDegraded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaultedPump_IsNotRestartableInPlace_ButAFreshPumpResumesDispatch()
|
||||
{
|
||||
// Regression coverage for Driver.Galaxy-008 (High): after a stream fault the old
|
||||
// pump's RunAsync loop has exited and its channel is completed — EventPump.Start()
|
||||
// is a no-op on a non-null-but-completed loop, so the recovery path must DISPOSE
|
||||
// the faulted pump and create a FRESH one. This test pins both halves of that:
|
||||
// (a) the faulted pump is dead, (b) a new pump on a live stream resumes OnDataChange.
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tank.Level", ItemHandle: 7)]);
|
||||
|
||||
// --- first pump: faults, then is "restarted" (no-op) and confirmed dead ---
|
||||
var faulted = new FaultingSubscriber();
|
||||
var staleObserved = false;
|
||||
var oldPump = new EventPump(faulted, registry, channelCapacity: 8, clientName: "Restart");
|
||||
oldPump.OnDataChange += (_, _) => staleObserved = true;
|
||||
oldPump.Start();
|
||||
faulted.FaultStream(new IOException("simulated gateway transport drop"));
|
||||
await Task.Delay(100);
|
||||
|
||||
// In-place Start() after a fault is a no-op — the loop task is non-null but done.
|
||||
oldPump.Start();
|
||||
await oldPump.DisposeAsync();
|
||||
|
||||
// --- fresh pump on a live re-subscribed stream: OnDataChange must resume ---
|
||||
var resubscribed = new ReplaySubscriber();
|
||||
var resumed = new TaskCompletionSource<DataChangeEventArgs>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var newPump = new EventPump(
|
||||
resubscribed, registry, channelCapacity: 8, clientName: "Restart");
|
||||
newPump.OnDataChange += (_, args) => resumed.TrySetResult(args);
|
||||
newPump.Start();
|
||||
|
||||
await resubscribed.EmitAsync(itemHandle: 7, value: 123.0);
|
||||
|
||||
var completed = await Task.WhenAny(resumed.Task, Task.Delay(WaitMs));
|
||||
completed.ShouldBe(resumed.Task,
|
||||
"a fresh EventPump created after a fault must resume dispatching OnDataChange");
|
||||
(await resumed.Task).FullReference.ShouldBe("Tank.Level");
|
||||
staleObserved.ShouldBeFalse("the faulted pump must not dispatch after its stream dropped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanShutdown_DoesNotInvokeOnStreamFault()
|
||||
{
|
||||
@@ -115,4 +159,34 @@ public sealed class EventPumpStreamFaultTests
|
||||
/// <summary>Fault the stream so the pump's <c>await foreach</c> throws.</summary>
|
||||
public void FaultStream(Exception cause) => _stream.Writer.TryComplete(cause);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IGalaxySubscriber"/> fake modelling the post-reconnect stream — a
|
||||
/// fresh, healthy StreamEvents the recovery path's new EventPump consumes.
|
||||
/// </summary>
|
||||
private sealed class ReplaySubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly Channel<MxEvent> _stream =
|
||||
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitAsync(int itemHandle, double value) =>
|
||||
_stream.Writer.WriteAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = itemHandle,
|
||||
Value = new MxValue { DoubleValue = value },
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,74 @@ public sealed class SubscriptionRegistryTests
|
||||
registry.TrackedItemHandleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ===== Driver.Galaxy-008 regression: reconnect replay rebinds with fresh handles =====
|
||||
|
||||
[Fact]
|
||||
public void SnapshotEntries_GroupsBindingsBySubscriptionId()
|
||||
{
|
||||
var registry = new SubscriptionRegistryAccess();
|
||||
registry.Register(1, [new TagBindingAccess("A", 100)]);
|
||||
registry.Register(2, [new TagBindingAccess("B", 200), new TagBindingAccess("C", 300)]);
|
||||
|
||||
var entries = registry.SnapshotEntries();
|
||||
|
||||
entries.Count.ShouldBe(2);
|
||||
entries.Single(e => e.SubscriptionId == 1).Bindings.Count.ShouldBe(1);
|
||||
entries.Single(e => e.SubscriptionId == 2).Bindings.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebind_ReplacesStaleItemHandles_WithThePostReconnectHandles()
|
||||
{
|
||||
// Before reconnect the gw assigned handle 100; after reconnect it issues 555.
|
||||
var registry = new SubscriptionRegistryAccess();
|
||||
registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
|
||||
|
||||
registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]);
|
||||
|
||||
// The stale handle no longer fans out; the fresh handle does.
|
||||
registry.ResolveSubscribers(100).ShouldBeEmpty();
|
||||
var subs = registry.ResolveSubscribers(555);
|
||||
subs.Count.ShouldBe(1);
|
||||
subs[0].SubscriptionId.ShouldBe(1);
|
||||
subs[0].FullReference.ShouldBe("Tank.Level");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebind_LeavesOtherSubscriptionsOnTheSameOldHandleIntact()
|
||||
{
|
||||
var registry = new SubscriptionRegistryAccess();
|
||||
registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
|
||||
registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]);
|
||||
|
||||
// Only subscription 1 replays onto a fresh handle.
|
||||
registry.Rebind(1, [new TagBindingAccess("Tank.Level", 555)]);
|
||||
|
||||
registry.ResolveSubscribers(100).Select(s => s.SubscriptionId).ShouldBe(new[] { 2L });
|
||||
registry.ResolveSubscribers(555).Select(s => s.SubscriptionId).ShouldBe(new[] { 1L });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebind_UnknownSubscription_IsNoOp()
|
||||
{
|
||||
var registry = new SubscriptionRegistryAccess();
|
||||
Should.NotThrow(() => registry.Rebind(999, [new TagBindingAccess("X", 1)]));
|
||||
registry.ResolveSubscribers(1).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebind_FailedItemHandle_NotIndexedForFanOut()
|
||||
{
|
||||
var registry = new SubscriptionRegistryAccess();
|
||||
registry.Register(1, [new TagBindingAccess("Tag", 100)]);
|
||||
|
||||
// Post-reconnect the gw rejected the tag — handle 0.
|
||||
registry.Rebind(1, [new TagBindingAccess("Tag", 0)]);
|
||||
|
||||
registry.ResolveSubscribers(100).ShouldBeEmpty();
|
||||
registry.ResolveSubscribers(0).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Internal types are accessed via friend assembly (InternalsVisibleTo); these
|
||||
// wrapper aliases keep the test code readable.
|
||||
private sealed class SubscriptionRegistryAccess
|
||||
@@ -132,6 +200,12 @@ public sealed class SubscriptionRegistryTests
|
||||
}
|
||||
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle)
|
||||
=> _inner.ResolveSubscribers(handle);
|
||||
public void Rebind(long id, IReadOnlyList<TagBindingAccess> bindings)
|
||||
=> _inner.Rebind(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
|
||||
public IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBindingAccess> Bindings)> SnapshotEntries()
|
||||
=> [.. _inner.SnapshotEntries().Select(e =>
|
||||
(e.SubscriptionId,
|
||||
(IReadOnlyList<TagBindingAccess>)[.. e.Bindings.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))]))];
|
||||
}
|
||||
private sealed record TagBindingAccess(string FullReference, int ItemHandle);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user