From 30ece6e22c8966c53995448b4c175e5757117132 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 06:03:16 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=208=20=E2=80=94=20wire=20gateway?= =?UTF-8?q?-level=20host-status=20push=20from=20MxAccessGalaxyBackend.=20P?= =?UTF-8?q?R=204=20built=20the=20IPC=20infrastructure=20for=20OnHostStatus?= =?UTF-8?q?Changed=20(MessageKind.RuntimeStatusChange=20frame=20+=20Connec?= =?UTF-8?q?tionSink=20forwarding=20through=20FrameWriter)=20but=20no=20bac?= =?UTF-8?q?kend=20actually=20raised=20the=20event;=20the=20#pragma=20warni?= =?UTF-8?q?ng=20disable=20CS0067=20around=20MxAccessGalaxyBackend.OnHostSt?= =?UTF-8?q?atusChanged=20declared=20the=20event=20for=20interface=20symmet?= =?UTF-8?q?ry=20while=20acknowledging=20the=20wire-up=20was=20Phase=202=20?= =?UTF-8?q?follow-up.=20This=20PR=20closes=20the=20gateway-level=20signal:?= =?UTF-8?q?=20MxAccessClient.ConnectionStateChanged=20(already=20raised=20?= =?UTF-8?q?on=20false=E2=86=92true=20Register=20and=20true=E2=86=92false?= =?UTF-8?q?=20Unregister=20transitions,=20including=20the=20reconnect=20pa?= =?UTF-8?q?th=20in=20MonitorLoopAsync)=20now=20drives=20OnHostStatusChange?= =?UTF-8?q?d=20with=20a=20synthetic=20HostConnectivityStatus=20tagged=20Ho?= =?UTF-8?q?stName=3DMxAccessClient.ClientName,=20RuntimeStatus=3D"Running"?= =?UTF-8?q?=20on=20reconnect=20+=20"Stopped"=20on=20disconnect,=20LastObse?= =?UTF-8?q?rvedUtcUnixMs=20set=20to=20the=20transition=20moment.=20The=20A?= =?UTF-8?q?dmin=20UI's=20existing=20IHostConnectivityProbe=20subscriber=20?= =?UTF-8?q?on=20GalaxyProxyDriver=20(HostStatusChangedEventArgs)=20already?= =?UTF-8?q?=20handles=20the=20full=20translation=20=E2=80=94=20OnHostConne?= =?UTF-8?q?ctivityUpdate=20parses=20"Running"/"Stopped"/"Faulted"=20into?= =?UTF-8?q?=20the=20Core.Abstractions=20HostState=20enum=20and=20fires=20O?= =?UTF-8?q?nHostStatusChanged=20downstream,=20so=20this=20single=20backend?= =?UTF-8?q?-side=20event=20wire-up=20produces=20an=20end-to-end=20signal?= =?UTF-8?q?=20with=20no=20further=20Proxy=20changes=20required.=20Per-plat?= =?UTF-8?q?form=20and=20per-AppEngine=20ScanState=20probing=20(the=20472?= =?UTF-8?q?=20LOC=20GalaxyRuntimeProbeManager=20state=20machine=20in=20v1?= =?UTF-8?q?=20that=20advises=20.ScanState=20on=20every=20deployed=20?= =?UTF-8?q?$WinPlatform=20+=20$AppEngine=20gobject,=20tracks=20Unknown=20?= =?UTF-8?q?=E2=86=92=20Running=20=E2=86=92=20Stopped=20transitions,=20hand?= =?UTF-8?q?les=20the=20on-change-only=20delivery=20quirk=20of=20ScanState,?= =?UTF-8?q?=20and=20surfaces=20IsHostStopped(gobjectId)=20for=20the=20node?= =?UTF-8?q?=20manager's=20Read=20path=20to=20short-circuit=20on-demand=20r?= =?UTF-8?q?eads=20against=20known-stopped=20runtimes)=20remains=20deferred?= =?UTF-8?q?=20to=20a=20follow-up=20PR=20=E2=80=94=20the=20gateway-level=20?= =?UTF-8?q?signal=20gives=20operators=20the=20top-level=20transport-health?= =?UTF-8?q?=20rung=20of=20the=20status=20ladder,=20which=20is=20what=20mat?= =?UTF-8?q?ters=20when=20the=20Galaxy=20COM=20proxy=20itself=20goes=20down?= =?UTF-8?q?=20(vs=20a=20specific=20platform=20going=20down).=20MxAccessCli?= =?UTF-8?q?ent.ClientName=20property=20exposes=20the=20previously-private?= =?UTF-8?q?=20=5FclientName=20field=20so=20the=20backend=20can=20tag=20its?= =?UTF-8?q?=20pushes=20with=20a=20stable=20gateway=20identity=20=E2=80=94?= =?UTF-8?q?=20operators=20configure=20this=20via=20OTOPCUA=5FGALAXY=5FCLIE?= =?UTF-8?q?NT=5FNAME=20env=20var=20(default=20"OtOpcUa-Galaxy.Host"=20per?= =?UTF-8?q?=20Program.cs).=20MxAccessGalaxyBackend=20constructor=20subscri?= =?UTF-8?q?bes=20the=20new=20=5FonConnectionStateChanged=20field=20before?= =?UTF-8?q?=20returning=20+=20Dispose=20unsubscribes=20it=20via=20=5Fmx.Co?= =?UTF-8?q?nnectionStateChanged=20-=3D=20=5FonConnectionStateChanged=20to?= =?UTF-8?q?=20prevent=20the=20backend's=20own=20dispose=20from=20leaving?= =?UTF-8?q?=20a=20dangling=20handler=20on=20the=20MxAccessClient=20(same?= =?UTF-8?q?=20shape=20as=20MxAccessClient.SubscriptionReplayFailed=20PR=20?= =?UTF-8?q?6=20dispose=20discipline).=20#pragma=20warning=20disable=20CS00?= =?UTF-8?q?67=20removed=20from=20around=20OnHostStatusChanged=20since=20th?= =?UTF-8?q?e=20event=20is=20now=20raised;=20the=20directive=20is=20narrowe?= =?UTF-8?q?d=20to=20cover=20only=20OnAlarmEvent=20which=20stays=20unraised?= =?UTF-8?q?=20pending=20the=20alarm=20subsystem=20port=20(PR=209=20candida?= =?UTF-8?q?te).=20Tests=20=E2=80=94=20HostStatusPushTests=20(new,=202=20ca?= =?UTF-8?q?ses):=20ConnectionStateChanged=5Fraises=5FOnHostStatusChanged?= =?UTF-8?q?=5Fwith=5Fgateway=5Fname=20fires=20mx.ConnectAsync=20=E2=86=92?= =?UTF-8?q?=20mx.DisconnectAsync=20and=20asserts=20two=20notifications=20i?= =?UTF-8?q?n=20order=20with=20HostName=3D"GatewayClient"=20(the=20clientNa?= =?UTF-8?q?me=20passed=20to=20MxAccessClient=20ctor),=20RuntimeStatus=3D"R?= =?UTF-8?q?unning"=20then=20"Stopped",=20LastObservedUtcUnixMs=20>=200;=20?= =?UTF-8?q?Dispose=5Funsubscribes=5Fso=5Fpost=5Fdispose=5Fstate=5Fchanges?= =?UTF-8?q?=5Fdo=5Fnot=5Ffire=5Fevents=20asserts=20that=20after=20backend.?= =?UTF-8?q?Dispose()=20a=20subsequent=20mx.DisconnectAsync=20does=20not=20?= =?UTF-8?q?bump=20the=20count=20on=20a=20registered=20OnHostStatusChanged?= =?UTF-8?q?=20handler=20=E2=80=94=20guards=20against=20the=20subscription-?= =?UTF-8?q?leak=20regression=20where=20a=20lingering=20backend=20instance?= =?UTF-8?q?=20would=20accumulate=20cross-reconnect=20notifications=20for?= =?UTF-8?q?=20a=20dead=20writer.=20Host.Tests=20csproj=20gains=20a=20Refer?= =?UTF-8?q?ence=20to=20lib/ArchestrA.MxAccess.dll=20(identical=20to=20the?= =?UTF-8?q?=20reference=20PR=206=20adds=20=E2=80=94=20conflict-free=20cher?= =?UTF-8?q?ry-pick/merge=20since=20both=20PRs=20stage=20the=20same=20=20node;=20git=20will=20collapse=20to=20one=20when=20eith?= =?UTF-8?q?er=20lands=20first).=20Full=20Galaxy.Host.Tests=20Unit=20suite:?= =?UTF-8?q?=2026=20pass=20/=200=20fail=20(2=20new=20host-status=20+=209=20?= =?UTF-8?q?PR5=20historian=20+=2015=20pre-existing=20PostMortemMmf/Recycle?= =?UTF-8?q?Policy/StaPump/MemoryWatchdog/EndToEndIpc/Handshake).=20Galaxy.?= =?UTF-8?q?Host=20builds=20clean=20(0=20errors,=200=20warnings).=20Branch?= =?UTF-8?q?=20base=20=E2=80=94=20PR=208=20is=20on=20phase-2-pr5-historian?= =?UTF-8?q?=20rather=20than=20phase-2-pr4-findings=20because=20the=20const?= =?UTF-8?q?ructor=20path=20on=20MxAccessGalaxyBackend=20gained=20a=20new?= =?UTF-8?q?=20historian=20parameter=20in=20PR=205=20and=20the=20Dispose=20?= =?UTF-8?q?implementation=20needs=20to=20coordinate=20the=20two=20unsubscr?= =?UTF-8?q?ibes;=20targeting=20the=20earlier=20base=20would=20leave=20a=20?= =?UTF-8?q?trivial=20conflict=20on=20Dispose.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/MxAccess/MxAccessClient.cs | 7 ++ .../Backend/MxAccessGalaxyBackend.cs | 28 +++++- .../HostStatusPushTests.cs | 91 +++++++++++++++++++ ...WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj | 5 + 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs index de38f37..69cae55 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs @@ -54,6 +54,13 @@ public sealed class MxAccessClient : IDisposable public int SubscriptionCount => _subscriptions.Count; public int ReconnectCount => _reconnectCount; + /// + /// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so + /// can tag its OnHostStatusChanged IPC + /// pushes with a stable gateway name per PR 8. + /// + public string ClientName => _clientName; + /// Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call. public async Task ConnectAsync() { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 7cd543a..a13307d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -34,16 +34,34 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable _refToSubs = new(System.StringComparer.OrdinalIgnoreCase); public event System.EventHandler? OnDataChange; -#pragma warning disable CS0067 // event not yet raised — alarm + host-status wire-up in PR #4 follow-up +#pragma warning disable CS0067 // alarm wire-up deferred to PR 9 public event System.EventHandler? OnAlarmEvent; - public event System.EventHandler? OnHostStatusChanged; #pragma warning restore CS0067 + public event System.EventHandler? OnHostStatusChanged; + + private readonly System.EventHandler _onConnectionStateChanged; public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null) { _repository = repository; _mx = mx; _historian = historian; + + // PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions + // connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named + // after the Wonderware client identity so the Admin UI surfaces top-level transport + // health even before per-platform/per-engine probing lands (deferred to a later PR that + // ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions). + _onConnectionStateChanged = (_, connected) => + { + OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus + { + HostName = _mx.ClientName, + RuntimeStatus = connected ? "Running" : "Stopped", + LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + }; + _mx.ConnectionStateChanged += _onConnectionStateChanged; } public async Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct) @@ -267,7 +285,11 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); - public void Dispose() => _historian?.Dispose(); + public void Dispose() + { + _mx.ConnectionStateChanged -= _onConnectionStateChanged; + _historian?.Dispose(); + } private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs new file mode 100644 index 0000000..f32f627 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using ArchestrA.MxAccess; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class HostStatusPushTests +{ + /// + /// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false, + /// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with + /// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp. + /// This is the gateway-level signal; per-platform ScanState probes are deferred. + /// + [Fact] + public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var proxy = new FakeProxy(); + var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false }); + using var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + historian: null); + + var notifications = new ConcurrentQueue(); + backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s); + + await mx.ConnectAsync(); + await mx.DisconnectAsync(); + + notifications.Count.ShouldBe(2); + notifications.TryDequeue(out var first).ShouldBeTrue(); + first!.HostName.ShouldBe("GatewayClient"); + first.RuntimeStatus.ShouldBe("Running"); + first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0); + + notifications.TryDequeue(out var second).ShouldBeTrue(); + second!.HostName.ShouldBe("GatewayClient"); + second.RuntimeStatus.ShouldBe("Stopped"); + } + + [Fact] + public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events() + { + using var pump = new StaPump("Test.Sta"); + await pump.WaitForStartedAsync(); + var proxy = new FakeProxy(); + var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false }); + var backend = new MxAccessGalaxyBackend( + new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), + mx, + historian: null); + + var count = 0; + backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count); + + await mx.ConnectAsync(); + count.ShouldBe(1); + + backend.Dispose(); + await mx.DisconnectAsync(); + + count.ShouldBe(1); // no second notification after Dispose + } + + private sealed class FakeProxy : IMxProxy + { + private int _next = 1; + public int Register(string _) => 42; + public void Unregister(int _) { } + public int AddItem(int _, string __) => Interlocked.Increment(ref _next); + public void RemoveItem(int _, int __) { } + public void AdviseSupervisory(int _, int __) { } + public void UnAdviseSupervisory(int _, int __) { } + public void Write(int _, int __, object ___, int ____) { } + public event MxDataChangeHandler? OnDataChange { add { } remove { } } + public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj index fd5d722..6f803d5 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj @@ -24,6 +24,11 @@ + + + ..\..\lib\ArchestrA.MxAccess.dll + -- 2.49.1