Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyLiveReopenAndWriteTests.cs
T
Joseph Doherty 20a816dbf4 feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker
53f69cd — all four unary write kinds), so every write reply's statuses[0] now
carries the real COM commit outcome. Live-gating the cutover on windev found
and fixed three defects:

- StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus
  detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a
  REFUSED write to Good. New invariant: a row that is not IsSuccess() never
  maps Good — details > 0xFF (and contradictory Good-band substatuses) route
  by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by
  StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes).

- The borrow-from-subscription-registry write optimization could never commit:
  a subscription handle is plain-advised, and MXAccess refuses both the
  no-login write through it (SECURITY_ERROR detail 1008, live-captured) and
  the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not
  per attribute. Removed: writer subscribedHandleSource ctor param + borrow
  branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle +
  its forward index and test file. The writer always AddItems its own
  supervisory-advised handle, which commits fine while the same attribute is
  subscribed (live-proven; pinned by the new write-while-subscribed smoke).

- (gateway-side, fixed there) the correlation initially covered WriteSecured
  only, while the dominant no-login path emits plain Write.

Live gate, all legs PASSED against windev: happy write Good off a confirmed
status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only
refusal surfaces Bad-family (the #5 node-revert input Galaxy could never
produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good
+ metered unconfirmed (override removed after, restoration re-verified). New
env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal,
Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow
smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are
process-wide).
2026-08-09 20:11:55 -04:00

311 lines
18 KiB
C#

using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Live-gateway smokes for the two seams that <em>cannot</em> be faked because the MXAccess
/// session types are sealed with internal ctors — so the unit suite can only exercise them in
/// isolation (see <see cref="GatewayGalaxyDataWriterTests"/>):
/// <list type="number">
/// <item>
/// <description>
/// <b>Reopen</b> — the exact primitive sequence <c>GalaxyDriver.ReopenAsync</c> runs on a
/// reconnect (<see cref="GalaxyMxSession.RecreateAsync"/> then
/// <see cref="GatewayGalaxyDataWriter.InvalidateHandleCaches"/>): a write seeds the
/// item-handle + supervisory-advise caches, the recreate tears the session down and
/// rebuilds it against the live gateway, the caches are dropped, and a follow-up write
/// re-AddItems + re-AdviseSupervisory against the fresh session and succeeds.
/// </description>
/// </item>
/// <item>
/// <description>
/// <b>Write commit + persist</b> — a no-login (<c>WriteUserId = 0</c>) write commits to
/// the galaxy only because the writer <c>AdviseSupervisory</c>'s the handle first; proven
/// by reading the value back through a <em>brand-new</em> session (so the value came from
/// the galaxy, not a client-side echo).
/// </description>
/// </item>
/// </list>
/// <para>
/// Skip-gated: runs only when <c>MXGW_ENDPOINT</c> + <c>GALAXY_MXGW_API_KEY</c> point at a
/// reachable gateway (so CI stays green). Captured 2026-06-14 against <c>10.100.0.48:5120</c>.
/// Writes only the dedicated test attribute <c>TestMachine_002.TestFloat</c>.
/// </para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class GatewayGalaxyLiveReopenAndWriteTests
{
/// <summary>The dedicated writable Galaxy test attribute (Float, Operate) used by both smokes.</summary>
private const string WriteRef = "TestMachine_002.TestFloat";
[Fact]
public async Task Live_reopen_recreates_session_and_re_establishes_write_handles()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaReopenSmoke"));
await session.ConnectAsync(clientOptions, ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
// First write seeds both caches (AddItem -> item handle, AdviseSupervisory -> supervised handle).
var first = await writer.WriteAsync(
[new WriteRequest(WriteRef, 4242.0f)], _ => SecurityClassification.FreeAccess, ct);
first.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the first live write should return Good (0)");
writer.CachedItemHandleCount.ShouldBe(1, "the write should have cached one item handle");
writer.CachedSupervisedHandleCount.ShouldBe(1, "the write should have supervisory-advised the handle once");
// Exactly what GalaxyDriver.ReopenAsync does on a reconnect: recreate the live session, drop the caches.
await session.RecreateAsync(clientOptions, ct);
writer.InvalidateHandleCaches();
writer.CachedItemHandleCount.ShouldBe(0, "reopen must drop the stale item-handle cache");
writer.CachedSupervisedHandleCount.ShouldBe(0, "reopen must drop the stale supervisory-advise cache");
// A write after the reopen must re-AddItem + re-AdviseSupervisory against the fresh session and succeed —
// proving RecreateAsync produced a working session and the writer rebuilt its handles.
var second = await writer.WriteAsync(
[new WriteRequest(WriteRef, 1313.0f)], _ => SecurityClassification.FreeAccess, ct);
second.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the post-reopen write should return Good (0)");
writer.CachedItemHandleCount.ShouldBe(1, "the post-reopen write should re-establish the item handle");
writer.CachedSupervisedHandleCount.ShouldBe(1, "the post-reopen write should re-advise the handle");
TestContext.Current.SendDiagnosticMessage(
$"reopen smoke: wrote {WriteRef}=4242 -> recreate+invalidate -> wrote 1313, both Good, handles re-established");
}
[Fact]
public async Task Live_supervisory_write_commits_and_persists_to_galaxy()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
// Baseline: read the current value through a fresh session so we can pick a target that
// genuinely CHANGES it (otherwise a no-op write could falsely "persist").
var current = await ReadbackFloatAsync(clientOptions, "OtOpcUaReadSmokeA", WriteRef,
TimeSpan.FromSeconds(20), ct);
var target = current is { } c && Math.Abs(c - 4242.0) < 1.0 ? 1313.0 : 4242.0;
// Write via the production writer: no-login write that only commits because the writer
// AdviseSupervisory's the handle first.
await using (var writerSession = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaWriteSmoke")))
{
await writerSession.ConnectAsync(clientOptions, ct);
var writer = new GatewayGalaxyDataWriter(writerSession, writeUserId: 0);
var results = await writer.WriteAsync(
[new WriteRequest(WriteRef, (float)target)], _ => SecurityClassification.FreeAccess, ct);
results.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the supervisory write should return Good (0)");
}
// Persist proof: a BRAND-NEW session must read the written value back from the galaxy.
// Poll (the worker's commit is async) — fail with the last observed value if it never lands.
double? persisted = null;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(25);
while (DateTime.UtcNow < deadline)
{
persisted = await ReadbackFloatAsync(clientOptions, "OtOpcUaReadSmokeB", WriteRef,
TimeSpan.FromSeconds(15), ct);
if (persisted is { } p && Math.Abs(p - target) < 0.5) break;
await Task.Delay(1000, ct);
}
persisted.ShouldNotBeNull("a fresh session should have read a value for the test attribute");
persisted!.Value.ShouldBe(target, 0.5,
$"the supervisory write must persist in the galaxy (fresh-session read-back; last observed {persisted})");
TestContext.Current.SendDiagnosticMessage(
$"write-persist smoke: {WriteRef} {current} -> wrote {target} -> fresh-session read-back {persisted}");
}
/// <summary>
/// <b>Write-while-subscribed</b> — the writer must commit a no-login supervisory write to a
/// tag that is ALREADY live-subscribed on the same session, via its own AddItem'd handle.
/// This replaces the retired borrow smoke: the borrow optimization's premise ("a subscription
/// handle is usable for a committing no-login write") was disproven by the correlated write
/// replies — a plain-advised subscription handle refuses the write (SECURITY_ERROR detail
/// 1008) and refuses an advise upgrade (E_INVALIDARG). What this pins instead is the fix's
/// premise: MXAccess's one-advise-per-handle limit is per HANDLE, not per attribute, so the
/// writer's own supervisory-advised handle commits while the subscription keeps its own.
/// </summary>
[Fact]
public async Task Live_write_to_subscribed_tag_commits_via_own_handle()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaSubWriteSmoke"));
await session.ConnectAsync(clientOptions, ct);
// Live subscription on the tag — the state the borrow path used to piggyback on.
var subscriber = new GatewayGalaxySubscriber(session);
var subResults = await subscriber.SubscribeBulkAsync([WriteRef], bufferedUpdateIntervalMs: 0, ct);
var match = subResults.SingleOrDefault(r =>
string.Equals(r.TagAddress, WriteRef, StringComparison.OrdinalIgnoreCase) && r.WasSuccessful);
match.ShouldNotBeNull($"the gateway should accept a subscription on {WriteRef}");
// The production writer must AddItem its own handle and commit, despite the live subscription.
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
var result = await writer.WriteAsync(
[new WriteRequest(WriteRef, 2727.0f)], _ => SecurityClassification.FreeAccess, ct);
result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
"a write to an already-subscribed tag must commit Good via the writer's own handle");
writer.AddItemCallCount.ShouldBe(1, "the writer owns its handle — exactly one AddItem");
writer.CachedSupervisedHandleCount.ShouldBe(1, "the writer's own handle should be supervisory-advised");
TestContext.Current.SendDiagnosticMessage(
$"write-while-subscribed smoke: subscribed {WriteRef} (handle {match!.ItemHandle}); own-handle write Good, AddItemCallCount=1");
}
/// <summary>
/// archreview 06/S-1 guard — after the fail-closed advise change, a NORMAL advised no-login write
/// must STILL return Good (0). Guards against over-eager fail-closing: the advise round-trip succeeds
/// on a healthy gateway, so the raw Write proceeds and commits. If this ever returns Bad on a healthy
/// gateway, the fail-closed branch is mis-judging a successful advise. Skip-gated like the others.
/// </summary>
[Fact]
public async Task Live_normal_advised_write_still_returns_good_under_failclosed_advise()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaFailClosedGuard"));
await session.ConnectAsync(clientOptions, ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
var result = await writer.WriteAsync(
[new WriteRequest(WriteRef, 5151.0f)], _ => SecurityClassification.FreeAccess, ct);
result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
"a normal advised write must still return Good after the fail-closed advise change (no over-eager fail-close)");
writer.CachedSupervisedHandleCount.ShouldBe(1, "the healthy advise should have supervised the handle");
TestContext.Current.SendDiagnosticMessage(
$"fail-closed guard smoke: normal advised write {WriteRef}=5151 returned Good; handle supervised");
}
/// <summary>
/// 06/S-1 confirmation smoke — the gateway (mxaccessgw ≥ <c>53f69cd</c>) correlates the worker's
/// <c>OnWriteComplete</c> onto the unary write reply as <c>statuses[0]</c>, so the production
/// writer's verdict is now the real COM commit outcome, not worker-side acceptance:
/// <list type="bullet">
/// <item><description>a committed write returns Good off a CONFIRMED status row
/// (<c>success:-1, category:OK</c>) — pre-correlation this was a provisional Good off an
/// empty statuses array;</description></item>
/// <item><description>a REFUSED write (read-only attribute) returns a Bad-family code off
/// the correlated failure row (live shape: <c>category:OPERATIONAL_ERROR, detail:1007</c>)
/// — the capability that never existed before: pre-correlation the refusal was invisible
/// and reported Good, so the #5 write-outcome self-correction never fired for Galaxy.
/// </description></item>
/// </list>
/// Requires both deployed gateway halves at the correlation build (server ≥ b948e69, worker ≥
/// 53f69cd); against an older gateway the refusal leg fails — that is the point of the gate.
/// </summary>
[Fact]
public async Task Live_correlated_write_statuses_confirm_commit_and_surface_refusal()
{
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
var ct = TestContext.Current.CancellationToken;
var clientOptions = BuildClientOptions(endpoint, apiKey);
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Smoke"));
await session.ConnectAsync(clientOptions, ct);
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
// Happy path: the confirmed commit comes back Good (statuses[0] success row, not empty-array
// provisional). The commit itself is proven by Live_supervisory_write_commits_and_persists_to_galaxy.
var good = await writer.WriteAsync(
[new WriteRequest(WriteRef, 6262.0f)], _ => SecurityClassification.FreeAccess, ct);
good.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
"a committed write should return Good off the correlated status row");
// Refusal path: Tagname is read-only, so the automation object refuses the write and the
// correlated failure row must surface as a Bad-family code (top two bits 10b). Pre-correlation
// this returned Good. Assert the family, not the exact code — the detail→code mapping may
// gain per-code specificity (StatusCodeMap.FromCategory) without invalidating the gate.
var refused = await writer.WriteAsync(
[new WriteRequest("TestMachine_002.Tagname", "refused-write-probe")],
_ => SecurityClassification.FreeAccess, ct);
var refusedCode = refused.ShouldHaveSingleItem().StatusCode;
((refusedCode >> 30) & 0x3u).ShouldBe(2u,
$"a refused write must surface a Bad-family code off the correlated failure row (got 0x{refusedCode:X8})");
TestContext.Current.SendDiagnosticMessage(
$"06/S-1 smoke: {WriteRef}=6262 -> Good confirmed; Tagname write refused with 0x{refusedCode:X8}");
}
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
{
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live gateway reopen/write smokes.");
return (endpoint!, apiKey!);
}
private static MxGatewayClientOptions BuildClientOptions(string endpoint, string apiKey) => new()
{
Endpoint = new Uri(endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
DefaultCallTimeout = TimeSpan.FromSeconds(30),
StreamTimeout = TimeSpan.FromSeconds(30),
};
/// <summary>
/// Open a fresh session, subscribe to a single reference, and return the first decoded
/// data-change value (the subscribe snapshot reflects the galaxy's current value), or null
/// on timeout. A fresh session per call guarantees the value came from the galaxy.
/// </summary>
private static async Task<double?> ReadbackFloatAsync(
MxGatewayClientOptions clientOptions, string clientName, string fullRef, TimeSpan timeout, CancellationToken ct)
{
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: clientName));
await session.ConnectAsync(clientOptions, ct);
var subscriber = new GatewayGalaxySubscriber(session);
// First decoded data-change value, completed by the pump (an explicit TCS rather than a
// captured local, so the value's visibility across the Task boundary is unambiguous).
var firstValue = new TaskCompletionSource<double?>(TaskCreationOptions.RunContinuationsAsynchronously);
using var streamCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
streamCts.CancelAfter(timeout);
var pump = Task.Run(async () =>
{
try
{
await foreach (var ev in subscriber.StreamEventsAsync(streamCts.Token))
{
// SubscribeBulk uses Advise (not AdviseBuffered), so the initial galaxy-value
// snapshot arrives as OnDataChange — never OnBufferedDataChange.
if (ev.Family != MxEventFamily.OnDataChange) continue;
if (MxValueDecoder.Decode(ev.Value) is not { } decoded) continue;
firstValue.TrySetResult(Convert.ToDouble(decoded));
return;
}
}
catch (OperationCanceledException) { /* timeout, or stream ended */ }
firstValue.TrySetResult(null); // timed out / stream completed without a data event
}, ct);
// Let the StreamEvents RPC attach before adding the subscription so the initial snapshot isn't
// missed. bufferedUpdateIntervalMs:0 skips the superfluous SetBufferedUpdateInterval RPC — the
// Advise-based snapshot is delivered regardless.
await Task.Delay(250, ct);
await subscriber.SubscribeBulkAsync([fullRef], bufferedUpdateIntervalMs: 0, ct);
var result = await firstValue.Task;
streamCts.Cancel(); // stop the pump's StreamEvents enumeration
await pump; // observe any pump exception + ensure teardown before the session disposes
return result;
}
}