fix(galaxy): confirm unconfirmed native alarm acks against Galaxy's acknowledged transition (#869)

mxaccessgw answers AcknowledgeAlarm OK both when the AckMsg write was
applied and when it was only sent (no OnWriteComplete; the reply then
carries a diagnostic). EvaluateReply treated both as success, so the H6
live gate saw a condition go Acknowledged while Galaxy never applied it.

- EvaluateReply returns Applied (no diagnostic) or Unconfirmed (diagnostic)
  and logs the gateway diagnostic on every branch that carries one.
- GalaxyAckConfirmationTracker watches the alarm feed from before the RPC
  and, for an unconfirmed reply, waits up to AlarmAck.ConfirmTimeoutMs
  (default 10 s, 0 = old behaviour) for an Acknowledge/ActiveAcknowledged
  transition; on timeout it refuses (BadTimeout + diagnostic).
- New optional IAlarmAcknowledgeConfirmation: DriverInstanceActor awaits it
  after AcknowledgeAsync, off its mailbox and outside the resilience
  pipeline; a failure is the existing #760 AlarmAckFailed revert.
- AdminUI Galaxy form carries the new knob; docs updated.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 13:03:04 -05:00
parent e857135e8b
commit 4e492e6550
19 changed files with 1046 additions and 34 deletions
@@ -79,6 +79,28 @@ public sealed record GalaxyAlarmAckOptions(
string? FallbackDomain = null,
string? FallbackPasswordSecretRef = null)
{
/// <summary>Default <see cref="ConfirmTimeoutMs"/>.</summary>
public const int DefaultConfirmTimeoutMs = 10_000;
/// <summary>Upper bound for <see cref="ConfirmTimeoutMs"/>.</summary>
public const int MaxConfirmTimeoutMs = 120_000;
/// <summary>
/// How long an acknowledge the gateway SENT but could not confirm may wait for Galaxy's own acknowledged
/// transition on the alarm feed before it is treated as failed and the OPC UA condition is reverted to
/// unacknowledged (lmxopcua#869). Default 10 000 ms; <c>0</c> disables confirmation (an unconfirmed ack
/// counts as success, with a Warning — the pre-#869 behaviour); a negative value means the default, and
/// anything above 120 000 is clamped to it.
/// </summary>
[Display(Name = "Ack confirm timeout (ms)", Description = "Wait for Galaxy to confirm an unconfirmed acknowledge. 0 = do not wait.", GroupName = "Alarms")]
[Range(0, MaxConfirmTimeoutMs)]
public int ConfirmTimeoutMs { get; init; } = DefaultConfirmTimeoutMs;
/// <summary>The confirmation window: negative ⇒ the default, else clamped to <see cref="MaxConfirmTimeoutMs"/>.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public TimeSpan ConfirmTimeout => TimeSpan.FromMilliseconds(
ConfirmTimeoutMs < 0 ? DefaultConfirmTimeoutMs : Math.Min(ConfirmTimeoutMs, MaxConfirmTimeoutMs));
/// <summary>True when a fallback account is configured.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackUser);
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// </para>
/// </remarks>
public sealed class GalaxyDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, IAlarmAcknowledgeConfirmation, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
{
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815
// out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the
@@ -138,6 +138,15 @@ public sealed class GalaxyDriver
// lmxopcua#765: the cached key → has-Galaxy-identity set the synchronous ack pre-check reads (no I/O).
// Null = no directory wired, so a keyed session is never pre-refused (the pre-#765 behaviour).
private readonly IGalaxyAckIdentityDirectory? _ackIdentityDirectory;
// lmxopcua#869: confirms acks the gateway SENT but could not confirm, against Galaxy's own acknowledged
// transition on the alarm feed (bounded by AlarmAck.ConfirmTimeoutMs). Built in the ctor.
private readonly GalaxyAckConfirmationTracker _ackConfirmations;
/// <summary>The ack confirmation window in force (lmxopcua#869) — tests.</summary>
internal TimeSpan AckConfirmationWindow => _ackConfirmations.Window;
/// <summary>Acks still awaiting (or exempt from) confirmation (lmxopcua#869) — tests.</summary>
internal int PendingAckConfirmations => _ackConfirmations.PendingCount;
private IGalaxyAlarmFeed? _alarmFeed;
private readonly Lock _alarmHandlersLock = new();
private readonly Lock _alarmFeedLock = new();
@@ -377,6 +386,8 @@ public sealed class GalaxyDriver
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
_ackConfirmations = new GalaxyAckConfirmationTracker(
_options.AlarmAck?.ConfirmTimeout ?? GalaxyAckConfirmationTracker.DefaultWindow, _logger, _driverInstanceId);
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
@@ -2340,11 +2351,40 @@ public sealed class GalaxyDriver
_driverInstanceId, alarmFullReference, credential.DisplayName, credential.Source,
PrincipalOf(ack), ack.ApiKeyId ?? "none");
await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference, comment, credential, cancellationToken).ConfigureAwait(false);
// lmxopcua#869: the gateway's OK can mean "sent but unconfirmed". Watch for Galaxy's own acknowledged
// transition from BEFORE the RPC — the feed can deliver it ahead of the unary reply — and arm the
// watch when the reply is unconfirmed; ConfirmAcknowledgedAsync then awaits it, bounded.
var watch = _ackConfirmations.Begin(ack, alarmFullReference);
var armed = false;
try
{
var outcome = await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference, comment, credential, cancellationToken).ConfigureAwait(false);
if (!outcome.IsApplied)
{
_ackConfirmations.Arm(watch, outcome.Diagnostic);
armed = true;
}
}
finally
{
if (!armed) _ackConfirmations.Release(watch);
}
}
}
/// <inheritdoc />
/// <remarks>
/// lmxopcua#869: completes at once for an ack the gateway reported applied; for one it only SENT, waits up to
/// <c>AlarmAck.ConfirmTimeoutMs</c> (default 10 s) for Galaxy's acknowledged transition on the alarm feed and
/// throws <see cref="DriverOperationRefusedException"/> when it does not come — the host then reverts the
/// condition to unacknowledged. The feed must be running (it is whenever the host holds native conditions,
/// the only way an ack reaches this driver); without it nothing can confirm and the ack reverts.
/// </remarks>
public Task ConfirmAcknowledgedAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_ackConfirmations.ConfirmAsync(acknowledgements, cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Only cases decidable without I/O, on a driver with no <c>AlarmAck.FallbackUser</c>: an anonymous session
@@ -2404,6 +2444,10 @@ public sealed class GalaxyDriver
/// </summary>
private void OnAlarmFeedTransition(object? sender, GalaxyAlarmTransition transition)
{
// lmxopcua#869: an acknowledged transition confirms any ack the gateway sent unconfirmed — before the
// subscription gate below, which only governs fan-out to OnAlarmEvent.
_ackConfirmations.Observe(transition);
GalaxyAlarmSubscriptionHandle? handle;
int subCount;
lock (_alarmHandlersLock)
@@ -114,7 +114,10 @@ public static class GalaxyDriverFactoryExtensions
AlarmAck = new GalaxyAlarmAckOptions(
FallbackUser: NullIfBlank(dto.AlarmAck?.FallbackUser),
FallbackDomain: NullIfBlank(dto.AlarmAck?.FallbackDomain),
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef)),
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef))
{
ConfirmTimeoutMs = dto.AlarmAck?.ConfirmTimeoutMs ?? GalaxyAlarmAckOptions.DefaultConfirmTimeoutMs,
},
};
return new GalaxyDriver(
@@ -160,6 +163,8 @@ public static class GalaxyDriverFactoryExtensions
public string? FallbackDomain { get; init; }
/// <summary>Gets or sets the fallback password reference (secret:/env:/file:/dev:).</summary>
public string? FallbackPasswordSecretRef { get; init; }
/// <summary>Gets or sets how long an unconfirmed ack waits for Galaxy to confirm it (lmxopcua#869); null = default.</summary>
public int? ConfirmTimeoutMs { get; init; }
}
/// <summary>Gateway configuration section.</summary>
@@ -0,0 +1,249 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Confirms Galaxy acknowledges the gateway sent but could not confirm (lmxopcua#869) against Galaxy's own
/// acknowledged transition on the session-less alarm feed.
/// </summary>
/// <remarks>
/// <para>
/// Lifecycle of one acknowledge: <see cref="Begin"/> registers a watch BEFORE the gateway RPC is sent, so a
/// confirming transition that overtakes the RPC reply is not lost (the feed and the unary reply race). The
/// RPC outcome then either releases the watch (<see cref="Release"/>: applied, or the RPC failed) or arms it
/// (<see cref="Arm"/>: sent but unconfirmed — the confirmation window starts). <see cref="ConfirmAsync"/>
/// awaits the armed watches of the same request instances, outside the resilience pipeline and off the
/// driver actor's mailbox (see <see cref="IAlarmAcknowledgeConfirmation"/>), and throws a
/// <see cref="DriverOperationRefusedException"/> when the window passes without confirmation — the host
/// then reverts the condition to unacknowledged.
/// </para>
/// <para>
/// A confirmation is an <see cref="GalaxyAlarmTransitionKind.Acknowledge"/> or
/// <see cref="GalaxyAlarmTransitionKind.ActiveAcknowledged"/> transition (live or snapshot re-statement) for
/// the same alarm reference — whoever acknowledged it, Galaxy now shows it acknowledged, which is the
/// truth the OPC UA condition must match. Galaxy references are case-insensitive, so the match is too.
/// </para>
/// <para>
/// A watch whose <see cref="ConfirmAsync"/> never comes (an <see cref="IAlarmSource.AcknowledgeAsync"/>
/// caller that does not confirm) is pruned once it is well past its window, so the set stays bounded.
/// A zero window disables confirmation: an unconfirmed ack is counted as success with a Warning — the
/// pre-#869 behaviour, kept only as an operator escape hatch.
/// </para>
/// </remarks>
internal sealed class GalaxyAckConfirmationTracker
{
/// <summary>Default confirmation window — long enough for a gateway alarm-feed round trip, short enough
/// that an operator sees the revert while still looking at the alarm.</summary>
internal static readonly TimeSpan DefaultWindow = TimeSpan.FromSeconds(10);
/// <summary>How long past its window an armed watch nobody confirms is kept before it is pruned.</summary>
private static readonly TimeSpan AbandonedGrace = TimeSpan.FromMinutes(1);
private readonly TimeSpan _window;
private readonly ILogger _logger;
private readonly string _driverInstanceId;
private readonly Lock _lock = new();
private readonly List<Watch> _watches = [];
/// <summary>Initializes a new instance of the <see cref="GalaxyAckConfirmationTracker"/> class.</summary>
/// <param name="window">How long an unconfirmed ack may wait for Galaxy's transition; zero disables confirmation.</param>
/// <param name="logger">Logger for confirmation outcomes.</param>
/// <param name="driverInstanceId">The owning driver, for log context.</param>
public GalaxyAckConfirmationTracker(TimeSpan window, ILogger logger, string driverInstanceId)
{
if (window < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(window), window, "must not be negative");
_window = window;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_driverInstanceId = driverInstanceId ?? string.Empty;
}
/// <summary>The confirmation window (zero = disabled).</summary>
public TimeSpan Window => _window;
/// <summary>Number of live watches (tests / diagnostics).</summary>
internal int PendingCount
{
get { lock (_lock) return _watches.Count; }
}
/// <summary>Registers a watch for <paramref name="alarmFullReference"/> before its acknowledge is sent.</summary>
/// <param name="request">The request instance the caller will later pass to <see cref="ConfirmAsync"/>.</param>
/// <param name="alarmFullReference">The Galaxy alarm reference the ack is sent for.</param>
/// <returns>The watch, to be <see cref="Release"/>d or <see cref="Arm"/>ed once the RPC answers.</returns>
public Watch Begin(AlarmAcknowledgeRequest request, string alarmFullReference)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
var watch = new Watch(request, alarmFullReference);
lock (_lock)
{
PruneAbandoned();
_watches.Add(watch);
}
return watch;
}
/// <summary>Drops a watch whose acknowledge needs no confirmation (applied) or was never delivered (failed).</summary>
/// <param name="watch">The watch.</param>
public void Release(Watch watch)
{
lock (_lock) _watches.Remove(watch);
}
/// <summary>
/// Starts the confirmation window for an acknowledge the gateway sent but could not confirm. A watch the feed
/// already confirmed while the RPC was in flight is released at once; with confirmation disabled the ack is
/// counted as success (logged).
/// </summary>
/// <param name="watch">The watch.</param>
/// <param name="diagnostic">The gateway's diagnostic, carried into the timeout report.</param>
public void Arm(Watch watch, string diagnostic)
{
watch.Diagnostic = diagnostic ?? string.Empty;
if (_window == TimeSpan.Zero)
{
Release(watch);
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} was not confirmed by the gateway ({Diagnostic}) "
+ "and ack confirmation is disabled (AlarmAck.ConfirmTimeoutMs = 0); counting it as acknowledged.",
_driverInstanceId, watch.AlarmFullReference, watch.Diagnostic);
return;
}
if (watch.IsConfirmed)
{
Release(watch);
LogConfirmed(watch);
return;
}
lock (_lock) watch.ArmedTimestamp = Stopwatch.GetTimestamp();
}
/// <summary>
/// Feed hook: confirms every watch for the transition's alarm when the transition says Galaxy shows it
/// acknowledged. Runs on the feed's dispatch thread — never blocks.
/// </summary>
/// <param name="transition">The alarm transition.</param>
public void Observe(GalaxyAlarmTransition transition)
{
if (transition.TransitionKind is not (GalaxyAlarmTransitionKind.Acknowledge or GalaxyAlarmTransitionKind.ActiveAcknowledged))
return;
if (string.IsNullOrEmpty(transition.AlarmFullReference)) return;
lock (_lock)
{
foreach (var watch in _watches)
{
if (string.Equals(watch.AlarmFullReference, transition.AlarmFullReference, StringComparison.OrdinalIgnoreCase))
watch.Confirm();
}
}
}
/// <summary>
/// Awaits confirmation of every armed watch belonging to <paramref name="requests"/> (matched by instance).
/// Requests with no armed watch (applied, or never sent) need nothing. Throws
/// <see cref="DriverOperationRefusedException"/> when any is not confirmed inside the window.
/// </summary>
/// <param name="requests">The request instances the acknowledge was sent with.</param>
/// <param name="cancellationToken">Abandons the wait.</param>
/// <returns>A task that completes when every armed watch is confirmed.</returns>
public async Task ConfirmAsync(IReadOnlyList<AlarmAcknowledgeRequest> requests, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(requests);
List<Watch> mine;
lock (_lock)
{
mine = _watches.Where(w => w.ArmedTimestamp != 0 && requests.Any(r => ReferenceEquals(r, w.Request))).ToList();
}
if (mine.Count == 0) return;
var failures = new List<string>();
try
{
foreach (var watch in mine)
{
var remaining = _window - Stopwatch.GetElapsedTime(watch.ArmedTimestamp);
try
{
if (!watch.IsConfirmed)
{
if (remaining <= TimeSpan.Zero) throw new TimeoutException();
await watch.Confirmed.WaitAsync(remaining, cancellationToken).ConfigureAwait(false);
}
LogConfirmed(watch);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} was NOT confirmed — Galaxy reported no "
+ "acknowledged transition within {Window} after the gateway sent it unconfirmed (gateway diagnostic: "
+ "{Diagnostic}). Treating it as failed (BadTimeout); the OPC UA condition is reverted to unacknowledged.",
_driverInstanceId, watch.AlarmFullReference, _window, watch.Diagnostic);
failures.Add(
$"Galaxy did not confirm the acknowledge of '{watch.AlarmFullReference}' within "
+ $"{_window.TotalSeconds:0.#} s (BadTimeout; gateway: {watch.Diagnostic})");
}
}
}
finally
{
lock (_lock)
{
foreach (var watch in mine) _watches.Remove(watch);
}
}
if (failures.Count > 0)
throw new DriverOperationRefusedException(string.Join("; ", failures));
}
private void LogConfirmed(Watch watch) =>
_logger.LogInformation(
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} confirmed by Galaxy's acknowledged transition "
+ "(the gateway had reported it sent but unconfirmed: {Diagnostic}).",
_driverInstanceId, watch.AlarmFullReference, watch.Diagnostic);
// Caller holds _lock.
private void PruneAbandoned()
{
var limit = _window + AbandonedGrace;
_watches.RemoveAll(w => w.ArmedTimestamp != 0 && Stopwatch.GetElapsedTime(w.ArmedTimestamp) > limit);
}
/// <summary>One acknowledge awaiting (or exempt from) confirmation.</summary>
internal sealed class Watch
{
private readonly TaskCompletionSource _confirmed = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal Watch(AlarmAcknowledgeRequest request, string alarmFullReference)
{
Request = request;
AlarmFullReference = alarmFullReference;
}
/// <summary>The request instance this watch belongs to.</summary>
public AlarmAcknowledgeRequest Request { get; }
/// <summary>The Galaxy alarm reference.</summary>
public string AlarmFullReference { get; }
/// <summary>The gateway's diagnostic for the unconfirmed send.</summary>
public string Diagnostic { get; set; } = string.Empty;
/// <summary><see cref="Stopwatch"/> timestamp the window started at; 0 while not armed.</summary>
public long ArmedTimestamp { get; set; }
/// <summary>Completes when Galaxy reports the alarm acknowledged.</summary>
public Task Confirmed => _confirmed.Task;
/// <summary>True once Galaxy reported the alarm acknowledged.</summary>
public bool IsConfirmed => _confirmed.Task.IsCompleted;
/// <summary>Marks the watch confirmed (idempotent).</summary>
public void Confirm() => _confirmed.TrySetResult();
}
}
@@ -34,6 +34,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// gateway answered — so it neither retries nor counts toward the resilience breaker (a mis-keyed
/// account must not open the breaker for every correctly-configured caller).
/// </para>
/// <para>
/// <b>An OK reply is not proof the ack was applied (lmxopcua#869).</b> The gateway answers OK with
/// <c>hresult</c> 0 both when the <c>AckMsg</c> write completed and when it was only SENT — its worker saw no
/// <c>OnWriteComplete</c> inside its window — and in the second case it sets <c>diagnostic_message</c>
/// ("AckMsg write to '…' sent; OnWriteComplete not observed within N ms."). An applied ack carries an empty
/// diagnostic. So an OK reply with a non-empty diagnostic is returned as
/// <see cref="GalaxyAckOutcome.Unconfirmed"/> and the driver waits for Galaxy's own acknowledged transition
/// (<see cref="GalaxyAckConfirmationTracker"/>). Any unexplained diagnostic on an OK reply is treated the
/// same way — the safe direction: at worst an applied ack waits for its (arriving) transition. The H6 live
/// gate (2026-11-11) caught an unconfirmed ack being reported Acknowledged while Galaxy never applied it.
/// The diagnostic is logged on every branch that carries one.
/// </para>
/// </remarks>
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
{
@@ -53,7 +65,7 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
}
/// <inheritdoc />
public async Task AcknowledgeAsync(
public async Task<GalaxyAckOutcome> AcknowledgeAsync(
string alarmFullReference,
string comment,
GalaxyAckCredential credential,
@@ -77,7 +89,7 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
+ "details withheld because they echoed the operator credential.");
}
EvaluateReply(reply, alarmFullReference, credential, _logger);
return EvaluateReply(reply, alarmFullReference, credential, _logger);
}
/// <summary>Maps an acknowledge onto the gateway request. Pure — pinned by unit tests.</summary>
@@ -104,23 +116,32 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
/// <summary>
/// Applies the reply semantics: non-OK protocol status throws; a non-zero native code warns and throws a
/// <see cref="DriverOperationRefusedException"/> (lmxopcua#760).
/// Every message is scrubbed of the password.
/// <see cref="DriverOperationRefusedException"/> (lmxopcua#760); an OK reply is
/// <see cref="GalaxyAckOutcome.Applied"/> when it carries no diagnostic and
/// <see cref="GalaxyAckOutcome.Unconfirmed"/> when it does (lmxopcua#869). The gateway's diagnostic is
/// logged on every branch that carries one. Every message is scrubbed of the password.
/// </summary>
/// <param name="reply">The gateway reply.</param>
/// <param name="alarmFullReference">The alarm reference.</param>
/// <param name="credential">The credential the request carried.</param>
/// <param name="logger">The logger.</param>
internal static void EvaluateReply(
/// <returns>Whether the gateway confirmed the ack was applied.</returns>
internal static GalaxyAckOutcome EvaluateReply(
AcknowledgeAlarmReply reply, string alarmFullReference, GalaxyAckCredential credential, ILogger logger)
{
var diagnostic = Scrub(reply.DiagnosticMessage, credential.Password).Trim();
// Protocol status — the gateway failed before MXAccess saw the ack. This is a
// hard failure: the operator's request was not delivered at all.
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
{
var protoMessage = Scrub(proto.Message, credential.Password);
logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} failed at the gateway: {ProtocolCode} {ProtocolMessage} (gateway diagnostic: {Diagnostic})",
alarmFullReference, credential.DisplayName, proto.Code, protoMessage, DiagnosticText(diagnostic));
throw new InvalidOperationException(
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' as '{credential.DisplayName}' failed at the gateway: "
+ $"{proto.Code} {Scrub(proto.Message, credential.Password)}");
+ $"{proto.Code} {protoMessage}");
}
// hresult is the authoritative native ack return code (0 = success) — the AuthenticateUser HRESULT
@@ -129,14 +150,29 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
if (reply.HasHresult && reply.Hresult != 0)
{
logger.LogWarning(
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} returned native ack failure code {Hresult}.",
alarmFullReference, credential.DisplayName, reply.Hresult);
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} returned native ack failure code {Hresult} (gateway diagnostic: {Diagnostic}).",
alarmFullReference, credential.DisplayName, reply.Hresult, DiagnosticText(diagnostic));
throw new DriverOperationRefusedException(
$"MXAccess rejected the Galaxy acknowledge of '{alarmFullReference}' as '{credential.DisplayName}' "
+ $"(native code 0x{reply.Hresult:X8}); check the account's password and Galaxy acknowledge permission.");
}
// lmxopcua#869: OK + hresult 0 means "applied OR sent-but-unconfirmed"; only the unconfirmed case carries a
// diagnostic. Not a failure yet — the driver waits for Galaxy's own acknowledged transition.
if (diagnostic.Length > 0)
{
logger.LogInformation(
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} was sent but not confirmed by the gateway "
+ "(gateway diagnostic: {Diagnostic}); awaiting Galaxy's acknowledged transition.",
alarmFullReference, credential.DisplayName, diagnostic);
return GalaxyAckOutcome.Unconfirmed(diagnostic);
}
return GalaxyAckOutcome.Applied;
}
private static string DiagnosticText(string diagnostic) => diagnostic.Length > 0 ? diagnostic : "none";
/// <summary>Replaces every occurrence of <paramref name="secret"/> in <paramref name="text"/>.</summary>
/// <param name="text">Text that may echo the secret.</param>
/// <param name="secret">The secret; empty means nothing to scrub.</param>
@@ -22,10 +22,32 @@ internal interface IGalaxyAlarmAcknowledger
/// Carries a password — implementations must keep it out of logs and exception text.
/// </param>
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AcknowledgeAsync(
/// <returns>
/// Whether the gateway reported the ack APPLIED or only SENT without confirmation (lmxopcua#869). A refusal
/// or failure throws instead.
/// </returns>
Task<GalaxyAckOutcome> AcknowledgeAsync(
string alarmFullReference,
string comment,
GalaxyAckCredential credential,
CancellationToken cancellationToken);
}
/// <summary>
/// What the gateway said about an acknowledge it accepted (lmxopcua#869). mxaccessgw answers OK both when the
/// <c>AckMsg</c> write's <c>OnWriteComplete</c> reported success (<see cref="Applied"/>) and when the write was
/// sent but no completion arrived inside the worker's window (<see cref="Unconfirmed"/>) — its contract tells
/// clients to watch the alarm stream for the real acknowledged transition in that case.
/// </summary>
/// <param name="IsApplied">True when the gateway confirmed the ack write was applied.</param>
/// <param name="Diagnostic">The gateway's diagnostic message (password-scrubbed); empty when it sent none.</param>
internal sealed record GalaxyAckOutcome(bool IsApplied, string Diagnostic)
{
/// <summary>The gateway confirmed the ack write.</summary>
public static GalaxyAckOutcome Applied { get; } = new(true, string.Empty);
/// <summary>The gateway sent the ack write but could not confirm it.</summary>
/// <param name="diagnostic">The gateway's diagnostic message.</param>
/// <returns>The outcome.</returns>
public static GalaxyAckOutcome Unconfirmed(string diagnostic) => new(false, diagnostic ?? string.Empty);
}