diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md
index 411a500..451c1d5 100644
--- a/docs/AlarmClientDiscovery.md
+++ b/docs/AlarmClientDiscovery.md
@@ -835,7 +835,7 @@ carries `degraded = true`.
### Watch-list discovery
`GatewayAlarmMonitor` resolves the subtag watch-list at subscribe time by
-calling `IAlarmWatchListResolver.GetAlarmAttributesAsync`. The resolver merges:
+calling `IAlarmWatchListResolver.ResolveAsync`. The resolver merges:
1. Galaxy Repository SQL (`GetAlarmAttributesAsync`) — objects that have alarm
extensions in the configured area.
@@ -847,8 +847,42 @@ The resolved list is a set of `AlarmSubtagTarget` messages sent to the worker
inside `SubscribeAlarmsCommand.watch_list`. Each target carries the composed
MXAccess item addresses for the `InAlarm`, `Acked`, `AckMsg`, and `Priority`
subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live
-ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its
-reconcile cadence and pushes an updated watch-list when the model changes.
+ZB Galaxy `attribute_definition` rows).
+
+**The list is fixed for the life of the subscription.** The worker builds its
+subtag consumer from the targets `SubscribeAlarms` carried and keeps them until
+the subscription ends; a second `SubscribeAlarms` on a subscribed session is
+rejected, and the only way to replace the list — `UnsubscribeAlarms` then
+`SubscribeAlarms` — rebuilds `FailoverAlarmConsumer`, restarting it on the
+primary and discarding the warm standby state. Re-sending on a failover would
+therefore flap the feed rather than repair it. A monitor restart (a worker fault,
+or a gateway restart) re-resolves and re-sends, and that is the supported way to
+pick up Galaxy objects added since the monitor started.
+
+Because the list is fixed, the resolution is **graded** rather than merely
+logged (GWC-37). `ResolveAsync` returns an `AlarmWatchListResolution` carrying
+whether Galaxy Repository discovery was attempted and whether it failed, and the
+monitor folds the verdict into the alarm feed's `AlarmProviderStatus`:
+
+- Zero targets while the effective mode can need them (`Auto` or `ForceSubtag`)
+ sets `degraded = true` with reason `AlarmProviderReasons.SubtagWatchListEmpty`
+ — the subtag provider covers no alarms.
+- Discovery enabled but failing adds
+ `AlarmProviderReasons.SubtagDiscoveryFailed` and the failure message, so a
+ Galaxy Repository outage at startup is a standing status rather than one log
+ line emitted possibly weeks before the failover that needs the list.
+- On the failover edge (`ApplyProviderModeChangeAsync` with `toMode == Subtag`)
+ the monitor re-resolves and compares against what it sent. A difference is
+ reported as `AlarmProviderReasons.SubtagWatchListStale` with both counts, on
+ the same status frame as the failover, because it cannot be applied.
+
+`degraded` on the status is therefore the OR of "the subtag provider is the one
+serving" and "the subtag watch-list cannot cover anything". In `Auto` the second
+can be true while the mode is still `ALARMMGR`: the live feed is healthy and the
+standby behind it is empty. The dashboard renders that combination as the amber
+`Alarm Manager (no subtag standby)` badge rather than the failover label, so the
+badge never names the wrong live provider. `ForceAlarmManager` never builds a
+subtag consumer, so its watch-list is not graded at all.
Each target's canonical `AlarmFullReference` is composed as
`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the
diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md
index 2b34d2d..0f8f0c4 100644
--- a/docs/GatewayConfiguration.md
+++ b/docs/GatewayConfiguration.md
@@ -453,11 +453,11 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
| Option | Default | Description |
|--------|---------|-------------|
-| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover. `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
+| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover (and is the one mode whose subtag watch-list is never graded, because no subtag consumer is built). `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
| `MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold` | `3` | Number of consecutive wnwrap COM failures (`COMException` or failure HRESULT from `Subscribe` / `GetXmlCurrentAlarms2`) before the monitor switches to subtag mode. Floored at 1. |
| `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. |
| `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. |
-| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. |
+| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. A failed query is not fatal — the monitor continues with whatever configuration contributed — but with `Mode` `Auto` or `ForceSubtag` the resulting coverage gap is reported on the alarm feed's `AlarmProviderStatus` (`degraded = true` with a `Subtag watch-list is empty` / `Galaxy Repository alarm discovery failed` reason) and shown on the dashboard as the amber `Alarm Manager (no subtag standby)` badge, instead of only a startup log line. |
| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. |
| `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. |
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. |
diff --git a/docs/Sessions.md b/docs/Sessions.md
index 77f8621..ce2d997 100644
--- a/docs/Sessions.md
+++ b/docs/Sessions.md
@@ -213,6 +213,8 @@ The repair transitions the monitor's reconcile broadcasts on the alarm feed (Rai
`AttachInternalEventSubscriber` enforces the same readiness gate as `AttachEventSubscriber` — a session (or worker) that is not `Ready` throws `SessionNotReady` *before* the distributor is constructed. A premature attach would otherwise start the pump against a source that throws, completing every subscriber with that error and latching the distributor for the session's whole lifetime.
+The same one-distributor-per-session rule is enforced at the other end of the lifecycle. Session disposal disposes the distributor and latches the fact, so an attach that passed its readiness gate and then lost the CPU until the session was torn down throws `SessionNotReady` rather than constructing a replacement distributor. A replacement can never be pumped — the pump-start flag is a one-way latch — so its subscriber would block on an empty channel that is never completed until the client's own deadline expired, with no error, log or metric to explain it. Every attach entry point (`AttachEventSubscriber`, `AttachEventSubscriberWithReplay`, `AttachInternalEventSubscriber`) fails this way, and each rolls back the subscriber-count slot it took, so a caller that loses the race to teardown sees the same failure it would have seen a moment later from the readiness gate.
+
Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added to the open timestamp. Unary client activity refreshes the lease by the same duration. `ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. `SessionLeaseMonitorHostedService` runs that sweep every `MxGateway:Sessions:LeaseSweepIntervalSeconds` seconds (default 30).
#### Teardown parallelism
diff --git a/gateway.md b/gateway.md
index 08e03a0..f0c5791 100644
--- a/gateway.md
+++ b/gateway.md
@@ -249,6 +249,22 @@ monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSub
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
+**Standby-coverage visibility:** `degraded` also covers the case where the alarm
+manager is serving normally but the subtag standby behind it could not cover
+anything — a watch-list that resolved to zero targets, or Galaxy Repository
+discovery that was enabled and failed. That condition used to be a single startup
+log line, invisible until the failover that needed the standby found it empty; it
+is now a standing `AlarmProviderStatus` reason
+(`AlarmProviderReasons.SubtagWatchListEmpty` / `SubtagDiscoveryFailed`) carried on
+the feed and rendered as the amber `Alarm Manager (no subtag standby)` badge, which
+keeps the failover label from naming the wrong live provider. The watch-list the
+worker holds is fixed for the life of the subscription, so on the failover edge the
+monitor re-resolves and reports a difference
+(`AlarmProviderReasons.SubtagWatchListStale`, with both counts) rather than
+re-sending it — re-sending would rebuild the failover consumer on the primary that
+just failed. A monitor restart re-resolves and re-sends. See
+`docs/AlarmClientDiscovery.md`.
+
**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
flag, so a reply holding exactly the cap is treated as truncated. On such a
diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/TestSupport/EmptyAlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/TestSupport/EmptyAlarmWatchListResolver.cs
index 332a81a..2ee88e5 100644
--- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/TestSupport/EmptyAlarmWatchListResolver.cs
+++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/TestSupport/EmptyAlarmWatchListResolver.cs
@@ -1,4 +1,3 @@
-using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -19,14 +18,18 @@ public sealed class EmptyAlarmWatchListResolver : IAlarmWatchListResolver
/// Shared no-op instance.
public static readonly EmptyAlarmWatchListResolver Instance = new();
- private static readonly IReadOnlyList Empty = [];
+ private static readonly AlarmWatchListResolution Empty = new(
+ [],
+ DiscoveryAttempted: false,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null);
private EmptyAlarmWatchListResolver()
{
}
///
- public Task> ResolveAsync(
+ public Task ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmProviderReasons.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmProviderReasons.cs
index 045f3aa..46359b2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmProviderReasons.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmProviderReasons.cs
@@ -15,4 +15,32 @@ public static class AlarmProviderReasons
/// distinguish a forced subtag mode from an unexpected degraded failover.
///
public const string ForcedSubtag = "Forced subtag mode (configuration)";
+
+ ///
+ /// Reason prefix set when the effective mode can need the subtag watch-list
+ /// (Auto or ForceSubtag) but the list resolved to zero targets, so the
+ /// subtag provider covers no alarms (GWC-37). In Auto this is a warning about the
+ /// standby, not about the currently-serving alarm manager, which is why the dashboard
+ /// renders it against the ALARMMGR mode as a coverage badge rather than a failover.
+ ///
+ public const string SubtagWatchListEmpty =
+ "Subtag watch-list is empty: the subtag provider covers no alarms";
+
+ ///
+ /// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so
+ /// the watch-list carries only what configuration contributed and may be incomplete
+ /// (GWC-37). Distinct from : discovery failing with a
+ /// usable config-only list is a completeness warning, not a total loss of coverage.
+ ///
+ public const string SubtagDiscoveryFailed =
+ "Galaxy Repository alarm discovery failed: the subtag watch-list may be incomplete";
+
+ ///
+ /// Reason prefix set when a re-resolution at the failover edge produced a different
+ /// watch-list than the one sent at SubscribeAlarms (GWC-37). The worker keeps the
+ /// list it was given for the life of the subscription, so the difference is reported
+ /// rather than applied; a monitor restart re-resolves and re-sends.
+ ///
+ public const string SubtagWatchListStale =
+ "Subtag watch-list differs from the one the worker is using";
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolution.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolution.cs
new file mode 100644
index 0000000..8c19eef
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolution.cs
@@ -0,0 +1,29 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+
+namespace ZB.MOM.WW.MxGateway.Server.Alarms;
+
+///
+/// The outcome of one subtag watch-list resolution: the composed targets plus enough
+/// provenance for the caller to tell an empty list that means "nothing was configured"
+/// from one that means "discovery was configured and silently produced nothing"
+/// (GWC-37). deliberately never throws
+/// on a discovery failure, so without this the two are indistinguishable at the call site
+/// and a Galaxy Repository outage at startup can only be found in a log line.
+///
+/// The resolved watch-list, in order; possibly empty.
+///
+/// Galaxy Repository discovery was enabled for this resolution
+/// (Fallback:Discovery:UseGalaxyRepository).
+///
+///
+/// Discovery was attempted and failed, so holds only what
+/// configuration contributed.
+///
+///
+/// The discovery failure's message, or null when discovery did not fail.
+///
+public sealed record AlarmWatchListResolution(
+ IReadOnlyList Targets,
+ bool DiscoveryAttempted,
+ bool DiscoveryFailed,
+ string? DiscoveryFailureMessage);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs
index 6844cf0..b17c053 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs
@@ -31,7 +31,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
}
///
- public async Task> ResolveAsync(
+ public async Task ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default)
{
@@ -39,6 +39,11 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
+ // GWC-37: the caller cannot grade an empty watch-list without knowing whether discovery
+ // was asked to contribute and whether it failed while doing so, because the failure is
+ // deliberately swallowed below.
+ string? discoveryFailureMessage = null;
+
// Config fallback area used only for explicit IncludeAttributes entries (which
// carry no discovered area): discovery area, else the default area (may be empty).
string configFallbackArea = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area;
@@ -68,6 +73,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
_logger.LogWarning(
ex,
"Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list.");
+ discoveryFailureMessage = ex.Message;
rows = [];
}
@@ -148,7 +154,11 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
_logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count);
}
- return targets;
+ return new AlarmWatchListResolution(
+ targets,
+ DiscoveryAttempted: discovery.UseGalaxyRepository,
+ DiscoveryFailed: discoveryFailureMessage is not null,
+ discoveryFailureMessage);
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index 9058615..04fd3cb 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -57,6 +57,20 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
+ // GWC-37 subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
+ // using can actually cover anything, and why not. Independent of _providerDegraded (which
+ // means "the subtag provider is the one serving"): in Auto mode the alarm manager can be
+ // serving perfectly while the standby behind it covers zero alarms, and that is invisible
+ // until the failover that needs it. Folded into BuildProviderStatus so it reaches the feed,
+ // the dashboard badge and every late-joining subscriber rather than only a startup log line.
+ private bool _subtagCoverageMissing;
+ private string _subtagCoverageReason = string.Empty;
+
+ // The watch-list references actually sent to the worker in SubscribeAlarms, guarded by _sync.
+ // The worker holds that list for the life of the subscription, so a later re-resolution is
+ // compared against this to report drift rather than silently diverging.
+ private IReadOnlyCollection _sentWatchListReferences = [];
+
// Whether the worker's most recent reconcile fetch was capped, guarded by _sync.
// Written only by ApplyReconcile (and cleared with the cache), so it describes the last full
// reconcile — not necessarily the current _alarms contents, which live transitions keep moving
@@ -329,12 +343,25 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
{
- IReadOnlyList watchList = await _watchListResolver
+ AlarmWatchListResolution resolution = await _watchListResolver
.ResolveAsync(_options, cancellationToken)
.ConfigureAwait(false);
+ IReadOnlyList watchList = resolution.Targets;
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
+ // GWC-37: grade the coverage before the command goes out, so a Galaxy Repository outage at
+ // startup is a standing status on the feed instead of one log line an operator reads weeks
+ // later — when the failover that needed the watch-list finds it empty. Broadcast because
+ // feed subscribers outlive the monitor's worker session and may already be attached.
+ lock (_sync)
+ {
+ _sentWatchListReferences = watchList
+ .Select(target => target.AlarmFullReference)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ SetSubtagCoverage(GradeSubtagCoverage(forcedMode, resolution, staleAgainstSent: false), broadcast: true);
+ }
+
_logger.LogInformation(
"Alarm subscribe: forcedMode={ForcedMode} configMode={ConfigMode} watchList={WatchListCount}.",
forcedMode, _options.Fallback.Mode, watchList.Count);
@@ -518,6 +545,20 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
AlarmProviderMode toMode = change.Mode;
string reason = change.Reason ?? string.Empty;
+ // GWC-37: this is the moment the subtag watch-list stops being theoretical — the provider
+ // that is about to serve is the one built from it. Re-resolve BEFORE the status is
+ // published so the frame the operator sees on the failover carries the coverage verdict
+ // rather than a second frame later, and so a watch-list that has drifted since
+ // SubscribeAlarms is reported instead of silently diverging. The re-resolution is NOT
+ // re-sent: the worker rejects a second SubscribeAlarms while subscribed, and the only way
+ // to replace the list — Unsubscribe then Subscribe — rebuilds the FailoverAlarmConsumer,
+ // which restarts on the primary that just failed and discards the warm standby state, so
+ // it would flap the feed rather than repair it. A monitor restart re-resolves and re-sends.
+ if (toMode == AlarmProviderMode.Subtag)
+ {
+ await RefreshSubtagCoverageAsync(cancellationToken).ConfigureAwait(false);
+ }
+
AlarmProviderStatus status;
int fromModeInt;
lock (_sync)
@@ -565,14 +606,141 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
}
}
+ // GWC-37: re-resolves the subtag watch-list and re-grades the coverage verdict without
+ // touching the worker's subscription. Best-effort: a resolver that throws (only cancellation
+ // and programming errors do — discovery failure is reported in the resolution) leaves the
+ // verdict from the last subscribe in place, because a stale verdict is better than none.
+ // Does not broadcast: the callers publish a status frame of their own immediately after.
+ private async Task RefreshSubtagCoverageAsync(CancellationToken cancellationToken)
+ {
+ AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
+ AlarmWatchListResolution resolution;
+ try
+ {
+ resolution = await _watchListResolver.ResolveAsync(_options, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(
+ exception,
+ "Re-resolving the alarm subtag watch-list failed; keeping the coverage verdict from the last subscribe.");
+ return;
+ }
+
+ lock (_sync)
+ {
+ bool stale = !resolution.Targets
+ .Select(target => target.AlarmFullReference)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase)
+ .SetEquals(_sentWatchListReferences);
+ SetSubtagCoverage(GradeSubtagCoverage(forcedMode, resolution, stale), broadcast: false);
+ }
+ }
+
+ // Grades one resolution against the effective mode. Returns the reason the subtag coverage is
+ // unsound, or an empty string when it is sound (or irrelevant, as under ForceAlarmManager).
+ // Empty targets are the total-loss case; a failed discovery with a usable config-only list is
+ // a completeness warning; drift is reported separately because the worker cannot be updated.
+ private string GradeSubtagCoverage(
+ AlarmProviderMode forcedMode,
+ AlarmWatchListResolution resolution,
+ bool staleAgainstSent)
+ {
+ // ForceAlarmManager never builds a subtag consumer, so its watch-list is genuinely unused.
+ if (forcedMode == AlarmProviderMode.Alarmmgr)
+ {
+ return string.Empty;
+ }
+
+ // Nothing was asked of discovery and nothing was configured: subtag fallback is simply not
+ // in use in this deployment (Auto with an empty list subscribes alarmmgr-only), so there is
+ // no coverage to warn about.
+ if (resolution.Targets.Count == 0
+ && !resolution.DiscoveryAttempted
+ && _options.Fallback.Discovery.IncludeAttributes.Length == 0
+ && forcedMode != AlarmProviderMode.Subtag)
+ {
+ return string.Empty;
+ }
+
+ List reasons = [];
+ if (resolution.Targets.Count == 0)
+ {
+ reasons.Add(AlarmProviderReasons.SubtagWatchListEmpty);
+ }
+
+ if (resolution.DiscoveryFailed)
+ {
+ reasons.Add(resolution.DiscoveryFailureMessage is { Length: > 0 } message
+ ? $"{AlarmProviderReasons.SubtagDiscoveryFailed} ({message})"
+ : AlarmProviderReasons.SubtagDiscoveryFailed);
+ }
+
+ if (staleAgainstSent)
+ {
+ reasons.Add(
+ $"{AlarmProviderReasons.SubtagWatchListStale} ({resolution.Targets.Count} target(s) now "
+ + $"resolve against {_sentWatchListReferences.Count} sent at subscribe); restart the alarm "
+ + "monitor to re-send it");
+ }
+
+ return string.Join("; ", reasons);
+ }
+
+ // Caller holds _sync. Applies a coverage verdict, logging and (optionally) broadcasting only
+ // when it actually changed so a stable degraded state does not repeat a frame every pass.
+ private void SetSubtagCoverage(string reason, bool broadcast)
+ {
+ bool missing = reason.Length > 0;
+ if (missing == _subtagCoverageMissing && string.Equals(reason, _subtagCoverageReason, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ _subtagCoverageMissing = missing;
+ _subtagCoverageReason = reason;
+
+ if (missing)
+ {
+ _logger.LogWarning("Alarm subtag coverage is unsound: {Reason}", reason);
+ }
+ else
+ {
+ _logger.LogInformation("Alarm subtag coverage is sound.");
+ }
+
+ if (broadcast)
+ {
+ BroadcastToAll(new AlarmFeedMessage { ProviderStatus = BuildProviderStatus() });
+ }
+ }
+
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
+ //
+ // GWC-37: degraded is the OR of "the subtag provider is the one serving" and "the subtag
+ // watch-list cannot cover anything". The second is what makes an empty watch-list visible in
+ // Auto mode, where the alarm manager is serving happily and the standby behind it is empty.
+ // The reasons are concatenated when both apply, which is the worst case worth naming loudly:
+ // failed over to a provider that covers nothing.
private AlarmProviderStatus BuildProviderStatus()
{
+ bool degraded = _providerDegraded || _subtagCoverageMissing;
+ string reason = (_providerReason, _subtagCoverageReason) switch
+ {
+ ("", var coverage) => coverage,
+ (var mode, "") => mode,
+ (var mode, var coverage) => $"{mode} — {coverage}",
+ };
+
return new AlarmProviderStatus
{
Mode = _providerMode,
- Degraded = _providerDegraded,
- Reason = _providerReason,
+ Degraded = degraded,
+ Reason = reason,
Since = Timestamp.FromDateTimeOffset(_providerSince),
};
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs
index 196b2b8..55dc2d4 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs
@@ -18,13 +18,15 @@ public interface IAlarmWatchListResolver
/// Alarm configuration carrying discovery and subtag-name settings.
/// Token to cancel the asynchronous operation.
///
- /// The resolved watch-list, possibly empty.
+ /// The resolved watch-list, possibly empty, together
+ /// with the discovery provenance the caller needs to grade it.
/// Discovery being unavailable never throws — it yields an empty (or
- /// config-only) list and the caller decides what to do with it. Cancellation
+ /// config-only) list with set,
+ /// and the caller decides what to do with it. Cancellation
/// is the one exception: a triggered
/// still propagates an .
///
- Task> ResolveAsync(
+ Task ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmProviderStatus.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmProviderStatus.cs
index de035b1..19e01b9 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmProviderStatus.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmProviderStatus.cs
@@ -30,6 +30,14 @@ public sealed record DashboardAlarmProviderStatus(
///
public const string ForcedSubtagLabel = "Subtag monitoring (forced)";
+ ///
+ /// Badge label shown when the alarm manager is serving normally but the subtag standby
+ /// behind it covers nothing — an empty or undiscoverable watch-list (GWC-37). The feed is
+ /// healthy today; what is degraded is the fallback that would take over tomorrow, so the
+ /// badge names the standby rather than claiming the feed has failed over.
+ ///
+ public const string NoSubtagStandbyLabel = "Alarm Manager (no subtag standby)";
+
private const string HealthyBadge = "bg-success";
private const string DegradedBadge = "bg-warning text-dark";
@@ -69,8 +77,18 @@ public sealed record DashboardAlarmProviderStatus(
&& status.Mode == AlarmProviderMode.Subtag
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
- string label = !degraded ? AlarmManagerLabel : forced ? ForcedSubtagLabel : DegradedLabel;
- string badge = !degraded ? HealthyBadge : forced ? ForcedSubtagBadge : DegradedBadge;
+ // GWC-37: degraded WITHOUT subtag mode means the alarm manager is still serving and the
+ // standby is the unsound half. "Subtag monitoring (degraded)" would misreport which
+ // provider is live, so this state gets its own label; the amber badge is shared because
+ // the operator action — fix discovery — is equally overdue in both.
+ bool standbyOnlyWarning = degraded && status.Mode != AlarmProviderMode.Subtag;
+
+ string label = !degraded
+ ? AlarmManagerLabel
+ : standbyOnlyWarning ? NoSubtagStandbyLabel : forced ? ForcedSubtagLabel : DegradedLabel;
+ string badge = !degraded
+ ? HealthyBadge
+ : standbyOnlyWarning ? DegradedBadge : forced ? ForcedSubtagBadge : DegradedBadge;
return new DashboardAlarmProviderStatus(
Mode: status.Mode,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs
index 5802ff9..5331a81 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs
@@ -39,6 +39,14 @@ public sealed class GatewaySession
private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
+ // GWC-34: set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
+ // "one distributor per session, never reset" lifecycle symmetric at both ends. Without it
+ // EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
+ // distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
+ // one-way latch) and whose channel is never completed, so an attach that stalled across the
+ // close+dispose registers on it and blocks in WaitToReadAsync until the client's own deadline,
+ // silently and invisibly to metrics.
+ private bool _eventDistributorDisposed;
private bool _dashboardMirrorStarted;
private IEventSubscriberLease? _dashboardMirrorLease;
private Task? _dashboardMirrorTask;
@@ -532,10 +540,23 @@ public sealed class GatewaySession
// start). Both the construction and the started-flag flip happen under _syncRoot so two
// concurrent callers (e.g. MarkReady's dashboard mirror and a racing first
// AttachEventSubscriber) agree on a single distributor and a single start.
+ //
+ // GWC-34: after DisposeAsync the null distributor means "gone", not "not built yet", so this
+ // throws instead of constructing a replacement nothing will ever pump. The attach paths already
+ // roll back and surface the exception, which is the same SessionNotReady the readiness gates
+ // raise — a caller that loses the race to teardown now fails immediately instead of hanging.
private SessionEventDistributor EnsureDistributorCreated(out bool startNow)
{
lock (_syncRoot)
{
+ if (_eventDistributorDisposed)
+ {
+ throw new SessionManagerException(
+ SessionManagerErrorCode.SessionNotReady,
+ $"Session {SessionId} is not ready for event streaming. Its event distributor was "
+ + "disposed with the session.");
+ }
+
if (_eventDistributor is null)
{
EventOptions eventOptions = _eventStreaming.EventOptions;
@@ -672,7 +693,37 @@ public sealed class GatewaySession
// dashboard mirror only disconnects itself and never faults the session.
// These three calls are OUTSIDE _syncRoot to avoid holding it across
// EnsureDistributorCreated's own lock and StartAsync's Task.Run.
- SessionEventDistributor distributor = EnsureDistributorCreated(out bool startNow);
+ //
+ // GWC-34: the state check above can be overtaken by a close+dispose before we get here, and
+ // EnsureDistributorCreated now says so instead of building an orphan distributor. The
+ // dashboard mirror is a best-effort observability attachment on a session that is already
+ // gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started
+ // latch stays set (nothing may start one on a disposed session) and the loop's token source
+ // is released so it is not orphaned.
+ SessionEventDistributor distributor;
+ bool startNow;
+ try
+ {
+ distributor = EnsureDistributorCreated(out startNow);
+ }
+ catch (SessionManagerException exception)
+ {
+ CancellationTokenSource? abandonedCts;
+ lock (_syncRoot)
+ {
+ abandonedCts = _dashboardMirrorCts;
+ _dashboardMirrorCts = null;
+ }
+
+ abandonedCts?.Dispose();
+ _eventStreaming.DistributorLogger.LogDebug(
+ exception,
+ "Dashboard event mirror for session {SessionId} was abandoned: the session was disposed "
+ + "before the mirror could attach.",
+ SessionId);
+ return;
+ }
+
IEventSubscriberLease lease = distributor.Register(isInternal: true);
StartPumpIfRequested(distributor, startNow);
@@ -1867,6 +1918,9 @@ public sealed class GatewaySession
{
distributor = _eventDistributor;
_eventDistributor = null;
+ // GWC-34: latch the null-out so EnsureDistributorCreated cannot read it as
+ // "not created yet" and hand a racing attach a distributor that will never pump.
+ _eventDistributorDisposed = true;
}
if (distributor is not null)
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs
index 41cf7bf..d928659 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs
@@ -420,12 +420,32 @@ public sealed class WorkerClient : IWorkerClient
/// A task that represents the asynchronous operation.
public async ValueTask DisposeAsync()
{
- if (_disposed)
+ // GWC-32: disposal is the third channel-completing path, and it must enter a terminal state
+ // like the other two (MarkClosed, SetFaulted) BEFORE the channels are completed. Otherwise a
+ // client disposed while still Ready keeps reporting IsTerminalState() == false, and a read
+ // loop already inside DispatchEnvelope (the _stopCts.Cancel below only interrupts the NEXT
+ // ReadAsync) takes StageWorkerEvent's overflow arm on the completed staging channel: a
+ // spurious ProtocolViolation fault, a worker-event-staging overflow metric and a redundant
+ // kill, all during an intentional teardown. MarkClosed is deliberately not reused here: it
+ // would overwrite Faulted with Closed and it re-runs the whole teardown sequence this method
+ // performs itself. The transition is skipped when the state is already terminal, so a
+ // dispose-after-fault keeps Faulted and a double dispose changes nothing.
+ bool becameTerminalHere;
+ lock (_syncRoot)
{
- return;
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ becameTerminalHere = _state is not (WorkerClientState.Closed or WorkerClientState.Faulted);
+ if (becameTerminalHere)
+ {
+ _state = WorkerClientState.Closed;
+ }
}
- _disposed = true;
// Drop out of the worker queue-depth gauge before teardown: whatever this client still holds
// is about to be discarded, and the sum must not keep counting a client that is going away.
_eventQueueDepthRegistration?.Dispose();
@@ -434,6 +454,16 @@ public sealed class WorkerClient : IWorkerClient
_outboundEnvelopes.Writer.TryComplete();
_eventStaging.Writer.TryComplete();
_events.Writer.TryComplete();
+ if (becameTerminalHere)
+ {
+ // A worker that reached Ready and is now being disposed without a prior Close or fault
+ // (SessionManager.OpenSessionAsync's failure arm does exactly this) stopped, so the
+ // running-worker gauge must come down. RecordWorkerStoppedOnce is latched on
+ // _workerStartRecorded, so a client that already recorded its stop through MarkClosed or
+ // SetFaulted cannot record a second one here.
+ RecordWorkerStoppedOnce("dispose");
+ }
+
CompletePendingCommands(
new WorkerClientException(
WorkerClientErrorCode.GatewayShutdown,
@@ -647,8 +677,10 @@ public sealed class WorkerClient : IWorkerClient
/// Hands a received worker event to the dedicated event writer without blocking the read loop.
/// TryWrite is non-blocking, so the read loop keeps dispatching replies, heartbeats and
/// faults regardless of how backed up the event path is. It returns false in two cases:
- /// the staging channel has been completed during shutdown (the event is safely dropped because
- /// the client is already terminal), or the channel is full at its
+ /// the staging channel has been completed by one of the three completing paths —
+ /// , or , each of
+ /// which enters a terminal state before completing the channels (GWC-32), so the event is
+ /// safely dropped because the client is already terminal — or the channel is full at its
/// 2 × bound. The latter means the
/// consumer has been draining slower than the worker produces for the whole time it took the
/// worker to emit that many further events, so the client is faulted immediately (GWC-24) —
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs
index bf9a0d0..3a11ff9 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs
@@ -384,9 +384,14 @@ public sealed class AlarmFailoverEndToEndTests
private sealed class StubWatchListResolver(IReadOnlyList targets) : IAlarmWatchListResolver
{
///
- public Task> ResolveAsync(
+ public Task ResolveAsync(
AlarmsOptions options,
- CancellationToken cancellationToken = default) => Task.FromResult(targets);
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AlarmWatchListResolution(
+ targets,
+ DiscoveryAttempted: false,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null));
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs
index b226a22..6aa2177 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs
@@ -507,10 +507,14 @@ public sealed class AlarmTruncationSignalTests
private sealed class StubWatchListResolver : IAlarmWatchListResolver
{
///
- public Task> ResolveAsync(
+ public Task ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default) =>
- Task.FromResult>([]);
+ Task.FromResult(new AlarmWatchListResolution(
+ [],
+ DiscoveryAttempted: false,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null));
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
index f8c8b1b..62f8809 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
@@ -16,6 +16,21 @@ public sealed class AlarmWatchListResolverTests
private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) =>
new(repository, NullLogger.Instance);
+ ///
+ /// Resolves and projects to the target list. These cases assert composition rules, which
+ /// live in ; the discovery-provenance fields
+ /// the resolution also carries (GWC-37) are asserted by their own cases below.
+ ///
+ /// The resolver under test.
+ /// Alarm options to resolve against.
+ /// Token to cancel the resolution.
+ /// The resolved targets.
+ private static async Task> ResolveTargetsAsync(
+ AlarmWatchListResolver resolver,
+ AlarmsOptions options,
+ CancellationToken cancellationToken = default) =>
+ (await resolver.ResolveAsync(options, cancellationToken)).Targets;
+
private static AlarmsOptions Options(
bool useGalaxyRepository = true,
string area = "",
@@ -58,7 +73,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
include: ["pump01.fault", "Valve03.Position.Lo"],
exclude: ["Tank02.Level.HiHi"]));
@@ -81,7 +96,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
subtags: new AlarmSubtagNameOptions
{
Active = "InAlarm",
@@ -110,7 +125,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
// Default Priority is "Priority"; force it empty alongside empty AckComment.
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
subtags: new AlarmSubtagNameOptions { Priority = string.Empty, AckComment = string.Empty }));
AlarmSubtagTarget target = Assert.Single(result);
@@ -140,7 +155,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
// Config area "DEV" must be ignored for a GR row that has a discovered area.
- IReadOnlyList result = await resolver.ResolveAsync(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver,
Options(area: "DEV", defaultArea: "DEV"));
AlarmSubtagTarget target = Assert.Single(result);
@@ -161,7 +176,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
// No discovery area and no default area.
- IReadOnlyList result = await resolver.ResolveAsync(Options());
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options());
AlarmSubtagTarget target = Assert.Single(result);
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
@@ -178,7 +193,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
area: "Site_A",
include: ["Tank01.Level.HiHi"]));
@@ -196,7 +211,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
area: "",
defaultArea: "Plant",
include: ["Tank01.Level.HiHi"]));
@@ -217,7 +232,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
useGalaxyRepository: false,
include: ["Tank01.Level.HiHi"]));
@@ -235,7 +250,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
include: ["Tank01.Level.HiHi"]));
// Did not throw; discovery set empty, include retained.
@@ -243,6 +258,51 @@ public sealed class AlarmWatchListResolverTests
Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
}
+ ///
+ /// GWC-37: swallowing the discovery failure is right for the resolver but leaves the
+ /// caller unable to tell "discovery produced nothing" from "nothing was configured", which
+ /// is why the empty watch-list could sit unnoticed for a monitor's whole life. The
+ /// resolution therefore reports whether discovery was attempted, whether it failed, and
+ /// the failure message the provider status quotes.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ResolveAsync_RepositoryThrows_ReportsDiscoveryFailureOnTheResolution()
+ {
+ ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable"));
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ AlarmWatchListResolution resolution = await resolver.ResolveAsync(Options());
+
+ Assert.Empty(resolution.Targets);
+ Assert.True(resolution.DiscoveryAttempted);
+ Assert.True(resolution.DiscoveryFailed);
+ Assert.Equal("SQL unavailable", resolution.DiscoveryFailureMessage);
+ }
+
+ ///
+ /// The complement of the case above: discovery disabled is not discovery failed. An empty
+ /// watch-list here means the deployment did not ask for subtag coverage, which the monitor
+ /// must not report as a degraded standby (GWC-37).
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ResolveAsync_RepositoryDisabled_ReportsDiscoveryNotAttempted()
+ {
+ StubGalaxyRepository repo = new([]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ AlarmWatchListResolution resolution = await resolver.ResolveAsync(
+ Options(useGalaxyRepository: false));
+
+ Assert.Empty(resolution.Targets);
+ Assert.False(resolution.DiscoveryAttempted);
+ Assert.False(resolution.DiscoveryFailed);
+ Assert.Null(resolution.DiscoveryFailureMessage);
+ }
+
/// Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present.
/// A task that represents the asynchronous operation.
[Fact]
@@ -252,7 +312,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
include: ["Tank01.Level.HiHi", "StandaloneTag"]));
Assert.Equal("Tank01", result[0].SourceObjectReference);
@@ -273,7 +333,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
useGalaxyRepository: false,
include: ["Tank01.Level.HiHi"],
exclude: ["Tank01.Level.HiHi"]));
@@ -299,7 +359,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
useGalaxyRepository: true,
exclude: ["Tank02.Level.HiHi"]));
@@ -324,7 +384,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
// The exclude array contains a whitespace-only string — should be a no-op.
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
useGalaxyRepository: true,
exclude: [" "]));
@@ -351,7 +411,7 @@ public sealed class AlarmWatchListResolverTests
AlarmWatchListResolver resolver = CreateResolver(repo);
await Assert.ThrowsAnyAsync(() =>
- resolver.ResolveAsync(
+ ResolveTargetsAsync(resolver,
Options(include: ["Tank01.Level.HiHi"]),
cts.Token));
}
@@ -375,7 +435,7 @@ public sealed class AlarmWatchListResolverTests
// Pump01.Fault is an explicit include AND an exclude (case-insensitive). It must
// be dropped; the GR row Tank01 survives.
- IReadOnlyList result = await resolver.ResolveAsync(Options(
+ IReadOnlyList result = await ResolveTargetsAsync(resolver, Options(
useGalaxyRepository: true,
include: ["Pump01.Fault"],
exclude: ["pump01.fault"]));
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
index 956cf14..4bfbb58 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs
@@ -498,10 +498,14 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
private sealed class StubWatchListResolver : IAlarmWatchListResolver
{
///
- public Task> ResolveAsync(
+ public Task ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default) =>
- Task.FromResult>([]);
+ Task.FromResult(new AlarmWatchListResolution(
+ [],
+ DiscoveryAttempted: false,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null));
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs
index 3b3f8fb..06336ac 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs
@@ -623,6 +623,184 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
+ ///
+ /// GWC-37: a Galaxy Repository outage at startup used to leave one LogWarning and an
+ /// empty subtag watch-list that stayed empty for the monitor's whole life — invisible until
+ /// a failover found the standby covering nothing. The condition is now a standing verdict
+ /// on the provider status, so it reaches the alarm feed, the dashboard badge and every
+ /// late-joining subscriber. The mode stays ALARMMGR: the live provider is healthy,
+ /// it is the standby behind it that is unsound.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SubtagCoverage_WhenDiscoveryFailsAtSubscribe_ProviderStatusIsDegradedWithReason()
+ {
+ using GatewayMetrics metrics = new();
+ FakeSessionManager sessions = new();
+ ScriptedWatchListResolver resolver = new(DiscoveryFailedResolution());
+ using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics, resolver);
+
+ using CancellationTokenSource cts = new();
+ await monitor.StartAsync(cts.Token);
+ await sessions.WaitForSubscribeAsync(WaitTimeout);
+
+ // The empty list still went to the worker — the verdict is a report, not a behaviour change.
+ Assert.Empty(sessions.LastSubscribeCommand!.WatchList);
+
+ AlarmProviderStatus status = await ReadFirstProviderStatusAsync(monitor);
+
+ Assert.Equal(AlarmProviderMode.Alarmmgr, status.Mode);
+ Assert.True(status.Degraded);
+ Assert.Contains(AlarmProviderReasons.SubtagWatchListEmpty, status.Reason, StringComparison.Ordinal);
+ Assert.Contains(AlarmProviderReasons.SubtagDiscoveryFailed, status.Reason, StringComparison.Ordinal);
+ Assert.Contains("gr offline", status.Reason, StringComparison.Ordinal);
+
+ await cts.CancelAsync();
+ await monitor.StopAsync(CancellationToken.None);
+ }
+
+ ///
+ /// GWC-37: the failover edge is the moment the watch-list stops being theoretical, so the
+ /// monitor re-resolves it there and publishes the fresh verdict on the same status frame
+ /// the failover produces. Discovery that has recovered since SubscribeAlarms is
+ /// reported as drift rather than applied — the worker rejects a second SubscribeAlarms
+ /// while subscribed, and the only way to replace the list rebuilds the failover consumer on
+ /// the primary that just failed. The status names both the fresh count and the sent count so
+ /// an operator knows a monitor restart will pick the new list up.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SubtagCoverage_WhenDiscoveryRecoversByFailover_ReResolvesAndReportsDrift()
+ {
+ using GatewayMetrics metrics = new();
+ FakeSessionManager sessions = new();
+ ScriptedWatchListResolver resolver = new(
+ DiscoveryFailedResolution(),
+ new AlarmWatchListResolution(
+ [Target("Galaxy!Area.Tank01.Level.HiHi"), Target("Galaxy!Area.Tank02.Level.HiHi")],
+ DiscoveryAttempted: true,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null));
+ using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics, resolver);
+
+ using CancellationTokenSource cts = new();
+ await monitor.StartAsync(cts.Token);
+ await sessions.WaitForSubscribeAsync(WaitTimeout);
+ Assert.Equal(1, resolver.Calls);
+
+ List received = [];
+ TaskCompletionSource baselineReceived = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ using CancellationTokenSource streamCts = new();
+ Task reader = Task.Run(async () =>
+ {
+ try
+ {
+ await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, streamCts.Token))
+ {
+ lock (received)
+ {
+ received.Add(message);
+ if (received.Count == 1)
+ {
+ baselineReceived.TrySetResult();
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when the test cancels the stream.
+ }
+ });
+
+ await baselineReceived.Task.WaitAsync(WaitTimeout);
+
+ sessions.EmitEvent(new MxEvent
+ {
+ OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
+ {
+ Mode = AlarmProviderMode.Subtag,
+ Reason = "alarmmgr failed",
+ At = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ },
+ });
+
+ AlarmFeedMessage degraded = await WaitForAsync(
+ received,
+ m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus
+ && m.ProviderStatus.Mode == AlarmProviderMode.Subtag,
+ WaitTimeout);
+
+ // The re-resolve happened on the failover edge, before the status was published.
+ Assert.Equal(2, resolver.Calls);
+
+ string reason = degraded.ProviderStatus.Reason;
+ Assert.Contains("alarmmgr failed", reason, StringComparison.Ordinal);
+ Assert.Contains(AlarmProviderReasons.SubtagWatchListStale, reason, StringComparison.Ordinal);
+ Assert.Contains("2 target(s) now resolve against 0 sent", reason, StringComparison.Ordinal);
+
+ // The stale-list warning replaced the empty/failed pair: discovery works again, so the only
+ // remaining problem is that the worker is still using the list it was handed.
+ Assert.DoesNotContain(AlarmProviderReasons.SubtagWatchListEmpty, reason, StringComparison.Ordinal);
+ Assert.DoesNotContain(AlarmProviderReasons.SubtagDiscoveryFailed, reason, StringComparison.Ordinal);
+
+ await streamCts.CancelAsync();
+ await reader;
+ await cts.CancelAsync();
+ await monitor.StopAsync(CancellationToken.None);
+ }
+
+ /// Reads the provider-status frame every stream opens with, then closes the stream.
+ /// The monitor to read from.
+ /// The provider status carried by the first frame.
+ private static async Task ReadFirstProviderStatusAsync(GatewayAlarmMonitor monitor)
+ {
+ using CancellationTokenSource streamCts = new(WaitTimeout);
+ await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, streamCts.Token))
+ {
+ if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
+ {
+ return message.ProviderStatus;
+ }
+ }
+
+ throw new TimeoutException("No provider-status message was received in time.");
+ }
+
+ /// A resolution standing for "Galaxy Repository discovery was on and it failed".
+ /// The resolution.
+ private static AlarmWatchListResolution DiscoveryFailedResolution() => new(
+ [],
+ DiscoveryAttempted: true,
+ DiscoveryFailed: true,
+ DiscoveryFailureMessage: "gr offline");
+
+ private static AlarmSubtagTarget Target(string fullReference) => new()
+ {
+ AlarmFullReference = fullReference,
+ SourceObjectReference = fullReference,
+ ActiveSubtag = $"{fullReference}.InAlarm",
+ AckedSubtag = $"{fullReference}.Acked",
+ };
+
+ private static GatewayAlarmMonitor CreateMonitor(
+ FakeSessionManager sessions,
+ GatewayMetrics metrics,
+ IAlarmWatchListResolver resolver)
+ {
+ AlarmsOptions options = new()
+ {
+ Enabled = true,
+ SubscriptionExpression = @"\\NODE\Galaxy!Area",
+ };
+ return new GatewayAlarmMonitor(
+ sessions,
+ resolver,
+ metrics,
+ Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
+ NullLogger.Instance);
+ }
+
private static GatewayAlarmMonitor CreateMonitor(FakeSessionManager sessions, GatewayMetrics metrics)
{
AlarmsOptions options = new()
@@ -693,13 +871,42 @@ public sealed class GatewayAlarmMonitorProviderModeTests
throw new TimeoutException("Condition was not met in time.");
}
+ ///
+ /// that hands out a scripted sequence of
+ /// resolutions — one per call, the last repeating — and counts the calls, so a test can
+ /// drive "discovery was down at subscribe and back up by the failover" and assert that the
+ /// second resolution happened at all (GWC-37).
+ ///
+ /// Resolutions to return, in call order.
+ private sealed class ScriptedWatchListResolver(params AlarmWatchListResolution[] script) : IAlarmWatchListResolver
+ {
+ private int _calls;
+
+ /// Gets the number of times has been called.
+ public int Calls => Volatile.Read(ref _calls);
+
+ ///
+ public Task ResolveAsync(
+ AlarmsOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ int index = Interlocked.Increment(ref _calls) - 1;
+ return Task.FromResult(script[Math.Min(index, script.Length - 1)]);
+ }
+ }
+
/// that returns a fixed watch-list.
private sealed class StubWatchListResolver(IReadOnlyList targets) : IAlarmWatchListResolver
{
///
- public Task> ResolveAsync(
+ public Task ResolveAsync(
AlarmsOptions options,
- CancellationToken cancellationToken = default) => Task.FromResult(targets);
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AlarmWatchListResolution(
+ targets,
+ DiscoveryAttempted: false,
+ DiscoveryFailed: false,
+ DiscoveryFailureMessage: null));
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs
index a4a6de2..55ca53b 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs
@@ -179,8 +179,10 @@ public sealed class DashboardBrowseAndAlarmModelTests
///
/// An explicitly-degraded status whose mode is still Alarmmgr (the
- /// Degraded || Mode==Subtag guard's second, independent branch) must still
- /// map to the degraded amber badge.
+ /// Degraded || Mode==Subtag guard's second, independent branch) must map to the
+ /// amber badge, but not to the failover label: GWC-37 gives that combination a meaning —
+ /// the alarm manager is serving and the subtag standby behind it covers nothing — so
+ /// reporting "Subtag monitoring (degraded)" would name the wrong live provider.
///
[Fact]
public void FromProviderStatus_Alarmmgr_DegradedFlagSet_WarningBadge()
@@ -195,8 +197,9 @@ public sealed class DashboardBrowseAndAlarmModelTests
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
Assert.True(model.IsDegraded);
- Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
+ Assert.Equal(DashboardAlarmProviderStatus.NoSubtagStandbyLabel, model.Label);
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
+ Assert.Equal(AlarmProviderMode.Alarmmgr, model.Mode);
}
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs
index 9e78b09..5581103 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewaySessionTests.cs
@@ -719,6 +719,47 @@ public sealed class GatewaySessionTests
Assert.Equal([1UL, 2UL], received.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
}
+ ///
+ /// GWC-34, the disposal-side twin of the GWC-27 premature-attach poison: an attach that
+ /// passed its readiness gate and then lost the CPU until the session was torn down used to
+ /// find _eventDistributor null, read that as "not created yet" and build a
+ /// REPLACEMENT distributor. Its pump could never start (the started flag is a one-way latch)
+ /// and its channel was never completed, so the subscriber registered on it and blocked in
+ /// WaitToReadAsync until its own deadline — no error, no log, no metric. Disposal now
+ /// latches the null-out, so every attach entry point fails immediately and loudly instead.
+ /// Attaching twice pins the rollback: the second call must still report
+ /// SessionNotReady, not EventSubscriberAlreadyActive, which it only can if the
+ /// first failure released the subscriber slot it took.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AttachAfterDisposeThrowsSessionNotReadyInsteadOfRegisteringOnANeverPumpedDistributor()
+ {
+ FakeWorkerClient workerClient = new();
+ GatewaySession session = CreateReadySession(workerClient);
+
+ // Disposal leaves the session state Ready (only CloseAsync transitions it), so the attach
+ // paths' readiness gates pass and the calls below genuinely reach EnsureDistributorCreated —
+ // the exact position the racing attach in the finding occupies.
+ await session.DisposeAsync();
+
+ SessionManagerException attachException = Assert.Throws(
+ () => session.AttachEventSubscriber(maxSubscribers: 1));
+ Assert.Equal(SessionManagerErrorCode.SessionNotReady, attachException.ErrorCode);
+
+ SessionManagerException secondAttachException = Assert.Throws(
+ () => session.AttachEventSubscriber(maxSubscribers: 1));
+ Assert.Equal(SessionManagerErrorCode.SessionNotReady, secondAttachException.ErrorCode);
+
+ SessionManagerException replayException = Assert.Throws(
+ () => session.AttachEventSubscriberWithReplay(maxSubscribers: 1, afterSequence: 0));
+ Assert.Equal(SessionManagerErrorCode.SessionNotReady, replayException.ErrorCode);
+
+ SessionManagerException internalException = Assert.Throws(
+ () => session.AttachInternalEventSubscriber());
+ Assert.Equal(SessionManagerErrorCode.SessionNotReady, internalException.ErrorCode);
+ }
+
private static GatewaySession CreateSession()
{
return new GatewaySession(
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs
index ad3761b..94185c4 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs
@@ -14,6 +14,10 @@ public sealed class WorkerClientTests
private const string SessionId = "session-worker-client";
private const string Nonce = "nonce-worker-client";
private const int WorkerProcessId = 4321;
+
+ /// Upper bound on the events writes.
+ private const ulong EmittedEventCeiling = 2000;
+
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// Verifies that StartAsync enters ready state after receiving worker hello and ready messages.
@@ -705,6 +709,96 @@ public sealed class WorkerClientTests
Assert.True(process.Disposed);
}
+ ///
+ /// GWC-32: disposing a still-Ready client while the worker is emitting events must be
+ /// an ordinary teardown, not a fault. DisposeAsync completes the same three channels
+ /// MarkClosed and SetFaulted do, so it must enter a terminal state first;
+ /// otherwise the read loop — already inside DispatchEnvelope when the staging channel
+ /// is completed, or reading a stream that is torn down underneath it — misdiagnoses the
+ /// completed channel as the GWC-24 sustained-backpressure overflow and raises a spurious
+ /// ProtocolViolation fault, a worker-event-staging overflow metric and a
+ /// redundant kill during an intentional dispose. The event channel is sized far above the
+ /// burst so a genuine overflow is impossible, and DisposeAsync awaits its background
+ /// tasks, so the metrics read afterwards is not racing a late fault.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DisposeAsync_WhileWorkerIsEmittingEvents_ClosesWithoutFaultingOrOverflowing()
+ {
+ // The burst is capped at EmittedEventCeiling and the staging channel holds 2 x capacity, so
+ // even if every emitted event were staged and none consumed the GWC-24 bound is unreachable:
+ // an overflow metric in this test can only be the dispose-race misdiagnosis.
+ const int capacity = 2048;
+ using GatewayMetrics metrics = new();
+ await using PipePair pipePair = await PipePair.CreateAsync();
+ WorkerClient client = CreateClient(
+ pipePair,
+ new WorkerClientOptions
+ {
+ EventChannelCapacity = capacity,
+ EventChannelFullModeTimeout = TimeSpan.FromMinutes(5),
+ HeartbeatGrace = TimeSpan.FromSeconds(30),
+ HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
+ },
+ metrics: metrics);
+ await CompleteHandshakeAsync(client, pipePair);
+
+ using CancellationTokenSource emitCts = new(TestTimeout);
+ Task emitTask = EmitEventsUntilStoppedAsync(pipePair, emitCts.Token);
+
+ // Proves the read loop is mid-stream — not parked before its first event — when dispose runs.
+ await WaitUntilAsync(() => metrics.GetSnapshot().WorkerEventQueueDepth > 0, TestTimeout);
+
+ await client.DisposeAsync().AsTask().WaitAsync(TestTimeout);
+ await emitCts.CancelAsync();
+ await emitTask.WaitAsync(TestTimeout);
+
+ Assert.Equal(WorkerClientState.Closed, client.State);
+
+ GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
+ Assert.Equal(0L, snapshot.Faults);
+ Assert.Equal(0L, snapshot.QueueOverflows);
+ Assert.Equal(0L, snapshot.WorkerKills);
+
+ // Disposal is now a terminal transition, so the worker that reached Ready is recorded as
+ // stopped exactly once — the running-worker gauge no longer leaks on this path.
+ Assert.Equal(0, snapshot.WorkersRunning);
+ Assert.Equal(1L, snapshot.WorkerExits);
+ }
+
+ ///
+ /// Writes worker events from the fake worker side until cancelled or until
+ /// events have been written, so the gateway read loop has a
+ /// continuous stream to dispatch while a test tears the client down. The ceiling keeps the
+ /// burst structurally below the staging bound the caller configures. Every failure is
+ /// swallowed: disposal closes the gateway end of the pipe underneath this writer, which is
+ /// the situation under test rather than a test failure.
+ ///
+ /// The connected pipe pair whose worker side emits the events.
+ /// Stops the emission.
+ /// A task that represents the asynchronous operation.
+ private static async Task EmitEventsUntilStoppedAsync(
+ PipePair pipePair,
+ CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+ try
+ {
+ for (ulong sequence = 1;
+ sequence <= EmittedEventCeiling && !cancellationToken.IsCancellationRequested;
+ sequence++)
+ {
+ await pipePair.WorkerWriter.WriteAsync(
+ CreateEventEnvelope(sequence, MxEventFamily.OnDataChange),
+ cancellationToken);
+ }
+ }
+ catch (Exception)
+ {
+ // Broken pipe, disposed stream or cancellation — all expected once dispose starts.
+ }
+ }
+
///
/// Verifies that a heartbeat envelope updates the last-heartbeat timestamp and worker
/// process id. Uses a so the timestamp advance is