diff --git a/CLAUDE.md b/CLAUDE.md
index 9a9e0135..e754e6aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1161,16 +1161,16 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection;
FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
- "tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
+ "tag name collision"); S7 swaps `Plc` under `_gate`.
- **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every
deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook
`OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile
re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick.
- A Connected in-place `ApplyDelta` (unreachable from deploys since #516) releases both subscriptions at the
- driver, reinitialises, then re-subscribes both (#663) — it used to keep handles the reinit had invalidated.
- A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)`, so **every driver's `InitializeAsync` must adopt a
- config with a body** — Calculation now does, Galaxy (no hot-swap) now refuses a non-equivalent one, and the actor
- sends `DeltaApplied` once the adopted init succeeds (#664).
+- **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied`
+ seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect
+ `AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync`
+ stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is
+ handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630).
`docs/DriverLifecycle.md`.
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md
index ca39046f..99f3c71d 100644
--- a/docs/DriverLifecycle.md
+++ b/docs/DriverLifecycle.md
@@ -255,25 +255,15 @@ Things to know before touching any of this:
instead of gating the feed for the life of the connection. OpcUaClient removes a half-built alarm
subscription from its session when the subscribe throws, so those retries do not accumulate.
End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`.
-- **An in-place `ApplyDelta` on a Connected driver releases and re-establishes BOTH subscriptions (#663).**
- `ReinitializeAsync` drops every driver-side subscription (#630), but the handler used to keep the actor's
- handles, and the reconcile only re-subscribes a *missing* handle — so the driver went silent until a
- reconnect. It now releases data and alarms at the driver first (explicitly, so a driver whose reinit keeps
- its subscriptions is not left holding two), reinitialises, and re-subscribes both in a `finally` (a failed
- reinit has lost them too). It stays on `ReinitializeAsync` rather than shutdown + initialise because
- Calculation and Galaxy take their config at construction and ignore the JSON passed to `InitializeAsync`.
- Not reachable from a deploy since #516 (a DriverConfig change respawns). Pin:
- `An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription` (one case per wrong fix).
-- **A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)` — so every driver's `InitializeAsync` must
- honour the JSON (#664).** `AdoptConfigDuringInit` adopts a delta that lands while Connecting/Reconnecting by
- re-initialising, and acks on adoption. Every driver re-parses a config with a body (#516/#630) except two that
- used to ignore it: **Calculation** now rebuilds its tag table from it (sharing `AdoptConfig` with
- `ReinitializeAsync`, and disposing any running timer scheduler first), and **Galaxy**, which cannot hot-swap
- its options, now **refuses** a non-equivalent config with the same `NotSupportedException` its
- `ReinitializeAsync` throws — it used to connect on the old endpoint while the deploy reported success. A
- `"{}"`/empty document still keeps the constructor options. Once the adopted init succeeds the actor sends
- `DeltaApplied`, so the host re-registers a dependency consumer's mux interest — the mid-connect path used to
- send nothing. A new driver must follow the same rule. Unreachable from a deploy since #516.
+- **There is no in-place config path — a config change is a respawn (#665).** Since #516 any DriverConfig,
+ DriverType or ResilienceConfig change is stop + respawn, so the factory is the single parse authority. The old
+ in-place seam (host `ApplyChildDelta` → actor `ApplyDelta` → `ReinitializeAsync`, plus the mid-connect
+ `AdoptConfigDuringInit`, `ApplyResult` and `DeltaApplied`) had no producer and was deleted after two latent
+ bugs were found in it: a Connected reinit kept subscription handles the driver had dropped (#663), and the
+ mid-connect path re-ran `InitializeAsync(newConfig)` on two drivers that ignored the JSON (#664). Kept from that
+ work: Calculation's `InitializeAsync` adopts a config with a body and Galaxy's refuses a non-equivalent one,
+ because the `IDriver` contract is that a driver serves the document it is handed. `ReinitializeAsync` stays as a
+ per-driver contract the runtime does not call.
- **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision`
asserts only that a child exists after a restart, and it passed green against the dead driver 03/S16
describes. It is kept — its own subject, the `InvalidActorNameException`, is real — but the load-bearing
@@ -458,8 +448,8 @@ have leaked ~360 transports at the 10 s retry interval. The shutdown is best-eff
on it still gets its initialise attempt.
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not
-the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no*
-teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
+the only way in: the `IDriver` contract allows `InitializeAsync` on a live driver with *no* teardown, and a retry
+tick can overlap a still-running attempt, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and
OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
diff --git a/docs/IncrementalSync.md b/docs/IncrementalSync.md
index deb6be2f..67667378 100644
--- a/docs/IncrementalSync.md
+++ b/docs/IncrementalSync.md
@@ -16,9 +16,9 @@ changes the served address space — the **deploy artifact** — and one advisor
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
reconciles in two halves:
- - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed
- driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`;
- a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
+ - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToSpawn`. A changed
+ driver config (DriverConfig, DriverType or ResilienceConfig) is a stop + respawn (#516) — there is no
+ in-place path (#665 deleted the unreachable `ApplyDelta` seam); a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
running children are left alone (never an empty configuration).
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index fb4393c5..f8aef796 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -415,7 +415,7 @@ down rather than swallowing it. A SUBACK failure degrades just the affected RawP
There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527
-comms-loss watchdog's retry path, and an `ApplyDelta` that changes the endpoint or credentials) — builds
+comms-loss watchdog's retry path, and a `ReinitializeAsync` that changes the endpoint or credentials) — builds
a *new* connection and re-attaches the *same* `MqttSubscriptionManager`, and the re-subscribe then comes
from the host (`DriverInstanceActor.ResubscribeDesired`) rather than from the callback. `AttachTo`
therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
diff --git a/docs/v2/Runtime.md b/docs/v2/Runtime.md
index 4d5158c7..ee831085 100644
--- a/docs/v2/Runtime.md
+++ b/docs/v2/Runtime.md
@@ -78,7 +78,7 @@ Per-driver-instance child. State machine:
`ShouldStub(driverType, roles)` returns `true` for `"Galaxy" | "Historian.Wonderware"` on non-Windows; the actor goes straight to `Stubbed` and returns deterministic success without touching real hardware. Wiring this into the DriverHost child-spawn path is follow-up F20 (folds into F7).
-Engine wiring (subscription publishing, ApplyDelta diff, bad-quality-on-disconnect, write path, supervisor backoff) is stubbed — tracked as F7. Tests exercise message contracts, not engine behaviour.
+Engine wiring (subscription publishing, config-change diff, bad-quality-on-disconnect, write path, supervisor backoff) is stubbed — tracked as F7. Tests exercise message contracts, not engine behaviour.
## VirtualTagActor / ScriptedAlarmActor
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 f735d0f4..0b869802 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
@@ -24,8 +24,9 @@ public interface IDriver
///
/// Apply a config change in place without tearing down the driver process.
- /// Invoked by the v2 DriverInstanceActor when ApplyDelta reports that only this
- /// driver's config changed in the new deployment.
+ /// The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
+ /// and #665 deleted DriverInstanceActor's in-place ApplyDelta path. Kept as the in-place
+ /// contract each driver implements and tests.
///
///
/// Per docs/v2/driver-stability.md §"In-process only (Tier A/B)" — Reinitialize is the
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 84df5a8b..6287390e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -360,8 +360,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
- // runs it with no teardown first). Release the previous device states' probe loops + handles before
+ // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
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 a352a803..5115ee95 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
- // without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
+ // #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
+ // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
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 af48df42..c6274f25 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs
@@ -67,7 +67,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
- // and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
+ // and REBUILT on every ReinitializeAsync / body-carrying InitializeAsync (an in-place re-bind of an edited calc-tag set).
private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
@@ -139,7 +139,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// (Re)build the authored tag table (), the change-trigger fan-out
/// (), the dependency-ref interest set (),
/// and the per-tag state map () from . Called
- /// once at construction AND on every so an in-place ApplyDelta — a
+ /// once at construction AND on every so an in-place config change — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
/// and reinit paths share ONE construction routine and cannot drift.
/// Per-tag is preserved for a tag that survives the edit (cheap last-known
@@ -200,7 +200,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
- // tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
+ // tolerant) so an in-place config payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -209,8 +209,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
Converters = { new JsonStringEnumConverter() },
};
- /// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
- /// payload). The ctor-fixed timeout is kept — RunTimeout is bound
+ /// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
+ /// config payload). The ctor-fixed timeout is kept — RunTimeout is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
/// current tags so a malformed delta can never blank the driver.
private CalculationDriverOptions ParseOptions(string driverConfigJson)
@@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
///
///
- /// #664: InitializeAsync is reachable on a LIVE driver — DriverInstanceActor.AdoptConfigDuringInit
- /// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a
+ /// #664: InitializeAsync takes a config document and the contract is that the
+ /// driver serves it (the runtime's mid-connect adoption path was deleted in #665) — so it adopts a config carrying a
/// real body (the #516 convention every other driver follows), and disposes any running timer scheduler
/// before arming a fresh one (#630: re-initialise without teardown must not leak). A "{}" / empty
/// document keeps the constructor-supplied tags, as unit tests rely on.
@@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
///
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
- // merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
- // respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
- // fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
- // here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
- // serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
- // reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
- // DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
+ // An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
+ // added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
+ // itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
+ // new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
StopTimers();
AdoptConfig(driverConfigJson);
// Re-arm timers off the rebuilt tag set.
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 673b271f..6864a4e1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -196,8 +196,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// A completed task.
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
- // AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
+ // #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
+ // re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
ResetAlarmHealth();
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 693619e0..0a7dbf0a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -349,8 +349,8 @@ public sealed class GalaxyDriver
///
///
- /// #664: InitializeAsync is reachable on a live driver with a NEW config —
- /// DriverInstanceActor.AdoptConfigDuringInit re-runs it when an ApplyDelta lands mid-connect. The
+ /// #664: InitializeAsync takes a config document, and the contract is that a
+ /// driver serves the one it is handed (the runtime's own mid-connect adoption path was deleted in #665). The
/// driver cannot hot-swap its options (they shape the gw session; see ), so a
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver
/// would otherwise connect on its old endpoint while the deploy reported the change applied. A
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 91c46b04..fc4c6235 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -21,11 +21,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// Agent. Every connection is owned by .
///
///
-/// The driverConfigJson argument is honoured, not decorative. The runtime hands
-/// a config change to a live instance — DriverInstanceActor's ApplyDelta
-/// calls with the new document and never rebuilds the object
-/// — so a driver that read only its constructor options would turn every MTConnect config
-/// edit into a silent no-op that still seals the deployment green. This class therefore
+/// The driverConfigJson argument is honoured, not decorative.
+/// and both take the document, and the contract is that the driver
+/// serves it — a driver that read only its constructor options would turn an in-place config
+/// change into a silent no-op. (The runtime itself now applies a config change by respawning,
+/// #516/#665.) This class therefore
/// re-parses the document () whenever it carries a real body,
/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
/// ignore the argument. A blank / {} / [] document means "no config supplied"
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 e50d433a..a379687d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -95,7 +95,7 @@ public sealed class ModbusDriver
/// concurrent operations would dispose each other's freshly-built transport or null
/// _transport out from under an in-flight connect. Not hypothetical:
/// DriverInstanceActor dispatches initialize on a fire-and-forget Task.Run and
- /// its AdoptConfigDuringInit can fire twice back to back. Same shape as
+ /// a reconnect retry tick can start a second attempt while the first is still running. Same shape as
/// MqttDriver._lifecycleGate.
///
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
@@ -297,9 +297,9 @@ public sealed class ModbusDriver
// Release whatever a PREVIOUS initialize published before building the replacements. All
// three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
// so a second initialize on a live driver stranded a connected transport plus two running
- // background loops that no later dispose could reach. Reachable without any teardown of
- // ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta
- // lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit).
+ // background loops that no later dispose could reach. The IDriver contract lets a
+ // caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
+ // can overlap a still-running attempt, so this must not rely on a teardown having happened.
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
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 fb39ed43..0a155562 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -983,9 +983,9 @@ public sealed class MqttDriver
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously
// overwritten, so a second initialize on a live driver stranded a connected, supervised
// broker session — plus a whole second primary-host connection — that no later dispose
- // could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs
- // InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in
- // Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync
+ // could reach. The IDriver contract lets a caller
+ // re-initialise a live driver without ShutdownAsync, and a reconnect retry can overlap a running
+ // attempt. Idempotent, so the ReinitializeAsync
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
// three nulls.
await TeardownAsync().ConfigureAwait(false);
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 e851bc9c..42739295 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
- // it with no teardown first); `Session = session` below would otherwise orphan the previous session,
+ // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it, and a reconnect retry can overlap a running attempt); `Session = session` below would otherwise orphan the previous session,
// its keep-alive handler and any reconnect handler.
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
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 f25bd794..bda755ca 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -225,8 +225,8 @@ public sealed class S7Driver
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
- // runs it with no teardown first). Stop the previous probe loop and drop the previous connection
+ // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection
// before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
// subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseConnectionAsync().ConfigureAwait(false);
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 c2d57855..bc5f932e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
- // runs it with no teardown first). Release the previous device states' probe loops + clients before
+ // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + clients before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 9800e657..77cc2f52 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -406,7 +406,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
- // both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
+ // the factory (TryCreate) and the init (InitializeRequested) hand the driver the same shape.
var rawTagsByDriver = BuildRawTagsByDriver(root);
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
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 2c5e6260..3b13328c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// value maps so stale condition state never leaks across redeploys.
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
- /// In-flight sends, keyed by correlation, so
- /// can advance the cached spec only once the child confirms it adopted
- /// the config (#516). Bounded by the number of driver children with a delta in flight.
- private readonly Dictionary _pendingDelta = new();
/// One driver child's desired subscription payload — exactly the
/// the apply's bulk push sent it.
@@ -1345,8 +1341,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
Receive(HandleCommsRecoveryAudit);
- Receive(HandleDeltaApplied);
- Receive(HandleApplyResult);
Receive(HandleRestartDriver);
Receive(HandleReprovisionRequested);
Receive(m => ReprovisionChild(m.Request, m.Child));
@@ -1408,8 +1402,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
Receive(HandleCommsRecoveryAudit);
- Receive(HandleDeltaApplied);
- Receive(HandleApplyResult);
Receive(HandleRestartDriver);
Receive(HandleReprovisionRequested);
Receive(m => ReprovisionChild(m.Request, m.Child));
@@ -2284,10 +2276,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
Receive(_ => { });
- // A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
- // mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
- Receive(HandleDeltaApplied);
- Receive(HandleApplyResult);
Receive(_ => { /* PubSub ack */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
}
@@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
///
/// Read the deployment artifact + reconcile the set of running
- /// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
+ /// children. Spawn missing, respawn on config change, stop removed/disabled drivers.
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
/// SKIPPED outright — see the issue #485 guard below — and when the configured
/// can't materialise any of the requested types this is a no-op.
@@ -2917,16 +2905,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
foreach (var id in plan.ToStop) StopChild(id);
- foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
// v3 (WP7): a dependency-consumer driver's declared dependency refs change when its tags/scripts are
- // edited — the adapter must re-register the NEW set on the mux. Newly-spawned adapters register in
- // PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished
- // reinitialising (DriverInstanceActor.DeltaApplied → HandleDeltaApplied). That notification is
- // sequenced AFTER ReinitializeAsync rebuilds DependencyRefs — a synchronous re-register HERE would
- // re-read the STALE ref set (ApplyDelta runs asynchronously on the child), so it is deliberately NOT
- // done inline.
+ // edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
+ // adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
}
///
@@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Context.Stop(adapter);
}
- ///
- /// Sends an in-place config delta to a running child.
- /// Unreachable from the reconcile path since #516 — a DriverConfig change is now a
- /// stop + respawn (), so ToApplyDelta is always empty.
- /// Kept because the seam is still exercised by DriverInstanceActor's own mid-connect config
- /// adoption.
- /// Two seals were removed here. This used to overwrite the cached
- /// Spec SYNCHRONOUSLY, before the child had even dequeued the message — so the host
- /// immediately believed the new config was live, the NEXT reconcile computed no delta against it,
- /// and any drift was sealed permanently. It also Telld with no Receive<ApplyResult>
- /// handler registered, so a FAILED reinit — including Galaxy's deliberate
- /// NotSupportedException — dead-lettered and was never surfaced.
- ///
- private void ApplyChildDelta(DriverInstanceSpec spec)
- {
- if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
- var correlation = CorrelationId.NewId();
- _pendingDelta[correlation] = spec;
- entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
- _log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
- }
-
- ///
- /// A child's reply to . On success the cached Spec is advanced —
- /// only now, when the child has actually adopted the config, so a failed reinit leaves the host
- /// believing the OLD config is live and the next reconcile re-attempts the change instead of
- /// silently treating the drift as applied. A failure is logged at Error rather than swallowed.
- ///
- private void HandleApplyResult(DriverInstanceActor.ApplyResult msg)
- {
- if (msg.Success)
- {
- if (_pendingDelta.Remove(msg.Correlation, out var spec)
- && _children.TryGetValue(spec.DriverInstanceId, out var entry))
- {
- // A delta can change Name, Enabled, ClusterId etc. alongside the config.
- _children[spec.DriverInstanceId] = entry with { Spec = spec };
- }
- return;
- }
-
- _pendingDelta.Remove(msg.Correlation, out var failed);
- _log.Error(
- "DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " +
- "the previous config so the next reconcile re-attempts it",
- _localNode, failed?.DriverInstanceId ?? "", msg.Reason ?? "no reason given");
- }
-
- ///
- /// A driver child finished applying an in-place (its
- /// completed). Re-register THAT driver's dependency-consumer
- /// mux adapter so a changed dependency-ref set (calc tags/scripts edited during the redeploy) is
- /// adopted on the mux. This runs AFTER reinit — closing the ordering hazard where a synchronous
- /// re-register at ApplyDelta dispatch would re-read the driver's stale
- /// . A Tell (no blocking); a no-op for a driver with no
- /// adapter (not an , or no mux wired on this node).
- ///
- private void HandleDeltaApplied(DriverInstanceActor.DeltaApplied msg)
- {
- if (_depConsumerAdapters.TryGetValue(msg.DriverInstanceId, out var adapter))
- adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
- }
-
private void StopChild(string driverInstanceId)
{
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index ad3de17c..b5e84b76 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -25,8 +25,7 @@ file sealed class HealthPollTick
/// Failed — terminal until parent restarts the actor.
///
///
-/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize,
-/// per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
+/// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// state machine so the Phase 6 control-plane integration tests can target it.
///
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
@@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// Connected=true on entering the Connected state, false on entering Reconnecting. The host
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
- /// events. Fire-and-forget, mirroring .
+ /// events. Fire-and-forget.
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
///
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// The driver instance that was restarted.
/// The type name of the exception that tripped supervision, for the host's log.
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
- public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
- public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
- ///
- /// Sent to the parent () AFTER an has fully
- /// re-initialised the driver (i.e. completed). The host uses
- /// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
- /// during reinit (the Calculation driver re-binds its
- /// calc tags), so the re-register MUST happen after reinit — NOT synchronously alongside the ApplyDelta
- /// dispatch, which would re-read the STALE ref set. Emitted only on a successful apply.
- ///
- /// The instance whose adapter should be re-registered.
- public sealed record DeltaApplied(string DriverInstanceId);
public sealed record WriteAttribute(string TagId, object Value);
public sealed record WriteAttributeResult(bool Success, string? Reason);
///
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson;
- /// #664: set when adopts a new config mid-connect; the next
- /// (generation-matched) sends to the parent and
- /// clears it, so a dependency consumer's mux interest is re-registered against the ADOPTED config — the
- /// same post-reinit notification the Connected path sends.
- private bool _adoptedConfigPendingNotify;
-
/// Monotonic token tagging each attempt. An init result is
/// honoured only when its generation matches the latest; an older result is from a superseded attempt
- /// (e.g. an adopted a new config mid-(re)connect) and is dropped. Touched only
+ /// (a retry tick that overlapped a still-running attempt) and is dropped. Touched only
/// on the actor thread, so no lock is needed.
private int _initGeneration;
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Stubbed drivers accept the standard message contracts but return deterministic
// success without touching real hardware. Read returns null; Write succeeds.
Receive(_ => { /* no-op */ });
- Receive(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
Receive(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
Receive(_ => { /* stubbed drivers have no alarm backend */ });
@@ -468,13 +448,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
Receive(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
- Receive(AdoptConfigDuringInit);
Receive(msg =>
{
if (msg.Generation != _initGeneration) return;
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
Become(Connected);
- NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -522,7 +500,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
_connectedSinceUtc = DateTime.UtcNow;
- ReceiveAsync(HandleApplyDeltaAsync);
Receive(msg =>
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
@@ -771,14 +748,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
Receive(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
- Receive(AdoptConfigDuringInit);
Receive(msg =>
{
if (msg.Generation != _initGeneration) return;
Timers.Cancel("retry-connect");
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
Become(Connected);
- NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
}
- /// Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits
- /// immediately with the new config. swaps _currentConfigJson and
- /// bumps the generation, so the in-flight (old-config) init is superseded and its result is dropped.
- /// The actor stays in its current state; the new init's result drives the next transition. In
- /// Reconnecting the retry timer is left running — if this immediate attempt fails it keeps retrying
- /// the new config (a redundant concurrent attempt is deduped by the generation guard).
- private void AdoptConfigDuringInit(ApplyDelta msg)
- {
- _log.Info("DriverInstance {Id}: ApplyDelta during (re)connect — adopting new config, re-initialising now",
- _driverInstanceId);
- InitializeAsync(msg.DriverConfigJson);
- _adoptedConfigPendingNotify = true;
- Sender.Tell(new ApplyResult(true, "config adopted; reinitializing", msg.Correlation));
- }
-
- /// Send the deferred for a mid-connect adoption (#664) once the driver
- /// has actually initialised on the adopted config. A no-op otherwise.
- private void NotifyAdoptedConfig()
- {
- if (!_adoptedConfigPendingNotify) return;
- _adoptedConfigPendingNotify = false;
- Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
- }
-
- /// Adopt a new config while Connected, in place, via .
- /// #663: every driver's reinit releases its device/session state (#630), so the driver-side data and
- /// native-alarm subscriptions do not survive it — but this handler used to keep both handles, and the
- /// health-poll reconcile only re-subscribes a MISSING handle, so the driver went silent until a
- /// reconnect or respawn. Both subscriptions are now released explicitly BEFORE the reinit (explicitly,
- /// not just by nulling the handles, so a driver whose reinit keeps its subscriptions is not left
- /// holding two) and re-established after it — in a finally, because a failed reinit has already
- /// lost them too. Reinit stays the adoption path rather than shutdown + initialise: Calculation and
- /// Galaxy take their config at construction and ignore 's JSON.
- /// Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).
- private async Task HandleApplyDeltaAsync(ApplyDelta msg)
- {
- var replyTo = Sender;
- await UnsubscribeAsync();
- await ReleaseAlarmSubscriptionAsync();
- try
- {
- await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
- _currentConfigJson = msg.DriverConfigJson;
- // The driver may have rebuilt its dependency-consumer interest during reinit (the Calculation
- // driver re-binds its calc tags + dependency refs). Notify the parent so it re-registers this
- // driver's mux adapter AFTER reinit completed — a synchronous re-register at ApplyDelta-dispatch
- // time would re-read the driver's STALE DependencyRefs. Parent Tell (fire-and-forget); the
- // continuation resumes on the actor context (no ConfigureAwait) so Context.Parent is live.
- Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
- replyTo.Tell(new ApplyResult(true, null, msg.Correlation));
- }
- catch (Exception ex)
- {
- replyTo.Tell(new ApplyResult(false, ex.Message, msg.Correlation));
- }
- finally
- {
- ResubscribeDesired();
- AttachAlarmSource();
- SubscribeDesiredAlarms();
- }
- }
-
- /// Release the native-alarm subscription at the driver (best-effort, bounded) and drop the
- /// actor's handler + handle. The in-place reinit counterpart of the data
- /// (#663).
- private async Task ReleaseAlarmSubscriptionAsync()
- {
- if (_driver is IAlarmSource src && _alarmSubscriptionHandle is { } handle)
- {
- try
- {
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
- await _invoker.ExecuteAsync(
- DriverCapability.AlarmSubscribe,
- _driverInstanceId,
- async ct => await src.UnsubscribeAlarmsAsync(handle, ct),
- cts.Token);
- }
- catch (Exception ex)
- {
- _log.Warning(ex, "DriverInstance {Id}: releasing the native-alarm subscription ({Diag}) threw (continuing)",
- _driverInstanceId, handle.DiagnosticId);
- }
- }
- DetachAlarmSource();
- }
-
private async Task HandleWriteAsync(WriteAttribute msg)
{
var replyTo = Sender;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
index 37ba98ab..e37d41e3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs
@@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
///
/// Pure diff between the currently-running driver children (keyed by
/// DriverInstance.DriverInstanceId) and the target spec list from a freshly-applied
-/// deployment artifact. The DriverHostActor consumes the three lists and calls
-/// spawn / ApplyDelta / stop on its child actors accordingly.
+/// deployment artifact. The DriverHostActor consumes the two lists and calls
+/// spawn / stop on its child actors accordingly. There is no in-place config path: since #516 a config
+/// change is a stop + respawn, and #665 deleted the unreachable in-place ApplyDelta seam.
///
-/// Specs with no current child — create a new actor.
-///
-/// In-place config deltas. Always empty since #516 — a DriverConfig change is now a
-/// stop + respawn so the factory is the single parse authority. Retained because
-/// DriverInstanceActor still handles ApplyDelta on its own config-adoption path
-/// (a config arriving mid-connect), and removing the list would hide that seam.
-///
+/// Specs with no current child (or whose child is being replaced) — create a new actor.
/// DriverInstanceIds currently running but missing from the new artifact, or now disabled.
public sealed record DriverSpawnPlan(
IReadOnlyList ToSpawn,
- IReadOnlyList ToApplyDelta,
IReadOnlyList ToStop);
public static class DriverSpawnPlanner
{
///
- /// Compute the spawn/delta/stop sets. Disabled entries in are
+ /// Compute the spawn/stop sets. Disabled entries in are
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
///
/// The currently running driver children keyed by ID.
/// The target driver instances from the deployment artifact.
- /// The computed with spawn, apply-delta, and stop sets.
+ /// The computed with spawn and stop sets.
public static DriverSpawnPlan Compute(
IReadOnlyDictionary current,
IReadOnlyList target)
{
var toSpawn = new List();
- var toDelta = new List();
var toStop = new List();
var targetById = new Dictionary(StringComparer.Ordinal);
@@ -83,7 +76,7 @@ public static class DriverSpawnPlanner
toSpawn.Add(spec);
}
- return new DriverSpawnPlan(toSpawn, toDelta, toStop);
+ return new DriverSpawnPlan(toSpawn, toStop);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
index ffcce445..85f3d1b5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
///
-/// #630 — InitializeAsync on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
-/// it with no teardown first) overwrote _devices[host] and orphaned the previous state's
+/// #630 — InitializeAsync on a live driver with no teardown first (the IDriver contract
+/// allows it) overwrote _devices[host] and orphaned the previous state's
/// probe loop + runtime. It now releases them first.
///
[Trait("Category", "Unit")]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs
index d9460d99..5eed4c6b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs
@@ -396,7 +396,7 @@ public sealed class CalculationDriverTests
}
/// The skip ledger must be REBUILT on ReinitializeAsync, not just at construction — an
- /// ApplyDelta that drops a tag's scriptId (or adds a new tag with none) must be reflected immediately,
+ /// in-place config change that drops a tag's scriptId (or adds a new tag with none) must be reflected immediately,
/// since RebuildTagTable is the one routine both the ctor and reinit share.
[Fact]
public async Task GetSkippedTags_updates_after_reinitialize()
@@ -414,9 +414,9 @@ public sealed class CalculationDriverTests
}
///
- /// Gitea #664 — InitializeAsync re-runs on a LIVE driver with a new config when an ApplyDelta lands
- /// mid-connect (DriverInstanceActor.AdoptConfigDuringInit). It used to ignore the JSON, so the
- /// driver kept its constructor tag set while the deploy reported the change applied. A config with a body
+ /// Gitea #664 — InitializeAsync must serve the config document it is handed (the IDriver
+ /// contract). It used to ignore the JSON, so a live driver re-initialised with a new config kept its
+ /// constructor tag set. A config with a body
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
///
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
index 89b3cd5f..e1687105 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
-/// #630 — InitializeAsync on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
-/// it with no teardown first) overwrote _devices[host] and orphaned the previous state's
+/// #630 — InitializeAsync on a live driver with no teardown first (the IDriver contract
+/// allows it) overwrote _devices[host] and orphaned the previous state's
/// probe loop + client. It now releases them first.
///
[Trait("Category", "Unit")]
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 91954ab5..66edc998 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
@@ -209,9 +209,8 @@ public sealed class GalaxyDriverFactoryTests
}
///
- /// Gitea #664 — InitializeAsync re-runs on a live driver with a NEW config when an ApplyDelta lands
- /// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD
- /// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly
+ /// Gitea #664 — InitializeAsync must not silently ignore the config it is handed. Galaxy cannot
+ /// hot-swap its options, and it used to ignore the JSON — connecting on the OLD endpoint. A non-equivalent config is now refused, exactly
/// as ReinitializeAsync refuses it; an equivalent one still initialises.
///
[Fact]
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 ef3dddfe..857f606d 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
@@ -265,9 +265,8 @@ public sealed class MTConnectDriverLifecycleTests
[Fact]
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
{
- // The runtime delivers a config change ONLY through this argument (DriverInstanceActor
- // ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
- // MTConnect config change a silent no-op that still seals the deployment green.
+ // The IDriver contract: the driver serves the config document it is handed, not only its
+ // constructor options — ignoring it would turn an in-place config change into a silent no-op.
MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
@@ -533,7 +532,7 @@ public sealed class MTConnectDriverLifecycleTests
[Fact]
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
{
- // Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
+ // Blast radius: an unparseable in-place config edit fails the reinitialize call; it must
// not down a driver that is happily serving its previous, valid configuration.
var (driver, client) = await InitializedDriverAsync();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
index df7beb48..316de319 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs
@@ -225,9 +225,8 @@ public sealed class ModbusLifecycleHygieneTests
// -------------------- Re-initialize transport hygiene --------------------
///
- /// A second InitializeAsync on a live driver — the shape
- /// DriverInstanceActor.AdoptConfigDuringInit produces when an ApplyDelta lands while the
- /// driver is Connecting/Reconnecting — must dispose the transport the previous initialize
+ /// A second InitializeAsync on a live driver with no teardown between (the IDriver
+ /// contract allows it) must dispose the transport the previous initialize
/// published before publishing a new one. Pre-fix _transport was simply overwritten,
/// stranding a connected transport no later dispose could reach.
///
@@ -251,7 +250,7 @@ public sealed class ModbusLifecycleHygieneTests
});
await drv.InitializeAsync("{}", CancellationToken.None);
- // No ShutdownAsync in between — exactly what AdoptConfigDuringInit does.
+ // No ShutdownAsync in between.
await drv.InitializeAsync("{}", CancellationToken.None);
handed.Count.ShouldBe(2, "each initialize must build its own transport");
@@ -415,8 +414,8 @@ public sealed class ModbusLifecycleHygieneTests
///
/// Two initializes racing on the same driver — the shape DriverInstanceActor produces by
- /// dispatching initialize on a fire-and-forget Task.Run with AdoptConfigDuringInit
- /// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
+ /// dispatching initialize on a fire-and-forget Task.Run, where a reconnect retry tick can
+ /// overlap a still-running attempt — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
/// initialize disposes the transport the other just published (or nulls _transport
/// mid-connect), leaving the driver holding a disposed transport.
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
index 5622a682..ca4e667e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs
@@ -28,10 +28,9 @@ public sealed class MqttDriverSessionLifecycleTests
///
///
///
- /// Reachable with no teardown of the driver's own: DriverInstanceActor re-runs
- /// InitializeAsync with no ShutdownAsync when an ApplyDelta arrives while
- /// the driver is Connecting/Reconnecting (AdoptConfigDuringInit). The
- /// teardownFirst flag on the #527 retry path covers only the retry path.
+ /// The IDriver contract allows re-initialising a live driver with no
+ /// ShutdownAsync, and the #527 retry path's teardownFirst flag does not stop a
+ /// retry overlapping a still-running attempt, so the driver must not rely on a teardown.
///
///
/// The assertion is the broker's live socket count, not the driver's own view: a leak
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
index 6f278194..ce4d4db1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
@@ -483,8 +483,8 @@ public sealed class S7DriverReconnectTests
}
///
- /// #630 — a second InitializeAsync on a live driver (no ShutdownAsync between, as
- /// DriverInstanceActor.AdoptConfigDuringInit does) must stop the first probe loop and
+ /// #630 — a second InitializeAsync on a live driver (no ShutdownAsync between — the
+ /// IDriver contract allows it) must stop the first probe loop and
/// dispose the first connection rather than orphan them.
///
[Fact]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs
index 9a8aa40c..008004d5 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs
@@ -31,7 +31,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
- /// Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy
+ /// Build the merged DriverConfig JSON an in-place re-bind reads (mirrors the deploy
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).
private static string ConfigJson(params RawTagEntry[] tags) =>
System.Text.Json.JsonSerializer.Serialize(new { rawTags = tags });
@@ -130,7 +130,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
- // ---- REDEPLOY (in-place ApplyDelta) ----
+ // ---- IN-PLACE RE-BIND (driver ReinitializeAsync) ----
// New authored set: "keep" script EDITED to src/a*1000; "add" is a NEW tag reading a NEW dep src/b;
// "remove" is dropped entirely.
var newConfig = ConfigJson(
@@ -141,8 +141,9 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
// The rebuilt dependency-ref set reflects the NEW authored tags (src/a from keep, src/b from add).
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
- // Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied).
- // Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b).
+ // The adapter re-reads the driver's refs only when told to; re-register AFTER the reinit so it adopts the
+ // NEW ref set (incl. the brand-new src/b). (The runtime respawns on a config change, #665, so a fresh
+ // adapter registers the new set in PreStart; this exercises the driver + adapter contract directly.)
adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
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 7663c411..c3895b53 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
@@ -369,56 +369,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
.Connected.ShouldBeTrue();
}
- ///
- /// Gitea #663 — an in-place config change on a Connected driver (
- /// → ) must end with exactly one live data subscription and one live
- /// native-alarm subscription at the driver, and the alarm feed still forwarding. The handler used to keep
- /// the actor's handles across the reinit while the driver had dropped the subscriptions behind them, so
- /// the reconcile (which only re-subscribes a MISSING handle) never restored them and the driver went silent.
- ///
- /// false = the production shape (reinit drops everything, #630) —
- /// the regression; true = a driver that keeps them, which pins that the fix releases before re-subscribing
- /// instead of piling a second subscription on top.
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public void An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription(bool reinitKeepsSubscriptions)
- {
- var driver = new AlarmSourceStubDriver { ReinitKeepsSubscriptions = reinitKeepsSubscriptions };
- var publisher = new CountingHealthPublisher();
- var parent = CreateTestProbe();
- parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
- var actor = parent.ChildActorOf(DriverInstanceActor.Props(
- driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100)));
-
- actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
- new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
- actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
- AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget);
-
- var correlation = CorrelationId.NewId();
- actor.Tell(new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation), parent.Ref);
- parent.FishForMessage(r => r.Correlation == correlation, PresenceBudget)
- .Success.ShouldBeTrue();
- driver.ReinitializeCount.ShouldBe(1);
- driver.InitializeCount.ShouldBe(1, "an in-place delta must not go through the reconnect path");
-
- AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget);
- // The "not two" half is an absence, so wait for three more health-poll ticks — each runs the reconcile
- // that would issue a duplicate — before re-checking.
- var ticks = publisher.Count;
- AwaitCondition(() => publisher.Count >= ticks + 3, PresenceBudget);
- driver.LiveDataSubscriptions.ShouldBe(1);
- driver.LiveAlarmSubscriptions.ShouldBe(1);
- driver.AlarmSubscriberCount.ShouldBe(1);
-
- driver.RaiseAlarm(new AlarmEventArgs(
- new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after reinit", AlarmSeverity.High,
- DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
- parent.FishForMessage(_ => true, PresenceBudget)
- .Args.ConditionId.ShouldBe("Temp.HiHi");
- }
-
// --- stub drivers ----------------------------------------------------------------------------
private class StubDriver : IDriver
@@ -439,7 +389,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
}
/// Reinitializes the driver.
- public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// Shuts down the driver.
@@ -463,55 +413,14 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
/// Number of live subscribers on — proves attach/detach.
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
- private readonly object _liveLock = new();
- private readonly HashSet