Merge hw6-665 (#665 delete the dead in-place ApplyDelta seam)
v2-ci / build (push) Successful in 5m10s
v2-ci / unit-tests (push) Failing after 21m20s

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 11:23:16 -04:00
33 changed files with 112 additions and 559 deletions
+6 -6
View File
@@ -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; 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 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 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 - **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 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 `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. 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 - **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied`
driver, reinitialises, then re-subscribes both (#663) — it used to keep handles the reinit had invalidated. seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect
A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)`, so **every driver's `InitializeAsync` must adopt a `AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync`
config with a body** — Calculation now does, Galaxy (no hot-swap) now refuses a non-equivalent one, and the actor stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is
sends `DeltaApplied` once the adopted init succeeds (#664). handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630).
`docs/DriverLifecycle.md`. `docs/DriverLifecycle.md`.
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME - **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. surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
+11 -21
View File
@@ -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 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. subscription from its session when the subscribe throws, so those retries do not accumulate.
End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`. End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`.
- **An in-place `ApplyDelta` on a Connected driver releases and re-establishes BOTH subscriptions (#663).** - **There is no in-place config path — a config change is a respawn (#665).** Since #516 any DriverConfig,
`ReinitializeAsync` drops every driver-side subscription (#630), but the handler used to keep the actor's DriverType or ResilienceConfig change is stop + respawn, so the factory is the single parse authority. The old
handles, and the reconcile only re-subscribes a *missing* handle — so the driver went silent until a in-place seam (host `ApplyChildDelta` → actor `ApplyDelta` → `ReinitializeAsync`, plus the mid-connect
reconnect. It now releases data and alarms at the driver first (explicitly, so a driver whose reinit keeps `AdoptConfigDuringInit`, `ApplyResult` and `DeltaApplied`) had no producer and was deleted after two latent
its subscriptions is not left holding two), reinitialises, and re-subscribes both in a `finally` (a failed bugs were found in it: a Connected reinit kept subscription handles the driver had dropped (#663), and the
reinit has lost them too). It stays on `ReinitializeAsync` rather than shutdown + initialise because mid-connect path re-ran `InitializeAsync(newConfig)` on two drivers that ignored the JSON (#664). Kept from that
Calculation and Galaxy take their config at construction and ignore the JSON passed to `InitializeAsync`. work: Calculation's `InitializeAsync` adopts a config with a body and Galaxy's refuses a non-equivalent one,
Not reachable from a deploy since #516 (a DriverConfig change respawns). Pin: because the `IDriver` contract is that a driver serves the document it is handed. `ReinitializeAsync` stays as a
`An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription` (one case per wrong fix). per-driver contract the runtime does not call.
- **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.
- **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision` - **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 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 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. on it still gets its initialise attempt.
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not **`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* the only way in: the `IDriver` contract allows `InitializeAsync` on a live driver with *no* teardown, and a retry
teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans 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 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` OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT. (`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
+3 -3
View File
@@ -16,9 +16,9 @@ changes the served address space — the **deploy artifact** — and one advisor
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`. check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and 3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
reconciles in two halves: reconciles in two halves:
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToSpawn`. A changed
driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`; driver config (DriverConfig, DriverType or ResilienceConfig) is a stop + respawn (#516) — there is no
a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the 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). running children are left alone (never an empty configuration).
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact; - **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier` `AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
+1 -1
View File
@@ -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 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` own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527 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 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` 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 therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
+1 -1
View File
@@ -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). `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 ## VirtualTagActor / ScriptedAlarmActor
@@ -24,8 +24,9 @@ public interface IDriver
/// <summary> /// <summary>
/// Apply a config change in place without tearing down the driver process. /// Apply a config change in place without tearing down the driver process.
/// Invoked by the v2 <c>DriverInstanceActor</c> when ApplyDelta reports that only this /// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
/// driver's config changed in the new deployment. /// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
/// contract each driver implements and tests.</para>
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the /// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
@@ -360,8 +360,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <inheritdoc /> /// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// runs it with no teardown first). Release the previous device states' probe loops + handles before // 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. // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false); await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null); _health = new DriverHealth(DriverState.Initializing, null, null);
@@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <inheritdoc /> /// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it // #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the // 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 // 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). // 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); await ReleaseDeviceStatesAsync().ConfigureAwait(false);
@@ -67,7 +67,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so // 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), // 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<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal); private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue. // 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 (<see cref="_tagsByRawPath"/>), the change-trigger fan-out /// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>), /// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called /// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a /// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place config change — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor /// 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. /// and reinit paths share ONE construction routine and cannot drift.
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known /// <para>Per-tag <see cref="TagState"/> 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 // 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() private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -209,8 +209,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
Converters = { new JsonStringEnumConverter() }, Converters = { new JsonStringEnumConverter() },
}; };
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta /// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound /// config payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the /// 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.</summary> /// current tags so a malformed delta can never blank the driver.</summary>
private CalculationDriverOptions ParseOptions(string driverConfigJson) private CalculationDriverOptions ParseOptions(string driverConfigJson)
@@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// #664: <c>InitializeAsync</c> is reachable on a LIVE driver — <c>DriverInstanceActor.AdoptConfigDuringInit</c> /// #664: <c>InitializeAsync</c> takes a config document and the <see cref="IDriver"/> contract is that the
/// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a /// 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 /// 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 <c>"{}"</c> / empty /// before arming a fresh one (#630: re-initialise without teardown must not leak). A <c>"{}"</c> / empty
/// document keeps the constructor-supplied tags, as unit tests rely on. /// document keeps the constructor-supplied tags, as unit tests rely on.
@@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <inheritdoc /> /// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the // An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a // added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT // itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set // new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
// 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).
StopTimers(); StopTimers();
AdoptConfig(driverConfigJson); AdoptConfig(driverConfigJson);
// Re-arm timers off the rebuilt tag set. // Re-arm timers off the rebuilt tag set.
@@ -196,8 +196,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <returns>A completed task.</returns> /// <returns>A completed task.</returns>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's // #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll // 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). // and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false); await ReleaseDeviceStatesAsync().ConfigureAwait(false);
ResetAlarmHealth(); ResetAlarmHealth();
@@ -349,8 +349,8 @@ public sealed class GalaxyDriver
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// #664: <c>InitializeAsync</c> is reachable on a live driver with a NEW config — /// #664: <c>InitializeAsync</c> takes a config document, and the <see cref="IDriver"/> contract is that a
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> re-runs it when an ApplyDelta lands mid-connect. The /// 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 <see cref="ReinitializeAsync"/>), so a /// driver cannot hot-swap its options (they shape the gw session; see <see cref="ReinitializeAsync"/>), so a
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver /// 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 /// would otherwise connect on its old endpoint while the deploy reported the change applied. A
@@ -21,11 +21,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// Agent. Every connection is owned by <see cref="InitializeAsync"/>. /// Agent. Every connection is owned by <see cref="InitializeAsync"/>.
/// </para> /// </para>
/// <para> /// <para>
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands /// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> <see cref="IDriver.InitializeAsync"/>
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c> /// and <see cref="ReinitializeAsync"/> both take the document, and the contract is that the driver
/// calls <see cref="ReinitializeAsync"/> with the new document and never rebuilds the object /// serves it — a driver that read only its constructor options would turn an in-place config
/// — so a driver that read only its constructor options would turn every MTConnect config /// change into a silent no-op. (The runtime itself now applies a config change by respawning,
/// edit into a silent no-op that still seals the deployment green. This class therefore /// #516/#665.) This class therefore
/// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body, /// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body,
/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that /// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
/// ignore the argument. A blank / <c>{}</c> / <c>[]</c> document means "no config supplied" /// ignore the argument. A blank / <c>{}</c> / <c>[]</c> document means "no config supplied"
@@ -95,7 +95,7 @@ public sealed class ModbusDriver
/// concurrent operations would dispose each other's freshly-built transport or null /// concurrent operations would dispose each other's freshly-built transport or null
/// <c>_transport</c> out from under an in-flight connect. Not hypothetical: /// <c>_transport</c> out from under an in-flight connect. Not hypothetical:
/// <c>DriverInstanceActor</c> dispatches initialize on a fire-and-forget <c>Task.Run</c> and /// <c>DriverInstanceActor</c> dispatches initialize on a fire-and-forget <c>Task.Run</c> and
/// its <c>AdoptConfigDuringInit</c> 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
/// <c>MqttDriver._lifecycleGate</c>. /// <c>MqttDriver._lifecycleGate</c>.
/// </summary> /// </summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1); 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 // Release whatever a PREVIOUS initialize published before building the replacements. All
// three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten, // 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 // 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 // background loops that no later dispose could reach. The IDriver contract lets a
// ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta // caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
// lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit). // 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 // Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a // 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 // mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
@@ -983,9 +983,9 @@ public sealed class MqttDriver
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously // published below (_connection, _primaryHost) are IAsyncDisposable and were previously
// overwritten, so a second initialize on a live driver stranded a connected, supervised // 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 // broker session — plus a whole second primary-host connection — that no later dispose
// could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs // could reach. The IDriver contract lets a caller
// InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in // re-initialise a live driver without ShutdownAsync, and a reconnect retry can overlap a running
// Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync // attempt. Idempotent, so the ReinitializeAsync
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees // rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
// three nulls. // three nulls.
await TeardownAsync().ConfigureAwait(false); await TeardownAsync().ConfigureAwait(false);
@@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc /> /// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// it with no teardown first); `Session = session` below would otherwise orphan the previous session, // 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. // its keep-alive handler and any reconnect handler.
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false); await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null); _health = new DriverHealth(DriverState.Initializing, null, null);
@@ -225,8 +225,8 @@ public sealed class S7Driver
/// <inheritdoc /> /// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection // 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 // 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). // subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseConnectionAsync().ConfigureAwait(false); await ReleaseConnectionAsync().ConfigureAwait(false);
@@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <inheritdoc /> /// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit // #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// runs it with no teardown first). Release the previous device states' probe loops + clients before // 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. // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false); await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null); _health = new DriverHealth(DriverState.Initializing, null, null);
@@ -406,7 +406,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from — // 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 // DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so // 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 rawTagsByDriver = BuildRawTagsByDriver(root);
var deviceRowsByDriver = BuildDeviceRowsByDriver(root); var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
@@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// value maps so stale condition state never leaks across redeploys.</summary> /// value maps so stale condition state never leaks across redeploys.</summary>
private readonly NativeAlarmProjector _nativeAlarmProjector = new(); private readonly NativeAlarmProjector _nativeAlarmProjector = new();
/// <summary>In-flight <see cref="DriverInstanceActor.ApplyDelta"/> sends, keyed by correlation, so
/// <see cref="HandleApplyResult"/> 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.</summary>
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
/// <summary>One driver child's desired subscription payload — exactly the /// <summary>One driver child's desired subscription payload — exactly the
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary> /// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary>
@@ -1345,8 +1341,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm); Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged); Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit); Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver); Receive<RestartDriver>(HandleRestartDriver);
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested); Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child)); Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
@@ -1408,8 +1402,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm); Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged); Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit); Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver); Receive<RestartDriver>(HandleRestartDriver);
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested); Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child)); Receive<ReprovisionDeferred>(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 // 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). // on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
Receive<CommsRecoveryAudit>(_ => { }); Receive<CommsRecoveryAudit>(_ => { });
// 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<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<SubscribeAck>(_ => { /* PubSub ack */ }); Receive<SubscribeAck>(_ => { /* PubSub ack */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval); Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
} }
@@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <summary> /// <summary>
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/> /// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
/// 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 /// 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 /// SKIPPED outright — see the issue #485 guard below — and when the configured
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op. /// <see cref="IDriverFactory"/> 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); var plan = DriverSpawnPlanner.Compute(snapshots, specs);
foreach (var id in plan.ToStop) StopChild(id); foreach (var id in plan.ToStop) StopChild(id);
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
foreach (var spec in plan.ToSpawn) SpawnChild(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 // 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 // edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
// PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished // adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
// 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.
} }
/// <summary> /// <summary>
@@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Context.Stop(adapter); Context.Stop(adapter);
} }
/// <summary>
/// Sends an in-place config delta to a running child.
/// <para><b>Unreachable from the reconcile path since #516</b> — a DriverConfig change is now a
/// stop + respawn (<see cref="DriverSpawnPlanner"/>), so <c>ToApplyDelta</c> is always empty.
/// Kept because the seam is still exercised by <c>DriverInstanceActor</c>'s own mid-connect config
/// adoption.</para>
/// <para><b>Two seals were removed here.</b> This used to overwrite the cached
/// <c>Spec</c> 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 <c>Tell</c>d with no <c>Receive&lt;ApplyResult&gt;</c>
/// handler registered, so a FAILED reinit — including Galaxy's deliberate
/// <c>NotSupportedException</c> — dead-lettered and was never surfaced.</para>
/// </summary>
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);
}
/// <summary>
/// A child's reply to <see cref="ApplyChildDelta"/>. On success the cached <c>Spec</c> 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.
/// </summary>
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 ?? "<unknown>", msg.Reason ?? "no reason given");
}
/// <summary>
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
/// <see cref="IDriver.ReinitializeAsync"/> 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
/// <see cref="IDependencyConsumer.DependencyRefs"/>. A Tell (no blocking); a no-op for a driver with no
/// adapter (not an <see cref="IDependencyConsumer"/>, or no mux wired on this node).
/// </summary>
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) private void StopChild(string driverInstanceId)
{ {
if (!_children.TryGetValue(driverInstanceId, out var entry)) return; if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
@@ -25,8 +25,7 @@ file sealed class HealthPollTick
/// <item><c>Failed</c> — terminal until parent restarts the actor.</item> /// <item><c>Failed</c> — terminal until parent restarts the actor.</item>
/// </list> /// </list>
/// ///
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize, /// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// 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. /// state machine so the Phase 6 control-plane integration tests can target it.
/// </summary> /// </summary>
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
@@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host /// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad, /// 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 /// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary> /// events. Fire-and-forget.</summary>
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected); public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
/// <summary> /// <summary>
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="DriverInstanceId">The driver instance that was restarted.</param> /// <param name="DriverInstanceId">The driver instance that was restarted.</param>
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param> /// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason); 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);
/// <summary>
/// Sent to the parent (<see cref="DriverHostActor"/>) AFTER an <see cref="ApplyDelta"/> has fully
/// re-initialised the driver (i.e. <see cref="IDriver.ReinitializeAsync"/> completed). The host uses
/// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
/// <see cref="IDependencyConsumer.DependencyRefs"/> 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.
/// </summary>
/// <param name="DriverInstanceId">The instance whose adapter should be re-registered.</param>
public sealed record DeltaApplied(string DriverInstanceId);
public sealed record WriteAttribute(string TagId, object Value); public sealed record WriteAttribute(string TagId, object Value);
public sealed record WriteAttributeResult(bool Success, string? Reason); public sealed record WriteAttributeResult(bool Success, string? Reason);
/// <summary> /// <summary>
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson; private string? _currentConfigJson;
/// <summary>#664: set when <see cref="AdoptConfigDuringInit"/> adopts a new config mid-connect; the next
/// (generation-matched) <see cref="InitializeSucceeded"/> sends <see cref="DeltaApplied"/> 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 <see cref="HandleApplyDeltaAsync"/> path sends.</summary>
private bool _adoptedConfigPendingNotify;
/// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is /// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is
/// honoured only when its generation matches the latest; an older result is from a superseded attempt /// honoured only when its generation matches the latest; an older result is from a superseded attempt
/// (e.g. an <see cref="ApplyDelta"/> 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.</summary> /// on the actor thread, so no lock is needed.</summary>
private int _initGeneration; private int _initGeneration;
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Stubbed drivers accept the standard message contracts but return deterministic // Stubbed drivers accept the standard message contracts but return deterministic
// success without touching real hardware. Read returns null; Write succeeds. // success without touching real hardware. Read returns null; Write succeeds.
Receive<InitializeRequested>(_ => { /* no-op */ }); Receive<InitializeRequested>(_ => { /* no-op */ });
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed"))); Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply). // Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ }); Receive<RouteAlarmAck>(_ => { /* 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). // fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
Receive<RouteAlarmAck>(_ => Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId)); _log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg => Receive<InitializeSucceeded>(msg =>
{ {
if (msg.Generation != _initGeneration) return; if (msg.Generation != _initGeneration) return;
_log.Info("DriverInstance {Id}: connected", _driverInstanceId); _log.Info("DriverInstance {Id}: connected", _driverInstanceId);
Become(Connected); Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot(); PublishHealthSnapshot();
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); 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. // reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
_connectedSinceUtc = DateTime.UtcNow; _connectedSinceUtc = DateTime.UtcNow;
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
Receive<DisconnectObserved>(msg => Receive<DisconnectObserved>(msg =>
{ {
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _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). // no reply — the OPC UA condition state already committed locally on the Part 9 ack).
Receive<RouteAlarmAck>(_ => Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId)); _log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg => Receive<InitializeSucceeded>(msg =>
{ {
if (msg.Generation != _initGeneration) return; if (msg.Generation != _initGeneration) return;
Timers.Cancel("retry-connect"); Timers.Cancel("retry-connect");
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId); _log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
Become(Connected); Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot(); PublishHealthSnapshot();
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); AttachAlarmSource();
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}); });
} }
/// <summary>Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits
/// immediately with the new config. <see cref="InitializeAsync"/> swaps <c>_currentConfigJson</c> 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).</summary>
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));
}
/// <summary>Send the deferred <see cref="DeltaApplied"/> for a mid-connect adoption (#664) once the driver
/// has actually initialised on the adopted config. A no-op otherwise.</summary>
private void NotifyAdoptedConfig()
{
if (!_adoptedConfigPendingNotify) return;
_adoptedConfigPendingNotify = false;
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
}
/// <summary>Adopt a new config while Connected, in place, via <see cref="IDriver.ReinitializeAsync"/>.
/// <para>#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 <c>finally</c>, 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 <see cref="IDriver.InitializeAsync"/>'s JSON.</para>
/// <para>Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).</para></summary>
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();
}
}
/// <summary>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 <see cref="UnsubscribeAsync"/>
/// (#663).</summary>
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) private async Task HandleWriteAsync(WriteAttribute msg)
{ {
var replyTo = Sender; var replyTo = Sender;
@@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary> /// <summary>
/// Pure diff between the currently-running driver children (keyed by /// Pure diff between the currently-running driver children (keyed by
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied /// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
/// deployment artifact. The DriverHostActor consumes the three lists and calls /// deployment artifact. The DriverHostActor consumes the two lists and calls
/// spawn / ApplyDelta / stop on its child actors accordingly. /// 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 <c>ApplyDelta</c> seam.
/// </summary> /// </summary>
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param> /// <param name="ToSpawn">Specs with no current child (or whose child is being replaced) — create a new actor.</param>
/// <param name="ToApplyDelta">
/// In-place config deltas. <b>Always empty since #516</b> — a DriverConfig change is now a
/// stop + respawn so the factory is the single parse authority. Retained because
/// <c>DriverInstanceActor</c> still handles <c>ApplyDelta</c> on its own config-adoption path
/// (a config arriving mid-connect), and removing the list would hide that seam.
/// </param>
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param> /// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
public sealed record DriverSpawnPlan( public sealed record DriverSpawnPlan(
IReadOnlyList<DriverInstanceSpec> ToSpawn, IReadOnlyList<DriverInstanceSpec> ToSpawn,
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
IReadOnlyList<string> ToStop); IReadOnlyList<string> ToStop);
public static class DriverSpawnPlanner public static class DriverSpawnPlanner
{ {
/// <summary> /// <summary>
/// Compute the spawn/delta/stop sets. Disabled entries in <paramref name="target"/> are /// Compute the spawn/stop sets. Disabled entries in <paramref name="target"/> are
/// treated as "not desired here": if a child exists for the id it goes into ToStop, /// 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). /// otherwise the row is dropped entirely (no spawn for a disabled driver).
/// </summary> /// </summary>
/// <param name="current">The currently running driver children keyed by ID.</param> /// <param name="current">The currently running driver children keyed by ID.</param>
/// <param name="target">The target driver instances from the deployment artifact.</param> /// <param name="target">The target driver instances from the deployment artifact.</param>
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn, apply-delta, and stop sets.</returns> /// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn and stop sets.</returns>
public static DriverSpawnPlan Compute( public static DriverSpawnPlan Compute(
IReadOnlyDictionary<string, DriverChildSnapshot> current, IReadOnlyDictionary<string, DriverChildSnapshot> current,
IReadOnlyList<DriverInstanceSpec> target) IReadOnlyList<DriverInstanceSpec> target)
{ {
var toSpawn = new List<DriverInstanceSpec>(); var toSpawn = new List<DriverInstanceSpec>();
var toDelta = new List<DriverInstanceSpec>();
var toStop = new List<string>(); var toStop = new List<string>();
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal); var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
@@ -83,7 +76,7 @@ public static class DriverSpawnPlanner
toSpawn.Add(spec); toSpawn.Add(spec);
} }
return new DriverSpawnPlan(toSpawn, toDelta, toStop); return new DriverSpawnPlan(toSpawn, toStop);
} }
} }
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary> /// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs /// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's /// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + runtime. It now releases them first. /// probe loop + runtime. It now releases them first.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
@@ -396,7 +396,7 @@ public sealed class CalculationDriverTests
} }
/// <summary>The skip ledger must be REBUILT on ReinitializeAsync, not just at construction — an /// <summary>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.</summary> /// since RebuildTagTable is the one routine both the ctor and reinit share.</summary>
[Fact] [Fact]
public async Task GetSkippedTags_updates_after_reinitialize() public async Task GetSkippedTags_updates_after_reinitialize()
@@ -414,9 +414,9 @@ public sealed class CalculationDriverTests
} }
/// <summary> /// <summary>
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a LIVE driver with a new config when an ApplyDelta lands /// Gitea #664 — <c>InitializeAsync</c> must serve the config document it is handed (the <c>IDriver</c>
/// mid-connect (<c>DriverInstanceActor.AdoptConfigDuringInit</c>). It used to ignore the JSON, so the /// contract). It used to ignore the JSON, so a live driver re-initialised with a new config kept its
/// driver kept its constructor tag set while the deploy reported the change applied. A config with a body /// constructor tag set. A config with a body
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from). /// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
/// </summary> /// </summary>
[Fact] [Fact]
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary> /// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs /// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's /// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + client. It now releases them first. /// probe loop + client. It now releases them first.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
@@ -209,9 +209,8 @@ public sealed class GalaxyDriverFactoryTests
} }
/// <summary> /// <summary>
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a live driver with a NEW config when an ApplyDelta lands /// Gitea #664 — <c>InitializeAsync</c> must not silently ignore the config it is handed. Galaxy cannot
/// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD /// hot-swap its options, and it used to ignore the JSON — connecting on the OLD endpoint. A non-equivalent config is now refused, exactly
/// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly
/// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises. /// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -265,9 +265,8 @@ public sealed class MTConnectDriverLifecycleTests
[Fact] [Fact]
public async Task Initialize_honours_the_config_json_over_the_constructor_options() public async Task Initialize_honours_the_config_json_over_the_constructor_options()
{ {
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor // The IDriver contract: the driver serves the config document it is handed, not only its
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every // constructor options — ignoring it would turn an in-place config change into a silent no-op.
// MTConnect config change a silent no-op that still seals the deployment green.
MTConnectDriverOptions? seen = null; MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o => var driver = new MTConnectDriver(Opts(), "mt1", o =>
{ {
@@ -533,7 +532,7 @@ public sealed class MTConnectDriverLifecycleTests
[Fact] [Fact]
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving() 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. // not down a driver that is happily serving its previous, valid configuration.
var (driver, client) = await InitializedDriverAsync(); var (driver, client) = await InitializedDriverAsync();
@@ -225,9 +225,8 @@ public sealed class ModbusLifecycleHygieneTests
// -------------------- Re-initialize transport hygiene -------------------- // -------------------- Re-initialize transport hygiene --------------------
/// <summary> /// <summary>
/// A second <c>InitializeAsync</c> on a live driver — the shape /// A second <c>InitializeAsync</c> on a live driver with no teardown between (the <c>IDriver</c>
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> produces when an ApplyDelta lands while the /// contract allows it) must dispose the transport the previous initialize
/// driver is Connecting/Reconnecting — must dispose the transport the previous initialize
/// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten, /// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten,
/// stranding a connected transport no later dispose could reach. /// stranding a connected transport no later dispose could reach.
/// </summary> /// </summary>
@@ -251,7 +250,7 @@ public sealed class ModbusLifecycleHygieneTests
}); });
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
// No ShutdownAsync in between — exactly what AdoptConfigDuringInit does. // No ShutdownAsync in between.
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
handed.Count.ShouldBe(2, "each initialize must build its own transport"); handed.Count.ShouldBe(2, "each initialize must build its own transport");
@@ -415,8 +414,8 @@ public sealed class ModbusLifecycleHygieneTests
/// <summary> /// <summary>
/// Two initializes racing on the same driver — the shape <c>DriverInstanceActor</c> produces by /// Two initializes racing on the same driver — the shape <c>DriverInstanceActor</c> produces by
/// dispatching initialize on a fire-and-forget <c>Task.Run</c> with <c>AdoptConfigDuringInit</c> /// dispatching initialize on a fire-and-forget <c>Task.Run</c>, where a reconnect retry tick can
/// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one /// 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 <c>_transport</c> /// initialize disposes the transport the other just published (or nulls <c>_transport</c>
/// mid-connect), leaving the driver holding a disposed transport. /// mid-connect), leaving the driver holding a disposed transport.
/// </summary> /// </summary>
@@ -28,10 +28,9 @@ public sealed class MqttDriverSessionLifecycleTests
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Reachable with no teardown of the driver's own: <c>DriverInstanceActor</c> re-runs /// The <c>IDriver</c> contract allows re-initialising a live driver with no
/// <c>InitializeAsync</c> with no <c>ShutdownAsync</c> when an <c>ApplyDelta</c> arrives while /// <c>ShutdownAsync</c>, and the #527 retry path's <c>teardownFirst</c> flag does not stop a
/// the driver is Connecting/Reconnecting (<c>AdoptConfigDuringInit</c>). The /// retry overlapping a still-running attempt, so the driver must not rely on a teardown.
/// <c>teardownFirst</c> flag on the #527 retry path covers only the retry path.
/// </para> /// </para>
/// <para> /// <para>
/// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak /// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak
@@ -483,8 +483,8 @@ public sealed class S7DriverReconnectTests
} }
/// <summary> /// <summary>
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as /// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between — the
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and /// <c>IDriver</c> contract allows it) must stop the first probe loop and
/// dispose the first connection rather than orphan them. /// dispose the first connection rather than orphan them.
/// </summary> /// </summary>
[Fact] [Fact]
@@ -31,7 +31,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
return new RawTagEntry(rawPath, json, WriteIdempotent: false); return new RawTagEntry(rawPath, json, WriteIdempotent: false);
} }
/// <summary>Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy /// <summary>Build the merged DriverConfig JSON an in-place re-bind reads (mirrors the deploy
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary> /// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary>
private static string ConfigJson(params RawTagEntry[] tags) => private static string ConfigJson(params RawTagEntry[] tags) =>
System.Text.Json.JsonSerializer.Serialize(new { rawTags = 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)); published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
}, PresenceBudget, TimeSpan.FromMilliseconds(100)); }, 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; // 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. // "remove" is dropped entirely.
var newConfig = ConfigJson( 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). // 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" }); driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
// Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied). // The adapter re-reads the driver's refs only when told to; re-register AFTER the reinit so it adopts the
// Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b). // 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()); adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the // Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
@@ -369,56 +369,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
.Connected.ShouldBeTrue(); .Connected.ShouldBeTrue();
} }
/// <summary>
/// Gitea #663 — an in-place config change on a Connected driver (<see cref="DriverInstanceActor.ApplyDelta"/>
/// → <see cref="IDriver.ReinitializeAsync"/>) 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.
/// </summary>
/// <param name="reinitKeepsSubscriptions">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.</param>
[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<DriverInstanceActor.ApplyResult>(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<DriverInstanceActor.AttributeAlarmPublished>(_ => true, PresenceBudget)
.Args.ConditionId.ShouldBe("Temp.HiHi");
}
// --- stub drivers ---------------------------------------------------------------------------- // --- stub drivers ----------------------------------------------------------------------------
private class StubDriver : IDriver private class StubDriver : IDriver
@@ -439,7 +389,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
} }
/// <summary>Reinitializes the driver.</summary> /// <summary>Reinitializes the driver.</summary>
public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
Task.CompletedTask; Task.CompletedTask;
/// <summary>Shuts down the driver.</summary> /// <summary>Shuts down the driver.</summary>
@@ -463,55 +413,14 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
/// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary> /// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary>
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0; public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
private readonly object _liveLock = new();
private readonly HashSet<object> _liveData = new(ReferenceEqualityComparer.Instance);
private readonly HashSet<object> _liveAlarms = new(ReferenceEqualityComparer.Instance);
private int _reinitializeCount;
/// <summary>Driver-side data subscriptions currently open (subscribed and not yet released).</summary>
public int LiveDataSubscriptions { get { lock (_liveLock) return _liveData.Count; } }
/// <summary>Driver-side native-alarm subscriptions currently open.</summary>
public int LiveAlarmSubscriptions { get { lock (_liveLock) return _liveAlarms.Count; } }
/// <summary>Number of <see cref="ReinitializeAsync"/> calls.</summary>
public int ReinitializeCount => Volatile.Read(ref _reinitializeCount);
/// <summary>When false (the default, and every production driver since #630), a reinit releases the
/// device/session state and with it every driver-side subscription; when true it keeps them — the
/// shape that would double-subscribe if the actor re-subscribed without releasing first.</summary>
public bool ReinitKeepsSubscriptions { get; init; }
/// <inheritdoc />
public override Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _reinitializeCount);
if (!ReinitKeepsSubscriptions)
{
lock (_liveLock)
{
_liveData.Clear();
_liveAlarms.Clear();
}
}
return Task.CompletedTask;
}
/// <summary>Subscribes to the specified full references.</summary> /// <summary>Subscribes to the specified full references.</summary>
public Task<ISubscriptionHandle> SubscribeAsync( public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
{ Task.FromResult<ISubscriptionHandle>(_subHandle);
var handle = new StubHandle();
lock (_liveLock) _liveData.Add(handle);
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <summary>Unsubscribes from the specified subscription handle.</summary> /// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
{ Task.CompletedTask;
lock (_liveLock) _liveData.Remove(handle);
return Task.CompletedTask;
}
private int _subscribeAlarmsCallCount; private int _subscribeAlarmsCallCount;
@@ -530,9 +439,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount); var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
if (call <= AlarmSubscribeFailuresBeforeSuccess) if (call <= AlarmSubscribeFailuresBeforeSuccess)
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)"); throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
var handle = new StubAlarmHandle(); return Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
lock (_liveLock) _liveAlarms.Add(handle);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
} }
/// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready /// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready
@@ -540,11 +447,8 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
public int AlarmSubscribeFailuresBeforeSuccess { get; init; } public int AlarmSubscribeFailuresBeforeSuccess { get; init; }
/// <summary>Cancels an alarm subscription.</summary> /// <summary>Cancels an alarm subscription.</summary>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
{ Task.CompletedTask;
lock (_liveLock) _liveAlarms.Remove(handle);
return Task.CompletedTask;
}
/// <summary>Acknowledges a batch of alarms.</summary> /// <summary>Acknowledges a batch of alarms.</summary>
public Task AcknowledgeAsync( public Task AcknowledgeAsync(
@@ -11,28 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public sealed class DriverInstanceActorTests : RuntimeActorTestBase public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{ {
/// <summary>Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.</summary>
[Fact]
public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success()
{
var driver = new StubDriver();
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
// Drive: Initialize → Connected.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// Issue ApplyDelta and capture the reply via Ask.
var correlation = CorrelationId.NewId();
var reply = await actor.Ask<DriverInstanceActor.ApplyResult>(
new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation),
TimeSpan.FromSeconds(3));
reply.Success.ShouldBeTrue();
reply.Correlation.ShouldBe(correlation);
driver.ReinitializeCount.ShouldBe(1);
}
/// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary> /// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary>
[Fact] [Fact]
public void Initialize_failure_keeps_actor_in_Reconnecting_state() public void Initialize_failure_keeps_actor_in_Reconnecting_state()
@@ -305,109 +283,6 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
} }
/// <summary>A driver stuck Reconnecting (init failing on a bad config) adopts a corrected config
/// delivered via ApplyDelta and connects on it — no node restart. Closes pending.md #7.</summary>
[Fact]
public async Task ApplyDelta_while_Reconnecting_adopts_new_config_and_connects()
{
const string bad = "{\"v\":\"bad\"}";
const string good = "{\"v\":\"good\"}";
var driver = new SubscribableStubDriver
{
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
};
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested(bad));
AwaitCondition(() => driver.InitializeCount >= 2, TimeSpan.FromSeconds(2));
driver.SubscribeCount.ShouldBe(0);
var correlation = CorrelationId.NewId();
var reply = await actor.Ask<DriverInstanceActor.ApplyResult>(
new DriverInstanceActor.ApplyDelta(good, correlation), TimeSpan.FromSeconds(3));
reply.Success.ShouldBeTrue();
reply.Correlation.ShouldBe(correlation);
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3));
driver.InitConfigs.ShouldContain(good);
}
/// <summary>
/// Gitea #664 — a mid-connect adoption sends <see cref="DriverInstanceActor.DeltaApplied"/> once the
/// driver has initialised on the ADOPTED config, so the host re-registers a dependency consumer's mux
/// interest (the Calculation driver's refs change with its tag set). It used to send nothing, leaving the
/// old interest registered. The notification is one-shot: a later reconnect on the same config sends none.
/// </summary>
[Fact]
public void A_mid_connect_adoption_notifies_DeltaApplied_once_after_the_adopted_init_succeeds()
{
const string bad = "{\"v\":\"bad\"}";
const string good = "{\"v\":\"good\"}";
var driver = new SubscribableStubDriver
{
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
};
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is not DriverInstanceActor.DeltaApplied);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested(bad));
AwaitCondition(() => driver.InitializeCount >= 2, PresenceBudget);
actor.Tell(new DriverInstanceActor.ApplyDelta(good, CorrelationId.NewId()));
parent.ExpectMsg<DriverInstanceActor.DeltaApplied>(PresenceBudget);
driver.InitConfigs.ShouldContain(good, "the notification must follow an init on the adopted config");
// A reconnect on the same config re-runs InitializeSucceeded; the positive control is its re-subscribe.
var subscribes = driver.SubscribeCount;
actor.Tell(new DriverInstanceActor.ForceReconnect());
AwaitCondition(() => driver.SubscribeCount > subscribes, PresenceBudget);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>A stale InitializeSucceeded from an old (superseded) config cannot hijack the state:
/// while a gated old-config init is pending in Connecting, an ApplyDelta adopts a new config; the
/// old init completing afterwards is ignored, and only the new config drives Connected.</summary>
[Fact]
public async Task ApplyDelta_supersedes_in_flight_init_so_stale_result_is_ignored()
{
const string v1 = "{\"v\":1}";
const string v2 = "{\"v\":2}";
var gate1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new SubscribableStubDriver
{
InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task,
};
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested(v1));
AwaitCondition(() => driver.InitConfigs.Contains(v1), TimeSpan.FromSeconds(2));
await actor.Ask<DriverInstanceActor.ApplyResult>(
new DriverInstanceActor.ApplyDelta(v2, CorrelationId.NewId()), TimeSpan.FromSeconds(3));
AwaitCondition(() => driver.InitConfigs.Contains(v2), TimeSpan.FromSeconds(2));
gate1.SetResult();
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
driver.SubscribeCount.ShouldBe(0);
gate2.SetResult();
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3));
}
/// <summary> /// <summary>
/// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way /// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way
/// out, so the host latches its value nodes <c>BadNoCommunication</c>. /// out, so the host latches its value nodes <c>BadNoCommunication</c>.
@@ -19,7 +19,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target); var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.Count.ShouldBe(2); plan.ToSpawn.Count.ShouldBe(2);
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty();
} }
@@ -36,7 +35,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target); var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty(); plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty();
} }
@@ -65,7 +63,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}"); plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
// The whole point: nothing is left on the in-place path that could discard the edit. // The whole point: nothing is left on the in-place path that could discard the edit.
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary>Verifies that removed drivers are routed to ToStop.</summary> /// <summary>Verifies that removed drivers are routed to ToStop.</summary>
@@ -83,7 +80,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBe(new[] { "b" }); plan.ToStop.ShouldBe(new[] { "b" });
plan.ToSpawn.ShouldBeEmpty(); plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary> /// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary>
@@ -100,7 +96,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a"); plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.ShouldBeEmpty(); plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary>Verifies that disabled new drivers are not spawned.</summary> /// <summary>Verifies that disabled new drivers are not spawned.</summary>
@@ -113,7 +108,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target); var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty(); plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty();
} }
@@ -131,7 +125,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a"); plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip"); plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary> /// <summary>
@@ -153,7 +146,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a"); plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary> /// <summary>
@@ -173,7 +165,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a"); plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
} }
/// <summary> /// <summary>
@@ -196,6 +187,5 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a"); plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
} }
} }