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

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