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:
Joseph Doherty
2026-05-22 06:59:09 -04:00
parent c9e446387a
commit 7f2e144f8d
7 changed files with 345 additions and 15 deletions

View File

@@ -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),
});
}
}