diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index c5a50698..0d9e5f18 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -32,6 +32,7 @@
+
diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index ce5e3c63..2dd68cfd 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -158,18 +158,56 @@ condition's state?") — mirroring the native OT semantic:
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
actually-`Bad` published value flips the bucket and annotates.
-**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
-data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
-per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
-goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
-`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
-condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
-`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
-`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
-as the Layer-4 follow-up (#481).
+**Comms loss (Layer 4, #481).** #478 covered input tags whose driver **publishes a data change carrying a
+Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's per-item Bad). It did not
+cover a driver **comms loss**: a poll driver (Modbus/S7) whose device goes unreachable emits only
+`ConnectivityChanged` and goes *silent* on the value feed (see `DriverInstanceActor.Reconnecting`), so the
+engine kept the last-known Good value, `WorstInputStatus` stayed Good — and worse, `AreInputsReady` stayed
+true, so the predicate went on evaluating a stale value after the source had died.
+
+`DriverHostActor.FanCommsLossToDependencyMux` closes it, symmetrically with the native
+`OnDriverConnectivityChanged` path above: on `ConnectivityChanged(false)` it pushes a synthetic
+`BadNoCommunication` with a **null value** into the dependency mux for every value ref the driver owns,
+resolved from the existing `_nodeIdByDriverRef` map. Alarm-bearing tags are deliberately excluded — a
+native condition is not a mux value, and its comms quality goes through the Part 9 annotation instead.
+
+Two things worth knowing before changing this:
+
+- **Nothing is pushed on reconnect, deliberately** — publishing Good would assert a value quality the host
+ has not observed. Recovery rides the driver's own republish: the `Connected → Reconnecting` edge calls
+ `DetachSubscription`, so the reconnect's `ResubscribeDesired` builds a *fresh* subscription whose
+ `PollGroupEngine` initial push runs with `forceRaise: true` and re-publishes every tag with its real
+ quality. That detach is load-bearing — without it the poll engine would compare against its retained
+ `LastValues`, see an unchanged value, raise nothing, and strand those refs Bad forever.
+- **Virtual tags computed from a comms-lost input now go Bad too.** That follows from using the shared mux
+ and is the correct reading of "this input's source is unreachable", but it is a behaviour change beyond
+ scripted alarms.
+
+⚠️ **The signal this hangs off does not currently fire for a mid-run device loss — see #527.** The #481 live
+gate on docker-dev found that `DriverInstanceActor.DisconnectObserved` has **no production sender**: the only
+paths into `Reconnecting` are a failed *initial* connect and an operator Force Reconnect. A Modbus driver
+whose fixture was stopped stayed `Connected` for >10 minutes publishing per-tag Bad, and did not recover when
+the fixture came back until the node was restarted. So the fan-out above — and the native `#477` Layer 2
+annotation it mirrors — are correctly wired to a signal that a dying device never raises. What partially
+covers the gap today is that Modbus publishes Bad *per tag* rather than going silent, so #478 Layer 3 does
+carry Bad into scripted input quality for that driver; that is a per-driver accident, not a guarantee, and it
+does nothing for native conditions or for recovery. Closing #527 is what makes this path reachable.
+
+**Cold start vs. a driver reporting Bad (#478 Finding 3, closed by #481).** `WorstInputStatus` used to skip
+every input whose *value* was null as "no data yet". A comms-lost driver produces exactly that shape — Bad
+with no value — so the skip was hiding the case that matters most. The upstream source now stamps a
+never-pushed path `BadWaitingForInitialData` and nothing else does, so the engine skips *that specific
+code* rather than every null. Deleting the null-skip outright would have been the obvious fix and is wrong
+in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, which is
+what the skip existed to prevent. Both failure modes are pinned — see the engine-test pair below, each of
+which goes red under the opposite mistake.
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
-`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
+`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing; plus the #481
+pair — a pushed Bad carrying no value counts, an input never pushed does not),
+`DriverHostActorLiveValueTests` (comms loss pushes Bad for every ref the driver owns, reconnect pushes
+nothing, another driver's loss pushes nothing),
+`DependencyMuxTagUpstreamSourceTests.The_engine_agrees_with_this_source_on_the_never_pushed_status_code`,
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md
index 16e9c496..5bfd6186 100644
--- a/docs/DriverLifecycle.md
+++ b/docs/DriverLifecycle.md
@@ -136,15 +136,31 @@ the real per-capability timeout / retry / breaker policies, resolved via
Every driver runs Tier A because no factory passes a tier, so today that means
one uniform policy set.
-⚠️ Two `IDriver` members are now **consumerless**: `GetMemoryFootprint()` and
-`FlushOptionalCachesAsync()` existed to feed `MemoryTracking`. All 12 drivers
-still implement them and nothing calls them. They were left in place rather
-than swept, since removing them touches every driver and every test stub —
-tracked as Gitea **#525**.
+`IDriver` **no longer carries `GetMemoryFootprint()` or
+`FlushOptionalCachesAsync()`** (Gitea **#525**). They existed to feed
+`MemoryTracking`, deleted with the rest of the driver-tier recycle machinery in
+**#522**; after that nothing called them, and their doc-comments still claimed
+Core polled the footprint every 30 s, which had not been true since v3. The
+sweep removed them from the interface, all 12 drivers, and every test stub,
+along with the support code that existed only to feed them — MTConnect's
+per-entry byte weights and its cached data-item count, OpcUaClient's
+`_discoveredNodeCount`, and MQTT's per-tag byte estimate.
+
+Two consequences worth knowing:
+
+- `AbCipTemplateCache` now has **no production reset path**. Its only caller was
+ AB CIP's flush — which itself had no caller, so nothing has actually changed
+ in behaviour, only in how visible the fact is. A UDT redefined on a live
+ controller keeps its stale shape until the driver instance is rebuilt.
+- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
+ Agent restart. The tests that used to reach it through the flush now drive
+ that path instead, so they cover a case that actually occurs.
If per-driver memory protection is wanted again, build it for the architecture
-as it is now: a process-wide watchdog, not a per-driver tier whose only remedy
-requires a supervisor that cannot exist in-process.
+as it is now: a process-wide watchdog that can ask drivers to flush, not a
+per-driver tier whose only remedy requires a supervisor that cannot exist
+in-process. The deleted per-driver implementations are in git history and were
+mostly reusable — what was always missing is the trigger.
---
diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md
index 0a62c406..2f9594a2 100644
--- a/docs/drivers/AbCip.md
+++ b/docs/drivers/AbCip.md
@@ -168,6 +168,6 @@ UDT-typed tags found in the walk.
## Operational Notes
-- **Native heap is invisible to the GC.** `GetMemoryFootprint()` reports CLR allocations only; libplctag's native `Tag` heap does not show up there. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth.
+- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md
index 1241876d..7cbcdfe6 100644
--- a/docs/drivers/AbLegacy.md
+++ b/docs/drivers/AbLegacy.md
@@ -132,6 +132,6 @@ and the UI authoring flow.
## Operational Notes
-- **Native heap is invisible to the GC.** As with AB CIP, `GetMemoryFootprint()` reports CLR allocations only; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles.
+- **Native heap is invisible to the GC.** As with AB CIP, libplctag's native heap does not show up in CLR allocation figures; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles. (`IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)).
- **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands.
diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md
index 0aa81f58..62a13701 100644
--- a/docs/plans/2026-07-15-sql-poll-driver-design.md
+++ b/docs/plans/2026-07-15-sql-poll-driver-design.md
@@ -62,7 +62,8 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** |
-| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Sql.Dialect`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). The browser opens its own transient connection. The **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/` | `ISqlDialect` + `SqlServerDialect`, assembly-split out of `Driver.Sql` (namespace unchanged). **Added by Gitea #526**, which corrected the row above: it used to say the browser referenced the runtime **`Driver.Sql`**, called that "a deliberate deviation from `OpcUaClient.Browser`", and asked that nobody "fix" it without reading this context. The *sharing* premise was right and survives; the conclusion that sharing required the runtime reference was not. The AdminUI references the browser, so that edge put the whole SQL driver runtime — and `ZB.MOM.WW.OtOpcUa.Core` through it — into the AdminUI's build/deploy graph, invisibly (measured before and after: both DLLs present, then absent). The dialect depends on nothing in `Driver.Sql`, so it moved to this leaf, referenced by the runtime driver and the browser alike. It cannot live in `.Contracts`: `ISqlDialect` exposes `DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`, and `.Contracts` is deliberately provider-free — the same shape as `Driver.Mqtt.Transport`. Guarded by `DriverBrowserLayeringTests`, which scans every `*.Browser` csproj in the tree. |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). |
diff --git a/docs/v2/driver-stability.md b/docs/v2/driver-stability.md
index b18c185e..c5be713d 100644
--- a/docs/v2/driver-stability.md
+++ b/docs/v2/driver-stability.md
@@ -120,6 +120,8 @@ For Tier A/B (in-process) drivers, the "crash" being counted is a driver-instanc
In-process drivers cannot be recycled by killing the server process — that would take down every other driver, every session, and the OPC UA endpoint. RSS watchdogs and scheduled recycle therefore do **not** apply to Tier A/B. Instead, each driver instance is monitored at a finer grain:
+> ⚠️ **The two bullets below were never built as described, and the surface they name is now gone.** `GetMemoryFootprint()` / `FlushOptionalCachesAsync()` did exist on `IDriver`, but nothing ever polled the footprint on a 30 s cadence in v3, no driver ever declared a cache budget in `DriverConfig`, and the recycle machinery their only consumer belonged to was deleted in Gitea #522. Both members were removed from `IDriver` in Gitea #525. Retained here as the design intent a future process-wide watchdog could revive — not as a description of the system.
+
- **Per-instance allocation tracking**: drivers expose a `GetMemoryFootprint()` capability returning bytes attributable to their own caches (symbol cache, subscription items, queued operations). The Core polls this every 30 s and logs growth slope per driver instance.
- **Soft-limit on cached state**: each driver declares a memory budget for its caches in `DriverConfig`. On breach, the Core asks the driver to flush optional caches (e.g. discard symbol cache, force re-discovery). No process action.
- **Escalation rule**: if a driver instance's footprint cannot be bounded by cache flushing — or if growth is in opaque allocations the driver can't account for — that driver is a candidate for **promotion to Tier C**. Process recycle is the only safe leak remediation, and the only way to apply process recycle to a single driver is to give it its own process.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
index 193b2381..02c353fc 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
@@ -64,26 +64,5 @@ public interface IDriver
/// has a running / not-running distinction worth reporting.
/// The driver's current health snapshot.
DriverHealth GetHealth();
-
- ///
- /// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables).
- /// ⚠️ Nothing calls this (Gitea #525). Its only consumer was MemoryTracking,
- /// deleted with the rest of the driver-tier recycle machinery in Gitea #522 — the doc-comment here
- /// used to claim Core polled it every 30 s, which was never true in v3. All 12 drivers still
- /// implement it; most return a real number, and returning 0 is equally correct today. Do not
- /// read a value from it and act on it without first building a consumer that is actually wired.
- ///
- /// The approximate memory footprint in bytes.
- long GetMemoryFootprint();
-
- ///
- /// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
- /// Required-for-correctness state must NOT be flushed.
- /// ⚠️ Nothing calls this (Gitea #525) — same reason as
- /// : the memory-pressure path that would have invoked it is
- /// gone.
- ///
- /// Cancellation token for the operation.
- /// A task that represents the asynchronous operation.
- Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}
+
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
index 250953d9..ff3582fd 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
@@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
///
public sealed class ScriptedAlarmEngine : IDisposable
{
+ ///
+ /// OPC UA Part 4 BadWaitingForInitialData — the status the upstream source stamps on a
+ /// path nothing has ever pushed to, and the marker uses to tell
+ /// genuine cold start apart from a driver reporting Bad.
+ /// Inlined rather than taken from Commons.OpcUa.OpcUaStatus because this project
+ /// deliberately does not reference Commons — the engine is pure evaluation logic over
+ /// Core.Abstractions. The value is fixed by the OPC UA spec, so the duplication cannot
+ /// drift; DependencyMuxTagUpstreamSourceTests pins the two agreeing.
+ ///
+ private const uint StatusNeverPushed = 0x80320000u;
+
private readonly ITagUpstreamSource _upstream;
private readonly IAlarmStateStore _store;
private readonly ScriptLoggerFactory _loggerFactory;
@@ -736,19 +747,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
WorstInputStatusCode: worstInputStatus);
/// Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
- /// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
- /// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
- /// guard already handles by holding the condition. Counting it as Bad here
- /// would flash every scripted condition Bad at deploy until the first push and would flood the quality
- /// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
- /// cache ⇒ Good (0).
+ /// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which
+ /// the guard already handles by holding the condition. Counting it would
+ /// flash every scripted condition Bad at deploy until the first push and flood the quality path with
+ /// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
+ /// "Never pushed" is a specific status code, not "the value is null" (#481, closing #478
+ /// Finding 3). This used to skip every entry whose value was null, which made a genuine runtime Bad
+ /// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
+ /// the case the skip was hiding was the case that matters most. The upstream source now stamps a
+ /// never-pushed path and nothing else does, so the two are tellable
+ /// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
+ /// observation.
private static uint WorstInputStatus(IReadOnlyDictionary cache)
{
uint worst = 0u;
var worstSeverity = 0u;
foreach (var kv in cache)
{
- if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
+ // No entry at all — nothing was observed, so there is nothing to report on.
+ if (kv.Value is null) continue;
+ // Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
+ if (kv.Value.StatusCode == StatusNeverPushed) continue;
var status = kv.Value.StatusCode;
var severity = status >> 30;
if (severity > worstSeverity)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 05ad2904..703579d6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -1017,15 +1017,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public DriverHealth GetHealth() => _health;
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
- {
- _templateCache.Clear();
- return Task.CompletedTask;
- }
// ---- ITagDiscovery ----
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs
index 719cd87f..81cda88d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs
@@ -4,9 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
/// Cache of UDT shape descriptors keyed by (deviceHostAddress, templateInstanceId).
-/// Populated on demand during discovery + whole-UDT reads; flushed via
-/// and on device
-/// ReinitializeAsync.
+/// Populated on demand during discovery + whole-UDT reads.
+/// Nothing in production clears it. Its only caller was
+/// AbCipDriver.FlushOptionalCachesAsync, deleted in Gitea #525 as an unconsumed
+/// IDriver member — but that member never had a caller either, so this is not a change in
+/// behaviour, only in how visible the fact is. The comment here used to claim a device
+/// ReinitializeAsync also flushed it; no such call ever existed. A UDT redefined on a live
+/// controller therefore keeps its stale shape until the driver instance is rebuilt.
///
///
/// Templates are decoded by (CIP Template Object
@@ -34,7 +38,8 @@ public sealed class AbCipTemplateCache
public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) =>
_shapes[(deviceHostAddress, templateInstanceId)] = shape;
- /// Drop every cached shape — called on .
+ /// Drop every cached shape. Retained as ordinary cache API + the tests' reset seam;
+ /// see the type remarks for why no production path calls it.
public void Clear() => _shapes.Clear();
/// Count of cached shapes — exposed for diagnostics + tests.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index faadaafc..65b2d068 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -229,11 +229,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public DriverHealth GetHealth() => _health;
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
/// Gets the device count.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs
index 2d94ea25..61e505d1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs
@@ -279,16 +279,6 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null)
: new DriverHealth(DriverState.Unknown, _lastComputeUtc, null);
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
- {
- // The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
- _evaluator.ClearCompiledScripts();
- return Task.CompletedTask;
- }
// ---- IDependencyConsumer ----
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index b01ef4a4..ebd68e38 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -311,10 +311,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public DriverHealth GetHealth() => Volatile.Read(ref _health);
- ///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Gets the number of configured devices.
internal int DeviceCount => _devices.Count;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index f77d55f6..dd9e7225 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -680,22 +680,6 @@ public sealed class GalaxyDriver
///
public IReadOnlyList GetHostStatuses() => _hostStatuses.Snapshot();
- ///
- public long GetMemoryFootprint()
- {
- // Estimated footprint: 64 bytes × tracked item handles (one gw subscription entry
- // per bound tag) + 256 bytes × tracked driver subscriptions (registry overhead per
- // OPC UA monitored item). Returns 0 when no subscriptions are active. These
- // constants are conservative — a 50k-tag set occupies ~3 MB and registers clearly
- // with the server's cache-flush heuristic.
- const long BytesPerItemHandle = 64L; // TagBinding + reverse-map entry
- const long BytesPerSubscription = 256L; // SubscriptionEntry overhead
- return (_subscriptions.TrackedItemHandleCount * BytesPerItemHandle)
- + (_subscriptions.TrackedSubscriptionCount * BytesPerSubscription);
- }
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ===== ITagDiscovery (PR 4.1) =====
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index 53df61f5..e811179c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -62,21 +62,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
///
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
{
- ///
- /// Coarse per-entry byte weights behind . The contract asks
- /// for an approximate driver-attributable footprint that Core polls every 30 s to
- /// decide whether to ask for a flush, so an order-of-magnitude estimate from the two things
- /// that actually scale with a plant (declared data items, live observations) is the right
- /// precision. Measuring real retained size would need a heap walk per poll.
- ///
- private const int ProbeModelBytesPerDataItem = 512;
-
- /// Estimated retained bytes per live dataItemId → snapshot entry.
- private const int IndexBytesPerEntry = 256;
-
- /// Estimated retained bytes per authored .
- private const int TagBytesPerEntry = 256;
-
///
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's BadNoCommunication, which means the Agent answered and said the
@@ -323,15 +308,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
///
///
- /// Why one field instead of three. These three values are read outside
- /// — by , by
- /// (Task 12's browse), and by
- /// — while every write happens under it. As three
- /// separate fields they could be observed mid-update: a reader could see a dropped
- /// ProbeModel beside a stale non-zero data-item count and report a footprint for
- /// a cache that no longer exists, or pair one initialize's client with the previous
- /// one's model. Packing them makes every observation internally consistent by
- /// construction, with no lock on the read path.
+ /// Why one field instead of several. These values are read outside
+ /// — by and by
+ /// (Task 12's browse) — while every write
+ /// happens under it. As separate fields they could be observed mid-update, pairing one
+ /// initialize's client with the previous one's model. Packing them makes every
+ /// observation internally consistent by construction, with no lock on the read path.
+ /// (A third value, a cached data-item count, lived here until Gitea #525 deleted its
+ /// only reader — GetMemoryFootprint — along with the rest of that dead surface.)
///
///
/// Why not simply take the lock in those readers. Both readers await the
@@ -423,8 +407,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
/// The cached /probe device model, or null when it has not been fetched or was
- /// dropped by . Prefer
- /// , which cannot observe the flushed hole.
+ /// dropped by on an Agent restart. Prefer
+ /// , which cannot observe the dropped hole.
///
internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel;
@@ -596,58 +580,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
public DriverHealth GetHealth() => ReadHealth();
- ///
- ///
- /// Sums the three caches that scale with plant size: the /probe device model (by
- /// declared data item), the live observation index (by indexed data item), and the authored
- /// tag table. See for why the weights are coarse
- /// constants.
- ///
- public long GetMemoryFootprint() =>
- ((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
- + ((long)ObservationIndex.Count * IndexBytesPerEntry)
- + ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry);
-
- ///
- ///
- /// Drops the /probe device model — the browse cache, which is re-derivable from the
- /// Agent at any time — and keeps the observation index, which is required for correctness
- /// (it is the read surface, and re-deriving it would mean a full re-prime the caller did not
- /// ask for). Flushing cannot wedge the driver: every consumer of the model goes through
- /// , which re-fetches and re-caches on a miss.
- ///
- /// The drop is a single of a whole new
- /// , so the model and its data-item count can never be seen
- /// out of step (which would skew ), and a flush that
- /// races a concurrent teardown or re-initialize is discarded rather than resurrecting
- /// the session it was reading.
- ///
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
- {
- var session = Volatile.Read(ref _session);
- if (session?.ProbeModel is null)
- {
- return Task.CompletedTask;
- }
-
- var flushed = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
- if (!ReferenceEquals(Interlocked.CompareExchange(ref _session, flushed, session), session))
- {
- // Someone swapped the session while we were building the flushed copy — their state is
- // newer than ours, and publishing would undo it. Whatever they installed is either a
- // teardown (no cache to flush) or a fresh initialize (a cache the operator's budget
- // breach never saw), so dropping this flush is correct, not merely safe.
- return Task.CompletedTask;
- }
-
- _logger.LogInformation(
- "MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.",
- _driverInstanceId, session.ProbeModelDataItemCount);
-
- return Task.CompletedTask;
- }
-
///
///
///
@@ -963,9 +895,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
///
/// The model comes from , never from
- /// . may have
- /// dropped the cache under memory pressure; reading the field directly would make a
- /// flushed driver browse as an Agent with no data items, permanently.
+ /// . may have
+ /// dropped the cache after an Agent restart; reading the field directly would make such
+ /// a driver browse as an Agent with no data items, permanently.
///
///
/// This throws, and that is the opposite of 's posture on
@@ -1250,8 +1182,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
/// Publishing the answer into is the opposite
/// mistake: it would put a second writer on a field the lifecycle owns under a
- /// compare-and-swap, and would silently re-warm a cache that
- /// — or an Agent restart, see
+ /// compare-and-swap, and would silently re-warm a cache that an Agent restart — see
/// — deliberately dropped. The parsed model is
/// discarded; only the fact that a request completed is used.
///
@@ -1473,9 +1404,9 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// , which reads
/// ; with the cache still warm that call answers
/// from a model describing a process that no longer exists, and the "rediscovery" would
- /// diff the stale tree against itself and change nothing. Dropped through the same
- /// compare-and-swap uses, so a lifecycle change
- /// landing mid-drop wins rather than being undone.
+ /// diff the stale tree against itself and change nothing. Dropped through a
+ /// compare-and-swap, so a lifecycle change landing mid-drop wins rather than being
+ /// undone.
///
///
/// Once per change, not once per chunk. Every chunk after a restart carries the new
@@ -1514,12 +1445,16 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
///
- /// Drops the cached /probe device model so the next consumer re-fetches it. Same
- /// compare-and-swap discipline as — retried, because
- /// unlike a flush this drop is a correctness requirement rather than a courtesy: losing the
- /// race must not leave a model from a dead Agent installed.
+ /// Drops the cached /probe device model so the next consumer re-fetches it.
+ /// internal rather than private so the discovery tests can exercise the real
+ /// drop path; production calls it from .
+ /// Compare-and-swap, and retried — this drop is a correctness requirement rather
+ /// than a courtesy: losing the race must not leave a model from a dead Agent installed.
+ /// (The courtesy version, FlushOptionalCachesAsync, took the same lock-free
+ /// approach but gave up on a lost race; it went with the rest of the unconsumed
+ /// memory-pressure surface in Gitea #525.)
///
- private void InvalidateProbeModel()
+ internal void InvalidateProbeModel()
{
while (true)
{
@@ -1529,7 +1464,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
return;
}
- var dropped = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
+ var dropped = session with { ProbeModel = null };
if (ReferenceEquals(Interlocked.CompareExchange(ref _session, dropped, session), session))
{
return;
@@ -2191,7 +2126,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// sequence that had just been rejected.
///
///
- /// Compare-and-swap for the same reason uses one:
+ /// Compare-and-swap for the same reason uses one:
/// a lifecycle change landing mid-update has newer state, so this write is dropped
/// rather than replayed over it.
///
@@ -2221,7 +2156,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// The /probe device model: the cached copy when warm, otherwise a fresh
/// deadline-bounded /probe that re-populates the cache. This is the accessor every
/// model consumer (Task 12's DiscoverAsync) must use, so that
- /// can never leave browse permanently disabled.
+ /// can never leave browse permanently disabled.
///
///
/// Lock-free by design — see the remarks for why this does not take
@@ -2261,7 +2196,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
// Publish only onto the session we actually fetched against.
- var recached = session with { ProbeModel = model, ProbeModelDataItemCount = CountDataItems(model) };
+ var recached = session with { ProbeModel = model };
_ = Interlocked.CompareExchange(ref _session, recached, session);
return model;
@@ -2416,7 +2351,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
// cache, and the pump's opening cursor together or none of them.
Volatile.Write(
ref _session,
- new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence));
+ new AgentSession(client, probe, current.InstanceId, current.NextSequence));
var index = new MTConnectObservationIndex(binding.IndexTags);
index.Apply(current);
@@ -2738,13 +2673,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|| current.SampleIntervalMs != incoming.SampleIntervalMs
|| current.SampleCount != incoming.SampleCount;
- ///
- /// Counts every data item the device model declares. Done once, when the model is cached,
- /// rather than re-walking the tree on every 30 s poll.
- ///
- private static int CountDataItems(MTConnectProbeModel model) =>
- model.Devices.Sum(d => d.AllDataItems().Count());
-
///
/// Runs one unary agent call under a deadline linked to the caller's token. Belt-and-braces
/// over the production client's own per-call deadline: the seam does not oblige an
@@ -3196,17 +3124,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
/// The live agent client and the /probe cache derived from it, as one immutable
/// unit. Immutable so that publishing a change is a single reference swap — see the
- /// remarks for why the three values must move together.
+ /// remarks for why these values must move together.
///
/// The agent client this session owns and will dispose on teardown.
///
- /// The cached device model, or null once has
+ /// The cached device model, or null once has
/// dropped it. Never null at the moment a session is first published.
///
- ///
- /// Data items declared by , counted once at cache time and
- /// always 0 when it is null.
- ///
///
/// The Agent's instanceId as last observed. Updated in place (by
/// ) when the pump sees the Agent restart, so the
@@ -3220,7 +3144,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
private sealed record AgentSession(
IMTConnectAgentClient Client,
MTConnectProbeModel? ProbeModel,
- int ProbeModelDataItemCount,
long InstanceId,
long NextSequence);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 3d972795..08b845cd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -282,10 +282,6 @@ public sealed class ModbusDriver
/// Barrier-protected publish of a new _health snapshot.
///
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
- ///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
index e8826724..7c17e2fc 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -88,15 +88,6 @@ public sealed class MqttDriver
/// The publish failed, or the driver has no live broker connection to publish through.
private const uint StatusBadCommunicationError = 0x80050000u;
- ///
- /// Rough per-tag driver-attributable cost: the parsed (five
- /// strings — RawPath, topic, JSONPath and the record header) plus its cached
- /// and the two dictionary entries that index it. An estimate
- /// by construction — GetMemoryFootprint exists to drive a cache-budget decision, not
- /// to be exact.
- ///
- private const long ApproxBytesPerAuthoredTag = 512;
-
///
/// The single folder streams every variable under — and therefore
/// the only subtree path a rediscovery ScopeHint can name truthfully. See
@@ -429,24 +420,6 @@ public sealed class MqttDriver
};
}
- ///
- /// Approximate driver-attributable footprint: the authored definition table and the
- /// last-value cache that indexes it. Both are sized by the authored tag count, which is the
- /// only thing a deployment can grow.
- ///
- /// The approximate memory footprint in bytes.
- public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag;
-
- ///
- /// No-op, deliberately. This driver holds no optional cache: the authored table is the
- /// address space itself, and the last-value cache is — MQTT is
- /// subscribe-first, so a dropped value is not re-fetchable, and flushing it would turn every
- /// node Bad until its topic next published, which for a slow-changing tag can be never.
- /// Birth/alias state (Sparkplug, P2) is correctness state for the same reason.
- ///
- /// Unused; present for the shape.
- /// A completed task.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index 26dece00..10730d77 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -129,12 +129,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// The that owns .
private CertificateValidator? _certValidatorRef;
///
- /// Approximate count of discovered nodes (folders + variables). Updated by
- /// and used to report a non-zero
- /// to the Core allocation-slope detector.
- ///
- private volatile int _discoveredNodeCount;
- ///
/// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery
/// when the session's keep-alive channel reports a bad status. Null outside the
/// reconnecting window; constructed lazily inside the keep-alive handler. Guarded by
@@ -644,23 +638,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
///
public DriverHealth GetHealth() => _health;
- ///
- // Each discovered node (folder or variable) contributes ~512 bytes to cover the
- // DriverAttributeInfo record, the browse-name string, and the stable nsu= reference string
- // stored in the address-space builder. The real number depends on string length + box sizes;
- // the constant is conservative enough that a 10k-node remote server reports ~5 MB — well
- // within the budget and detectable by the Core slope alarm.
- public long GetMemoryFootprint() => _discoveredNodeCount * 512L;
-
- ///
- // The OPC UA Client driver holds no independently-flushable cache beyond what the
- // address-space builder retains — a flush here resets the discovered-node footprint
- // counter and signals Core that re-discovery will rebuild it cleanly from the remote server.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
- {
- _discoveredNodeCount = 0;
- return Task.CompletedTask;
- }
// ---- IReadable ----
@@ -926,9 +903,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken)
.ConfigureAwait(false);
- // Update the footprint counter so GetMemoryFootprint() returns a real estimate
- // after each discovery pass.
- _discoveredNodeCount = discovered;
}
finally { _gate.Release(); }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index c7949bb4..02c3e8d7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -328,9 +328,6 @@ public sealed class S7Driver
///
public DriverHealth GetHealth() => _health;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-
///
/// True when carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass "{}" or
@@ -481,11 +478,6 @@ public sealed class S7Driver
///
private static readonly HashSet UnimplementedDataTypes = new();
- ///
- // The Plc instance + one 240-960 byte PDU buffer is under 4 KB; returns 0 because the
- // IDriver contract asks for a driver-attributable growth number and S7.Net doesn't expose one.
- public long GetMemoryFootprint() => 0;
-
// ---- IReadable ----
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
index bd43f089..2b0bd7a1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser
///
///
- /// Sourced from , the driver's interim local constant, rather
- /// than from DriverTypeNames — the shared constant set is added by the driver-factory task,
- /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is
- /// registered against it. Repointing that one constant repoints this too.
+ /// Sourced from the shared set, which is what the driver's own
+ /// SqlDriver.DriverTypeName resolves to. This used to read that interim local constant, and
+ /// the comment here said so; repointing it was deferred until a factory was registered against
+ /// (else DriverTypeNamesGuardTests goes red). One exists
+ /// now, and reading the shared constant is also what lets this browser stay off the runtime driver
+ /// — SqlDriver is the only type it still needed from there (Gitea #526).
///
- public string DriverType => SqlDriver.DriverTypeName;
+ public string DriverType => DriverTypeNames.Sql;
/// The environment variable a connectionStringRef resolves through.
/// The authored reference name.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
index daa01255..127b7caa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj
@@ -11,15 +11,34 @@
-
-
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
similarity index 97%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index 13f26329..31dae6ef 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// . Any code path that builds SQL by concatenating an authored tag field
/// without going through is a defect (design §8.1).
/// Quoting is the backstop, not the only defence. Design §8.1's catalog gate is
-/// implemented: resolves every authored table/column against the live
+/// implemented: SqlCatalogGate resolves every authored table/column against the live
/// catalog at Initialize and replaces it with the catalog's own spelling, so an identifier
/// reaching on the poll path is a string this driver read back out of
/// / / — not
@@ -96,7 +96,7 @@ public interface ISqlDialect
/// principal — T-SQL's SELECT SCHEMA_NAME(). Takes no parameters.
///
///
- /// Needed by : a tag authored as TagValues rather than
+ /// Needed by SqlCatalogGate: a tag authored as TagValues rather than
/// dbo.TagValues has to be looked up in some schema, and guessing dbo would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
similarity index 98%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index ca38e8bb..1f9b4c5b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect
/// The rejection messages deliberately do not echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.
/// This is a backstop, not the only defence. Design §8.1's catalog gate
- /// () resolves an authored table/column against the live catalog at
+ /// (SqlCatalogGate) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
new file mode 100644
index 00000000..dd9f264b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj
@@ -0,0 +1,30 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+
+ ZB.MOM.WW.OtOpcUa.Driver.Sql
+ ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
index c69de1ec..f837285d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -397,14 +397,6 @@ public sealed class SqlDriver
///
public DriverHealth GetHealth() => ReadHealth();
- /// No caches, no symbol table, no connection between polls — the footprint is the tag table.
- /// Always zero.
- public long GetMemoryFootprint() => 0;
-
- /// Nothing optional to flush: the authored table is required for correctness.
- /// Cancellation token for the operation.
- /// A completed task.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
index 66ddb720..559f1ab6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj
@@ -13,6 +13,9 @@
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 4acdbdf2..e041066f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -208,18 +208,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
///
public DriverHealth GetHealth() => _health;
- ///
- // This driver holds no flushable symbol cache — BrowseSymbolsAsync streams and discards; the
- // footprint reflects live allocations only: ~256 bytes per pre-declared tag (tag-definition
- // record + dictionary overhead) and ~512 bytes per active native subscription.
- public long GetMemoryFootprint() =>
- (_tagsByRawPath.Count * 256L) + (_nativeSubs.Count * 512L);
-
- ///
- // No flushable cache exists in this driver — the symbol table is streamed fresh on every
- // DiscoverAsync call. This is a no-op but is deliberately present so Core's cache-budget
- // enforcement sees a compliant Tier-A driver.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Gets the count of configured devices.
internal int DeviceCount => _devices.Count;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
index b616571a..94a76250 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
@@ -269,7 +269,7 @@ else
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
- var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
+ var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
index 978e4d9c..bd58ccf3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
@@ -22,10 +22,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
- // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
- // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
- // the factory" — went stale when the constant landed; the key is correct either way.
- [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
+ [DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
index 863f1552..fb713cf6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
@@ -24,8 +24,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
- // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
- [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
+ [DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
index a81f6b25..4b1727c9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
@@ -41,8 +41,10 @@
+ .Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is
+ reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the
+ runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own
+ csproj comment. -->
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 6d257dbd..950909b7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -1009,9 +1009,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// alerts row is published here — driver comms health has its own status surface
/// (); a row per condition would be alarm-fatigue.
///
+ ///
+ /// #481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency
+ /// mux. #478 gave a scripted condition the worst quality of its input tags, but only for
+ /// inputs whose driver actually publishes a Bad data change. A poll driver whose device
+ /// goes unreachable publishes nothing at all — it emits this signal and goes silent — so the
+ /// engine kept its last Good value, WorstInputStatus stayed Good, and worse,
+ /// AreInputsReady stayed true, so the predicate went on evaluating a stale value after
+ /// the source had died. closes that by pushing a
+ /// synthetic Bad for every value ref the driver owns.
+ ///
///
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
+ // #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
+ // publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
+ // return for exactly that reason.
+ if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId);
+
if (_opcUaPublishActor is null) return;
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
@@ -1038,6 +1053,68 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
}
+ ///
+ /// #481 (Layer 4) — push a synthetic into the dependency
+ /// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
+ /// the source die instead of holding its last Good value indefinitely.
+ ///
+ ///
+ ///
+ /// The ref set already existed. #481 asked for a driver → all-tag-refs map to be built;
+ /// is exactly that, keyed (DriverInstanceId, RawPath) and
+ /// rebuilt from the composition's RawTags ∪ UnsReferenceVariables on every apply.
+ /// Alarm-bearing tags live in the separate and are
+ /// deliberately absent here — a native condition is not a mux value, and its comms quality is
+ /// annotated by the caller through the Part 9 path instead.
+ ///
+ ///
+ /// Value null, not the last-known value. The honest statement is "there is no
+ /// current value", and the engine's AreInputsReady gate holds the predicate on any Bad
+ /// regardless, so no script observes the null. It does mean the Bad must survive
+ /// WorstInputStatus, which until #481 skipped every null-valued entry as cold start —
+ /// hence the paired change stamping never-pushed paths BadWaitingForInitialData so the two
+ /// are tellable apart. Without that half this fan-out would be silently inert.
+ ///
+ ///
+ /// Nothing is pushed on reconnect, deliberately. Publishing Good would assert a value
+ /// quality this actor has not observed. Recovery rides the driver's own republish:
+ /// DriverInstanceActor calls DetachSubscription on the Connected → Reconnecting
+ /// edge, which nulls the subscription handle, so the reconnect's ResubscribeDesired builds
+ /// a fresh subscription whose PollGroupEngine initial push runs with
+ /// forceRaise: true and re-publishes every tag with its real quality. That detach is what
+ /// makes recovery work: without it the poll engine would compare against its retained
+ /// LastValues, see an unchanged Good value, raise nothing, and leave these refs Bad
+ /// forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert.
+ ///
+ ///
+ /// Ungated by redundancy role, for the same reason as the native-condition annotation above: a
+ /// Secondary must keep its scripted-alarm evaluation coherent for failover. The mux fan-out
+ /// reaches only refs something is actually subscribed to, so a driver whose tags feed no script
+ /// and no virtual tag costs one dictionary scan and no delivery.
+ ///
+ ///
+ /// The driver child that just reported comms loss.
+ private void FanCommsLossToDependencyMux(string driverInstanceId)
+ {
+ if (_dependencyMux is null) return;
+
+ var ts = DateTime.UtcNow;
+ var pushed = 0;
+ foreach (var (driverId, rawPath) in _nodeIdByDriverRef.Keys)
+ {
+ if (driverId != driverInstanceId) continue;
+
+ _dependencyMux.Tell(new DriverInstanceActor.AttributeValuePublished(
+ driverInstanceId, rawPath, Value: null, OpcUaStatus.BadNoCommunication, ts));
+ pushed++;
+ }
+
+ if (pushed > 0)
+ _log.Debug(
+ "DriverHost {Node}: driver {Driver} disconnected — pushed BadNoCommunication for {Count} mux ref(s)",
+ _localNode, driverInstanceId, pushed);
+ }
+
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
@@ -2238,10 +2315,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
- ///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private void HandleRestartDriver(RestartDriver msg)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
index b58e6d7d..d0734f45 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
@@ -26,11 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
///
public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
{
- // OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The
- // codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver,
- // the Wonderware historian client) inline this same 0x80000000u, and the engine's own
- // AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
- private const uint StatusBad = 0x80000000u;
+ // The status an "unknown path" snapshot carries — a path nothing has ever pushed to.
+ //
+ // This is deliberately BadWaitingForInitialData rather than the bare Bad (0x80000000u) it used
+ // to be, and the distinction is load-bearing (#481, closing #478 Finding 3). Both codes are Bad,
+ // so the engine's AreInputsReady gate treats them identically and the cold-start hold is
+ // unchanged. What changed is that "no data has ever arrived" is now tellable apart from "a
+ // driver pushed Bad", which previously looked identical whenever the pushed Bad carried a null
+ // value — the shape a comms-lost driver produces. WorstInputStatus keys off exactly this code to
+ // decide which of the two it is looking at.
+ //
+ // The code is also the honest one: Part 4 defines BadWaitingForInitialData as precisely this
+ // state, and it is what a freshly materialised OPC UA variable already reports before its first
+ // publish.
+ private const uint StatusNeverPushed = OpcUaStatus.BadWaitingForInitialData;
// Intentionally never pruned: cold-start semantics depend on retained last values so
// ReadTag can answer synchronously for any path that has ever been pushed.
@@ -90,9 +100,9 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
// No value has been pushed for this path yet — return a Bad-quality placeholder so
// the engine's cold-start guard holds the prior condition rather than NRE-ing on a
- // null value cast.
+ // null value cast. See StatusNeverPushed for why the specific code matters.
var now = _clock();
- return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
+ return new DataValueSnapshot(Value: null, StatusCode: StatusNeverPushed, SourceTimestampUtc: null, ServerTimestampUtc: now);
}
// No-replay contract: a new subscriber does NOT receive a synthetic initial
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs
index 78f45228..d8fba909 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs
@@ -72,8 +72,6 @@ internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
- public long GetMemoryFootprint() => 0;
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate.
@@ -92,8 +90,6 @@ internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string
return Task.CompletedTask;
}
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
- public long GetMemoryFootprint() => 0;
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// Configurable IDriverFactory: delegates construction to a supplied func and records what it made.
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs
new file mode 100644
index 00000000..c8e70589
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs
@@ -0,0 +1,152 @@
+using System.Text.RegularExpressions;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+///
+/// Pins the layering rule for every bespoke driver browser in the tree: a *.Browser
+/// project must not reach its runtime *.Driver project, by any path.
+///
+///
+///
+/// Why this exists rather than a per-driver test. Gitea #512 removed this violation from
+/// the MQTT browser and pinned it with MqttBrowserLayeringTests. The identical violation was
+/// sitting in Driver.Sql.Browser the whole time and no guard saw it, because the guard was
+/// written for the driver that happened to have an issue filed (#526). This test enumerates the
+/// browsers off disk, so a browser added later is covered on the day it is added.
+///
+///
+/// Why it reads csproj files rather than loaded assemblies. The cost being prevented is a
+/// build/deploy-graph cost: the AdminUI references these browsers, so a runtime driver reachable
+/// from one lands in the AdminUI's output whether or not a single type is used. The compiler emits
+/// no assembly reference for an unused ProjectReference, so an assembly-reference test —
+/// which is what MqttBrowserLayeringTests does, and says so — cannot see that case. The two
+/// are complementary: this one catches the reference, that one catches the usage.
+///
+///
+/// The walk is transitive. Asserting only on direct references would pass a browser that
+/// reached its runtime driver through a shared leaf, which is the same defect one hop further out
+/// — the hazard MqttBrowserLayeringTests guards for .Transport by hand.
+///
+///
+[Trait("Category", "Unit")]
+public sealed class DriverBrowserLayeringTests
+{
+ ///
+ /// Browsers known to exist when this guard was written. Asserted as a MINIMUM, not an exact set —
+ /// a new browser must be covered automatically, so this list only defends against the discovery
+ /// itself silently finding nothing (a wrong repo root, a moved directory), which would make every
+ /// assertion below vacuously true.
+ ///
+ private static readonly string[] KnownBrowsers =
+ [
+ "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser",
+ "ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser",
+ ];
+
+ [Fact]
+ public void Every_bespoke_browser_is_discovered()
+ {
+ // The positive control for the whole file. Every other test here is an ABSENCE assertion over a
+ // discovered set, so an empty set passes them all.
+ var discovered = DiscoverBrowsers().Select(p => Path.GetFileNameWithoutExtension(p)!).ToArray();
+
+ foreach (var known in KnownBrowsers)
+ discovered.ShouldContain(known);
+ }
+
+ [Fact]
+ public void Every_browser_declares_at_least_one_project_reference()
+ // The second half of the control: proves the csproj parse actually returns something, so a
+ // regex that silently stopped matching cannot turn the rule below into a no-op.
+ => DiscoverBrowsers().ShouldAllBe(p => DirectReferences(p).Count > 0);
+
+ [Fact]
+ public void No_browser_reaches_its_runtime_driver_project()
+ {
+ var offenders = new List();
+
+ foreach (var browser in DiscoverBrowsers())
+ {
+ var browserName = Path.GetFileNameWithoutExtension(browser)!;
+
+ // "ZB…Driver.Sql.Browser" -> "ZB…Driver.Sql": the sibling runtime project.
+ var runtime = browserName[..^".Browser".Length];
+
+ var reached = TransitiveReferences(browser);
+ if (reached.Contains(runtime))
+ offenders.Add($"{browserName} -> {runtime}");
+ }
+
+ offenders.ShouldBeEmpty(
+ "a driver browser reaches its runtime driver project. The AdminUI references these browsers, "
+ + "so the whole driver runtime lands in its build/deploy graph — the cost is invisible, which "
+ + "is exactly why it needs a guard. If shared code is needed, put transport-free protocol or "
+ + "contract types in .Contracts, and anything that must name a provider/vendor type in a leaf "
+ + "project of its own (see Driver.Mqtt.Transport and Driver.Sql.Dialect). Do NOT resolve this "
+ + "by copying the shared code into the browser: for anything touching credentials, TLS or "
+ + "identifier quoting, a second copy is a silent security divergence rather than a build break.");
+ }
+
+ // ---- csproj graph ------------------------------------------------------------------------------
+
+ private static readonly Regex ProjectReferencePattern = new(
+ " DiscoverBrowsers() =>
+ [.. Directory
+ .EnumerateDirectories(Path.Combine(RepoRoot.Value, "src", "Drivers"), "*.Browser")
+ .Select(d => Path.Combine(d, Path.GetFileName(d) + ".csproj"))
+ .Where(File.Exists)
+ .OrderBy(p => p, StringComparer.Ordinal)];
+
+ /// Absolute paths of the csproj files one project references directly.
+ private static IReadOnlyList DirectReferences(string csprojPath)
+ {
+ var dir = Path.GetDirectoryName(csprojPath)!;
+
+ return
+ [
+ .. ProjectReferencePattern
+ .Matches(File.ReadAllText(csprojPath))
+ .Select(m => Path.GetFullPath(
+ Path.Combine(dir, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar))))
+ ];
+ }
+
+ /// Assembly-ish names of every project reachable from .
+ private static HashSet TransitiveReferences(string csprojPath)
+ {
+ var reached = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var pending = new Stack(DirectReferences(csprojPath));
+
+ while (pending.Count > 0)
+ {
+ var next = pending.Pop();
+ if (!reached.Add(Path.GetFileNameWithoutExtension(next)!) || !File.Exists(next)) continue;
+
+ foreach (var onward in DirectReferences(next)) pending.Push(onward);
+ }
+
+ return reached;
+ }
+
+ ///
+ /// Walks up from the test binary until the solution file appears. The tests run out of
+ /// bin/Debug/net10.0, so no fixed relative depth is reliable across host layouts.
+ ///
+ private static readonly Lazy RepoRoot = new(() =>
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
+ dir = dir.Parent;
+
+ dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
+ return dir.FullName;
+ });
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
index 6ad0a3fc..61316199 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
@@ -38,11 +38,23 @@ public sealed class FakeUpstream : ITagUpstreamSource
}
}
- /// Reads the current value of a tag, or returns a bad-status snapshot if not set.
+ ///
+ /// OPC UA BadWaitingForInitialData — the status a never-pushed path reports, matching what
+ /// the production DependencyMuxTagUpstreamSource stamps.
+ /// This used to be 0x80340000 (BadNotConnected), a different Bad code from the
+ /// one production uses. That drift was invisible while WorstInputStatus skipped every
+ /// null-valued entry — any Bad code behaved the same. #481 made the specific code load-bearing (it
+ /// is how cold start is told apart from a driver reporting Bad), so a fake disagreeing with
+ /// production here would let every alarm with an unset input report Bad in tests and Good in the
+ /// real runtime, or vice versa.
+ ///
+ public const uint StatusNeverPushed = 0x80320000u;
+
+ /// Reads the current value of a tag, or returns a never-pushed snapshot if not set.
/// The tag path to read.
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v
- : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
+ : new DataValueSnapshot(null, StatusNeverPushed, null, DateTime.UtcNow);
/// Subscribes an observer to tag changes for the given path.
/// The tag path to subscribe to.
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
index 1748070a..12786bb0 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
@@ -1103,6 +1103,75 @@ public sealed class ScriptedAlarmEngineTests
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
+ // -------------------------------------------------------------------------
+ // #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// A driver reporting Bad with no value counts as Bad quality.
+ /// This is the hole #478 Finding 3 recorded. WorstInputStatus skipped every entry whose
+ /// value was null as "no data yet", which is indistinguishable from a real fault when the fault
+ /// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the
+ /// case being skipped was the case that mattered most. Before the fix this test sees no
+ /// QualityChanged at all.
+ ///
+ [Fact]
+ public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ // The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value.
+ up.Push("Temp", null, 0x80310000u); // BadNoCommunication
+ await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
+
+ var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
+ (q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u);
+ // The sub-code survives, so an operator can tell comms loss from any other fault.
+ q.WorstInputStatusCode.ShouldBe(0x80310000u);
+ }
+
+ ///
+ /// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed
+ /// does NOT drag the condition Bad.
+ /// The falsifying control for the test above. Deleting the null-skip outright — the obvious
+ /// way to make a null-valued Bad count — would also make every not-yet-published input count, which
+ /// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its
+ /// first push, flooding the quality path with load-time annotations. Both tests pass only if the
+ /// engine keys off the never-pushed status code specifically.
+ ///
+ [Fact]
+ public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ // "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and
+ // gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has.
+ using var eng = Build(up, out _);
+
+ // Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a
+ // CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it
+ // makes this assertion vacuous, and the first draft of this test was exactly that: it passed
+ // against the broken behaviour because the flash had already happened by the time it was listening.
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ await eng.LoadAsync(
+ [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")],
+ TestContext.Current.CancellationToken);
+
+ up.Push("Temp", 60, 0u);
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+
+ events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
+ }
+
private static async Task WaitForAsync(Func cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
index 1f6ff319..98586ed2 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
@@ -45,12 +45,6 @@ public sealed class DriverHostTests
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
-
- /// Flushes optional caches asynchronously.
- /// The cancellation token.
- public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// Verifies that registering a driver initializes it and tracks its health.
@@ -248,13 +242,6 @@ public sealed class DriverHostTests
/// Gets the current health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
-
- /// Flushes optional caches asynchronously.
- /// The cancellation token.
- public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
-
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs
index f2d71510..3ea21a63 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/GenericDriverNodeManagerTests.cs
@@ -187,11 +187,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
/// Gets the current health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
- /// Flushes optional caches in the driver.
- /// Cancellation token (unused in test double).
- public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
/// Discovers the address space by throwing an exception.
/// The builder used to construct the address space.
@@ -224,11 +219,6 @@ public sealed class GenericDriverNodeManagerTests
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
/// Gets the current health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
- /// Flushes optional caches in the driver.
- /// Cancellation token.
- public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
/// Discovers the address space and registers alarm conditions.
/// The builder used to construct the address space.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
index 8e860f9e..5be7525d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
@@ -573,18 +573,6 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
- /// Verifies that FlushOptionalCachesAsync clears the template cache.
- [Fact]
- public async Task FlushOptionalCachesAsync_clears_template_cache()
- {
- var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
- drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
- drv.TemplateCache.Count.ShouldBe(1);
-
- await drv.FlushOptionalCachesAsync(CancellationToken.None);
- drv.TemplateCache.Count.ShouldBe(0);
- }
-
// ---- helpers ----
/// Test implementation of IAddressSpaceBuilder that records calls.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs
index 2d0554a8..f56e5893 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs
@@ -210,9 +210,9 @@ public sealed class AbCipFetchUdtShapeTests
shape.ShouldBeNull();
}
- /// Verifies that FlushOptionalCachesAsync empties the template cache.
+ /// Verifies that a cleared template cache is re-populated from the network.
[Fact]
- public async Task FlushOptionalCachesAsync_empties_template_cache()
+ public async Task A_cleared_template_cache_refetches_from_the_controller()
{
var factory = new FakeTemplateReaderFactory
{
@@ -230,7 +230,10 @@ public sealed class AbCipFetchUdtShapeTests
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
- await drv.FlushOptionalCachesAsync(CancellationToken.None);
+ // Gitea #525 deleted AbCipDriver.FlushOptionalCachesAsync, which is what used to clear this;
+ // the cache's own Clear() is now the only reset, and nothing in production calls it (see the
+ // AbCipTemplateCache remarks). What this test still pins is the cache-miss path below.
+ drv.TemplateCache.Clear();
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
index 2e3f6a3f..875dde8d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
@@ -174,9 +174,6 @@ public sealed class GalaxyDriverFactoryTests
await driver.ShutdownAsync(CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
-
- driver.GetMemoryFootprint().ShouldBe(0);
- await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// Verifies that reinitializing with equivalent config refreshes health.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs
index f135218c..b455ebe2 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs
@@ -10,8 +10,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
///
-/// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate)
-/// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate).
+/// Driver-level infrastructure tests for finding Driver.Galaxy-007 (dispose-CTS gate).
+/// Driver.Galaxy-011 (a non-zero GetMemoryFootprint estimate) was also covered here until
+/// Gitea #525 deleted that member from IDriver as unconsumed surface.
///
public sealed class GalaxyDriverInfrastructureTests
{
@@ -22,47 +23,11 @@ public sealed class GalaxyDriverInfrastructureTests
new GalaxyReconnectOptions());
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
-
- /// Verifies that memory footprint is zero when there are no subscriptions.
- [Fact]
- public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
- {
- var sub = new NoOpSubscriber();
- using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
-
- driver.GetMemoryFootprint().ShouldBe(0L);
- }
-
- /// Verifies that memory footprint is nonzero after subscriptions are active.
- [Fact]
- public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
- {
- // When subscriptions are active the footprint estimate must be > 0 so the
- // server's memory-pressure mechanism sees the Galaxy driver as a participant.
- var sub = new NoOpSubscriber();
- using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
-
- await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
-
- driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
- }
-
- /// Verifies that memory footprint decreases after unsubscribing.
- [Fact]
- public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
- {
- var sub = new NoOpSubscriber();
- using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
-
- var handle = await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
- var afterSubscribe = driver.GetMemoryFootprint();
- afterSubscribe.ShouldBeGreaterThan(0L);
-
- await driver.UnsubscribeAsync(handle, CancellationToken.None);
- var afterUnsubscribe = driver.GetMemoryFootprint();
- afterUnsubscribe.ShouldBeLessThan(afterSubscribe,
- "footprint must decrease when subscriptions are removed");
- }
+ //
+ // Three tests lived here asserting the footprint tracked the subscription registry. The member
+ // was deleted from IDriver in Gitea #525 as unconsumed surface — nothing ever read the number —
+ // and they went with it. The registry sizes they leaned on are still covered directly by the
+ // subscribe/unsubscribe tests below.
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
index 6e02e552..ea6eef6a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
@@ -585,17 +585,21 @@ public sealed class MTConnectDiscoverTests
}
///
- /// …and after has dropped that cache,
- /// discovery re-fetches and still streams the full tree. This is the whole reason discovery
- /// must go through GetOrFetchProbeModelAsync rather than reading the cached field: a
- /// memory-budget flush must never leave browse permanently answering "this agent has no data
- /// items".
+ /// …and after the cache has been dropped, discovery re-fetches and still streams the full
+ /// tree. This is the whole reason discovery must go through GetOrFetchProbeModelAsync
+ /// rather than reading the cached field: a drop must never leave browse permanently
+ /// answering "this agent has no data items".
+ /// Drives — the Agent-restart drop,
+ /// which is the only thing that empties this cache in production. It used to drive
+ /// FlushOptionalCachesAsync, deleted with the rest of the unconsumed memory-pressure
+ /// surface in Gitea #525; that path had no production caller, so this now tests a case that
+ /// actually occurs.
///
[Fact]
- public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
+ public async Task Discover_refetches_the_probe_model_after_the_cache_is_dropped()
{
var (driver, client) = await InitializedDriverAsync();
- await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
var cap = await DiscoverAsync(driver);
@@ -689,7 +693,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
{
var (driver, client) = await InitializedDriverAsync();
- await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ driver.InvalidateProbeModel();
client.ProbeFailure = new HttpRequestException("agent down");
var builder = new CapturingBuilder();
@@ -705,7 +709,7 @@ public sealed class MTConnectDiscoverTests
public async Task Discover_propagates_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
- await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ driver.InvalidateProbeModel();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
index b413261d..ef3dddfe 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
@@ -590,31 +590,21 @@ public sealed class MTConnectDriverLifecycleTests
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
- // ---- footprint + flush ----
+ // ---- dropping the probe cache ----
+ //
+ // These used to drive FlushOptionalCachesAsync. Gitea #525 deleted it as an unconsumed IDriver
+ // member, along with GetMemoryFootprint and the footprint test that only existed to assert on
+ // it. What survives is InvalidateProbeModel — the Agent-restart drop, which is the only thing
+ // that empties this cache in production, and which has the same retired-session guard and the
+ // same "browse must not be left permanently empty" obligation.
[Fact]
- public async Task Memory_footprint_grows_when_initialize_populates_the_caches()
+ public void Dropping_the_probe_cache_keeps_the_observation_index()
{
- var (driver, _) = NewDriver();
+ var (driver, _) = InitializedDriverAsync().GetAwaiter().GetResult();
- var before = driver.GetMemoryFootprint();
- await driver.InitializeAsync("{}", CancellationToken.None);
- var after = driver.GetMemoryFootprint();
+ driver.InvalidateProbeModel();
- // Before init the only config-attributable state is the two authored tags.
- before.ShouldBeLessThan(after);
- after.ShouldBeGreaterThan(0);
- }
-
- [Fact]
- public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
- {
- var (driver, _) = await InitializedDriverAsync();
- var before = driver.GetMemoryFootprint();
-
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
-
- driver.GetMemoryFootprint().ShouldBeLessThan(before);
driver.CachedProbeModel.ShouldBeNull();
// The index is required for correctness (it IS the read surface) — never flushable.
@@ -624,12 +614,12 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
- public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
+ public async Task Dropping_the_cache_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
{
- // Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
- // cache-budget breach would permanently disable browse on that driver instance.
+ // Task 12's DiscoverAsync reads the probe model. If dropping it left no way back, an Agent
+ // restart would permanently disable browse on that driver instance.
var (driver, client) = await InitializedDriverAsync();
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
+ driver.InvalidateProbeModel();
client.ProbeCallCount.ShouldBe(1);
@@ -679,7 +669,7 @@ public sealed class MTConnectDriverLifecycleTests
// it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
// the caller already handles "not connected" and should have exactly one failure mode.
var (driver, client) = await InitializedDriverAsync();
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
+ driver.InvalidateProbeModel();
// Held locally: the gate is one-shot, so the client has already cleared its own reference by
// the time the request is parked on it.
@@ -712,7 +702,7 @@ public sealed class MTConnectDriverLifecycleTests
});
await driver.InitializeAsync("{}", CancellationToken.None);
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
+ driver.InvalidateProbeModel();
var client = built[0];
var staleModel = client.Probe;
@@ -742,22 +732,22 @@ public sealed class MTConnectDriverLifecycleTests
}
[Fact]
- public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
+ public async Task Dropping_the_cache_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
{
- // Core polls the footprint and asks for a flush on its own schedule, so a flush arriving
- // after the driver was torn down is ordinary, not exotic. It must observe the retired
- // session and do nothing — publishing a "flushed" copy of it would reinstate a session
- // holding an already-disposed client for every later caller.
+ // The /sample pump can see an Agent restart at any moment, so a drop arriving after the
+ // driver was torn down is ordinary, not exotic. It must observe the retired session and do
+ // nothing — publishing a "dropped" copy of it would reinstate a session holding an
+ // already-disposed client for every later caller.
//
- // NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has
- // no await point, so nothing can be interleaved between its read and its publish from a
+ // NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. The drop
+ // has no await point, so nothing can be interleaved between its read and its publish from a
// single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
// lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
// fetch-side CAS, which does have an await point, is covered by the test above.
var (driver, client) = await InitializedDriverAsync();
await driver.ShutdownAsync(CancellationToken.None);
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
+ driver.InvalidateProbeModel();
driver.CachedProbeModel.ShouldBeNull();
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
index 0b9e3e1b..fc96b5c3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
@@ -440,8 +440,8 @@ public sealed class MTConnectHostAndRediscoverTests
///
/// The probe is a liveness check, never a cache writer. Publishing its answer into
/// AgentSession.ProbeModel would put a second writer on the field the lifecycle owns
- /// under a compare-and-swap, and would silently re-warm a cache that
- /// FlushOptionalCachesAsync (or an Agent restart) deliberately dropped.
+ /// under a compare-and-swap, and would silently re-warm a cache that an Agent restart
+ /// deliberately dropped.
///
[Fact]
public async Task Probe_ticks_never_publish_into_the_session_probe_cache()
@@ -457,10 +457,9 @@ public sealed class MTConnectHostAndRediscoverTests
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeSameAs(original);
- driver.GetMemoryFootprint().ShouldBeGreaterThan(0);
- // …and a flushed cache stays flushed until a real consumer asks for it.
- await driver.FlushOptionalCachesAsync(Ct);
+ // …and a dropped cache stays dropped until a real consumer asks for it.
+ driver.InvalidateProbeModel();
await TickAsync(ticker);
driver.CachedProbeModel.ShouldBeNull();
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
index 79c36015..cc3f1a2f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs
@@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// ProjectReference re-added but never used would not trip it (the compiler emits no
/// reference), so the csproj comment remains the primary record of the decision.
///
+///
+/// That gap is now covered by DriverBrowserLayeringTests (Commons.Tests), which
+/// scans the csproj graph of every *.Browser project in the tree — added for Gitea #526,
+/// after the identical violation was found sitting in Driver.Sql.Browser with no guard
+/// watching it, because this file was written for the one driver that had an issue filed. What
+/// stays here is the MQTT-specific half: the .Transport leaf shape and the
+/// MQTTnet-free-ness of .Contracts.
+///
///
[Trait("Category", "Unit")]
public sealed class MqttBrowserLayeringTests
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
index 7a5f28df..ba49a6c1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
@@ -159,24 +159,6 @@ public sealed class MqttDriverDiscoveryTests
results.ShouldBeEmpty();
}
- ///
- /// The falsifiability pin for the cache-flush contract: the last-value cache backs
- /// IReadable, so flushing it would silently turn every subscribed node Bad under
- /// memory pressure. FlushOptionalCachesAsync must leave it untouched.
- ///
- [Fact]
- public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
- {
- var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
- driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
-
- await driver.FlushOptionalCachesAsync(CancellationToken.None);
-
- var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
- results[0].StatusCode.ShouldBe(0u);
- results[0].Value.ShouldBe("hot");
- }
-
/// A malformed reinitialize delta must never fault the driver, and must not lose the running config.
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
@@ -334,14 +316,6 @@ public sealed class MqttDriverDiscoveryTests
fired.ShouldBe(0);
}
- /// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.
- [Fact]
- public void GetMemoryFootprint_TracksAuthoredTable()
- {
- PlainDriver().GetMemoryFootprint().ShouldBe(0);
- PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
- }
-
/// Shutdown on a never-initialized driver is a no-op, not a null-reference.
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs
index ea8f9221..6da77610 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientMediumFindingsRegressionTests.cs
@@ -13,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// - Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError
/// - Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16
/// - Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning
-/// - Driver.OpcUaClient-013 — GetMemoryFootprint and FlushOptionalCachesAsync contract
/// - Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion
///
///
@@ -97,39 +96,11 @@ public sealed class OpcUaClientMediumFindingsRegressionTests
}
// ---- Driver.OpcUaClient-013 ----
-
- /// Verifies that GetMemoryFootprint returns zero before discovery.
- [Fact]
- public void GetMemoryFootprint_returns_zero_before_first_discovery()
- {
- using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-mem-013");
- // Pre-init: no nodes discovered yet, so footprint is 0.
- drv.GetMemoryFootprint().ShouldBe(0L);
- }
-
- /// Verifies that FlushOptionalCachesAsync completes without throwing.
- [Fact]
- public async Task FlushOptionalCachesAsync_completes_without_throwing()
- {
- // FlushOptionalCachesAsync is a cheap no-op before any discovery. Must not throw.
- using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush-013");
- await Should.NotThrowAsync(async () =>
- await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken));
- }
-
- /// Verifies that FlushOptionalCachesAsync resets the footprint counter.
- [Fact]
- public async Task FlushOptionalCachesAsync_resets_footprint_counter()
- {
- // GetMemoryFootprint uses _discoveredNodeCount * 512. FlushOptionalCachesAsync resets
- // _discoveredNodeCount to 0, so a subsequent call to GetMemoryFootprint returns 0.
- // We drive this through a complete Discovery pass against a stub — instead, test the
- // contract via FlushOptionalCachesAsync clearing a counter we can observe.
- using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-flush2-013");
- drv.GetMemoryFootprint().ShouldBe(0L, "pre-flush should be 0 (no discovery)");
- await drv.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
- drv.GetMemoryFootprint().ShouldBe(0L, "post-flush should still be 0");
- }
+ //
+ // Three tests lived here covering GetMemoryFootprint + FlushOptionalCachesAsync. Both members
+ // were deleted from IDriver in Gitea #525 as unconsumed surface, and these went with them. Worth
+ // noting one was vacuous anyway: it asserted a footprint of 0 before the flush and 0 after, with
+ // no discovery in between, so it would have passed against a flush that did nothing at all.
// ---- Driver.OpcUaClient-015: pure-logic paths ----
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
index 4ccd43cf..88277f85 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
@@ -25,6 +25,13 @@
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs
index ceec98f6..a2ff9911 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs
@@ -155,14 +155,4 @@ public sealed class FakeReconnectDriver : IDriver
public DriverHealth GetHealth() => _reconnecting
? new DriverHealth(DriverState.Reconnecting, _lastSuccess, null)
: new DriverHealth(DriverState.Healthy, _lastSuccess, null);
-
- /// Returns a zero memory footprint (the fake holds no driver-attributable caches).
- /// Always 0.
- public long GetMemoryFootprint() => 0;
-
- /// Flushes optional caches — a no-op (the fake holds none).
- /// Cancellation token for the operation.
- /// A completed task.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
- => Task.CompletedTask;
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
index a445fe22..df113425 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
@@ -151,6 +151,94 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
+ // ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------
+ //
+ // #478 gave a scripted condition the worst quality of its inputs, but only for inputs whose driver
+ // PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it
+ // raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value and
+ // went on evaluating it. These pin the synthetic Bad that closes that hole.
+
+ /// Comms loss pushes a Bad for every value ref the driver owns — not one, and not the
+ /// alarm-bearing ones (those take the Part 9 quality path instead).
+ [Fact]
+ public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[]
+ {
+ (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
+ (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
+ },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
+
+ var pushes = new[]
+ {
+ mux.ExpectMsg(TimeSpan.FromSeconds(5)),
+ mux.ExpectMsg(TimeSpan.FromSeconds(5)),
+ };
+
+ pushes.Select(p => p.FullReference).ShouldBe(
+ new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true);
+
+ foreach (var p in pushes)
+ {
+ // BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code
+ // names the actual cause and reaches a client through the scripted condition's quality.
+ p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ // Null rather than the last-known value: the honest statement is "no current value". The
+ // engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null.
+ p.Value.ShouldBeNull();
+ p.DriverInstanceId.ShouldBe("drv-1");
+ }
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// Reconnect pushes NOTHING — and this is the falsifying control for the test above, since
+ /// both drive the same handler over the same populated map and only one may produce traffic.
+ [Fact]
+ public void Driver_reconnect_pushes_nothing_into_the_mux()
+ {
+ // Publishing Good here would assert a value quality the host has not observed. Recovery instead
+ // rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription,
+ // so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with
+ // forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll
+ // engine would compare against retained LastValues, see an unchanged value, raise nothing, and
+ // strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert.
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
+ /// Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is
+ /// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags.
+ [Fact]
+ public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
/// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
index 65fc6e57..f3d75523 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
@@ -266,10 +266,6 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
- ///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public Task SubscribeAlarmsAsync(
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
index eb0e4e5c..e16e1b7c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs
@@ -395,8 +395,6 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
- public long GetMemoryFootprint() => 0;
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs
index 56a85fb8..c3c0d672 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs
@@ -335,9 +335,5 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
- ///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs
index baf11d1a..f94acd8f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs
@@ -426,10 +426,6 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
///
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
///
- public long GetMemoryFootprint() => 0;
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
- ///
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
index 31d0da91..e4b77e51 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorHostStatusTests.cs
@@ -289,10 +289,5 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
///
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
index 450fafee..e1f22d7c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
@@ -314,10 +314,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Gets the health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
- /// Flushes optional caches in the driver.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// A driver that implements + and lets the
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
index 36b2651d..0154d205 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorRediscoverySignalTests.cs
@@ -241,10 +241,5 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
///
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError);
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs
index 87aa6167..745f3f6b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs
@@ -129,8 +129,6 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
- public long GetMemoryFootprint() => 0;
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs
index 4941eb45..38d27fea 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorWriteAndSubscribeTests.cs
@@ -205,10 +205,6 @@ public sealed class DriverInstanceActorWriteAndSubscribeTests : RuntimeActorTest
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Gets the health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
- /// Flushes optional caches in the driver.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Writes the specified requests (never reached — actor fast-fails in Connecting).
public Task> WriteAsync(
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs
index b9780be4..ea8c5c53 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs
@@ -63,11 +63,6 @@ internal class StubDriver : IDriver
}
/// Gets the health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
- /// Gets the memory footprint of the driver.
- public long GetMemoryFootprint() => 0;
- /// Flushes optional caches in the driver.
- /// Cancellation token for the operation.
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
index d26b5381..d020d118 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
@@ -40,6 +41,36 @@ public sealed class DependencyMuxTagUpstreamSourceTests
snap.StatusCode.ShouldNotBe(0u);
// Timestamp comes from the injected clock.
snap.ServerTimestampUtc.ShouldBe(fixedNow);
+
+ // #481: the SPECIFIC code is load-bearing, not merely "some Bad". ScriptedAlarmEngine skips
+ // exactly this code when computing worst-of-input quality, which is how it tells a path nothing
+ // has ever pushed to apart from a driver reporting Bad with no value — the shape a comms-lost
+ // driver produces, and previously indistinguishable from cold start. Any other Bad code here
+ // would make every not-yet-published input drag its condition Bad at deploy.
+ snap.StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
+ }
+
+ ///
+ /// The engine's private copy of the never-pushed sentinel agrees with the one this source stamps.
+ ///
+ ///
+ /// Core.ScriptedAlarms deliberately does not reference Commons, so the engine inlines
+ /// the constant rather than sharing . The value is fixed by the OPC UA spec
+ /// so it cannot legitimately drift, but a typo in either copy would silently un-wire #481: the
+ /// engine would stop recognising the placeholder and every cold-start input would report Bad. Read
+ /// by reflection because the engine's copy is private — deliberately, since nothing outside the
+ /// engine should key off it.
+ ///
+ [Fact]
+ public void The_engine_agrees_with_this_source_on_the_never_pushed_status_code()
+ {
+ var engineConst = typeof(Core.ScriptedAlarms.ScriptedAlarmEngine)
+ .GetField("StatusNeverPushed", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
+ .ShouldNotBeNull("ScriptedAlarmEngine.StatusNeverPushed is gone or was renamed — #481's cold-start "
+ + "vs. reported-Bad distinction is wired through it, so this guard must be repointed, not deleted");
+
+ engineConst.GetRawConstantValue().ShouldBe(
+ new DependencyMuxTagUpstreamSource().ReadTag("never/pushed").StatusCode);
}
[Fact]