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;
FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
"tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
"tag name collision"); S7 swaps `Plc` under `_gate`.
- **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every
deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook
`OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile
re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick.
A Connected in-place `ApplyDelta` (unreachable from deploys since #516) releases both subscriptions at the
driver, reinitialises, then re-subscribes both (#663) — it used to keep handles the reinit had invalidated.
A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)`, so **every driver's `InitializeAsync` must adopt a
config with a body** — Calculation now does, Galaxy (no hot-swap) now refuses a non-equivalent one, and the actor
sends `DeltaApplied` once the adopted init succeeds (#664).
- **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied`
seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect
`AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync`
stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is
handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630).
`docs/DriverLifecycle.md`.
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
+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
subscription from its session when the subscribe throws, so those retries do not accumulate.
End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`.
- **An in-place `ApplyDelta` on a Connected driver releases and re-establishes BOTH subscriptions (#663).**
`ReinitializeAsync` drops every driver-side subscription (#630), but the handler used to keep the actor's
handles, and the reconcile only re-subscribes a *missing* handle — so the driver went silent until a
reconnect. It now releases data and alarms at the driver first (explicitly, so a driver whose reinit keeps
its subscriptions is not left holding two), reinitialises, and re-subscribes both in a `finally` (a failed
reinit has lost them too). It stays on `ReinitializeAsync` rather than shutdown + initialise because
Calculation and Galaxy take their config at construction and ignore the JSON passed to `InitializeAsync`.
Not reachable from a deploy since #516 (a DriverConfig change respawns). Pin:
`An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription` (one case per wrong fix).
- **A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)` — so every driver's `InitializeAsync` must
honour the JSON (#664).** `AdoptConfigDuringInit` adopts a delta that lands while Connecting/Reconnecting by
re-initialising, and acks on adoption. Every driver re-parses a config with a body (#516/#630) except two that
used to ignore it: **Calculation** now rebuilds its tag table from it (sharing `AdoptConfig` with
`ReinitializeAsync`, and disposing any running timer scheduler first), and **Galaxy**, which cannot hot-swap
its options, now **refuses** a non-equivalent config with the same `NotSupportedException` its
`ReinitializeAsync` throws — it used to connect on the old endpoint while the deploy reported success. A
`"{}"`/empty document still keeps the constructor options. Once the adopted init succeeds the actor sends
`DeltaApplied`, so the host re-registers a dependency consumer's mux interest — the mid-connect path used to
send nothing. A new driver must follow the same rule. Unreachable from a deploy since #516.
- **There is no in-place config path — a config change is a respawn (#665).** Since #516 any DriverConfig,
DriverType or ResilienceConfig change is stop + respawn, so the factory is the single parse authority. The old
in-place seam (host `ApplyChildDelta` → actor `ApplyDelta` → `ReinitializeAsync`, plus the mid-connect
`AdoptConfigDuringInit`, `ApplyResult` and `DeltaApplied`) had no producer and was deleted after two latent
bugs were found in it: a Connected reinit kept subscription handles the driver had dropped (#663), and the
mid-connect path re-ran `InitializeAsync(newConfig)` on two drivers that ignored the JSON (#664). Kept from that
work: Calculation's `InitializeAsync` adopts a config with a body and Galaxy's refuses a non-equivalent one,
because the `IDriver` contract is that a driver serves the document it is handed. `ReinitializeAsync` stays as a
per-driver contract the runtime does not call.
- **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision`
asserts only that a child exists after a restart, and it passed green against the dead driver 03/S16
describes. It is kept — its own subject, the `InvalidActorNameException`, is real — but the load-bearing
@@ -458,8 +448,8 @@ have leaked ~360 transports at the 10 s retry interval. The shutdown is best-eff
on it still gets its initialise attempt.
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not
the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no*
teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
the only way in: the `IDriver` contract allows `InitializeAsync` on a live driver with *no* teardown, and a retry
tick can overlap a still-running attempt, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and
OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
+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`.
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
reconciles in two halves:
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed
driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`;
a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToSpawn`. A changed
driver config (DriverConfig, DriverType or ResilienceConfig) is a stop + respawn (#516) — there is no
in-place path (#665 deleted the unreachable `ApplyDelta` seam); a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
running children are left alone (never an empty configuration).
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
+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
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527
comms-loss watchdog's retry path, and an `ApplyDelta` that changes the endpoint or credentials) — builds
comms-loss watchdog's retry path, and a `ReinitializeAsync` that changes the endpoint or credentials) — builds
a *new* connection and re-attaches the *same* `MqttSubscriptionManager`, and the re-subscribe then comes
from the host (`DriverInstanceActor.ResubscribeDesired`) rather than from the callback. `AttachTo`
therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
+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).
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
@@ -24,8 +24,9 @@ public interface IDriver
/// <summary>
/// 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
/// driver's config changed in the new deployment.
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
/// 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>
/// <remarks>
/// 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 />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Release the previous device states' probe loops + handles before
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
@@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
// #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
@@ -67,7 +67,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
// and REBUILT on every ReinitializeAsync / body-carrying InitializeAsync (an in-place re-bind of an edited calc-tag set).
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
@@ -139,7 +139,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
/// (<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
/// 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
/// 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
@@ -200,7 +200,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
// tolerant) so an in-place config payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -209,8 +209,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
/// 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
/// current tags so a malformed delta can never blank the driver.</summary>
private CalculationDriverOptions ParseOptions(string driverConfigJson)
@@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <inheritdoc />
/// <remarks>
/// #664: <c>InitializeAsync</c> is reachable on a LIVE driver — <c>DriverInstanceActor.AdoptConfigDuringInit</c>
/// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a
/// #664: <c>InitializeAsync</c> takes a config document and the <see cref="IDriver"/> contract is that the
/// driver serves it (the runtime's mid-connect adoption path was deleted in #665) — so it adopts a config carrying a
/// real body (the #516 convention every other driver follows), and disposes any running timer scheduler
/// before arming a fresh one (#630: re-initialise without teardown must not leak). A <c>"{}"</c> / empty
/// document keeps the constructor-supplied tags, as unit tests rely on.
@@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
// An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
// added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
// itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
// new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
StopTimers();
AdoptConfig(driverConfigJson);
// Re-arm timers off the rebuilt tag set.
@@ -196,8 +196,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <returns>A completed task.</returns>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
// #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
// re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
ResetAlarmHealth();
@@ -349,8 +349,8 @@ public sealed class GalaxyDriver
/// <inheritdoc />
/// <remarks>
/// #664: <c>InitializeAsync</c> is reachable on a live driver with a NEW config —
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> re-runs it when an ApplyDelta lands mid-connect. The
/// #664: <c>InitializeAsync</c> takes a config document, and the <see cref="IDriver"/> contract is that a
/// driver serves the one it is handed (the runtime's own mid-connect adoption path was deleted in #665). The
/// driver cannot hot-swap its options (they shape the gw session; see <see cref="ReinitializeAsync"/>), so a
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver
/// would otherwise connect on its old endpoint while the deploy reported the change applied. A
@@ -21,11 +21,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// Agent. Every connection is owned by <see cref="InitializeAsync"/>.
/// </para>
/// <para>
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c>
/// calls <see cref="ReinitializeAsync"/> with the new document and never rebuilds the object
/// — so a driver that read only its constructor options would turn every MTConnect config
/// edit into a silent no-op that still seals the deployment green. This class therefore
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> <see cref="IDriver.InitializeAsync"/>
/// and <see cref="ReinitializeAsync"/> both take the document, and the contract is that the driver
/// serves it — a driver that read only its constructor options would turn an in-place config
/// change into a silent no-op. (The runtime itself now applies a config change by respawning,
/// #516/#665.) This class therefore
/// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body,
/// 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"
@@ -95,7 +95,7 @@ public sealed class ModbusDriver
/// 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>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>.
/// </summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
@@ -297,9 +297,9 @@ public sealed class ModbusDriver
// Release whatever a PREVIOUS initialize published before building the replacements. All
// three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
// so a second initialize on a live driver stranded a connected transport plus two running
// background loops that no later dispose could reach. Reachable without any teardown of
// ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta
// lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit).
// background loops that no later dispose could reach. The IDriver contract lets a
// caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
// can overlap a still-running attempt, so this must not rely on a teardown having happened.
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
@@ -983,9 +983,9 @@ public sealed class MqttDriver
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously
// overwritten, so a second initialize on a live driver stranded a connected, supervised
// broker session — plus a whole second primary-host connection — that no later dispose
// could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs
// InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in
// Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync
// could reach. The IDriver contract lets a caller
// re-initialise a live driver without ShutdownAsync, and a reconnect retry can overlap a running
// attempt. Idempotent, so the ReinitializeAsync
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
// three nulls.
await TeardownAsync().ConfigureAwait(false);
@@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
// it with no teardown first); `Session = session` below would otherwise orphan the previous session,
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt); `Session = session` below would otherwise orphan the previous session,
// its keep-alive handler and any reconnect handler.
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
@@ -225,8 +225,8 @@ public sealed class S7Driver
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection
// before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
// subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseConnectionAsync().ConfigureAwait(false);
@@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Release the previous device states' probe loops + clients before
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + clients before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
@@ -406,7 +406,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
// the factory (TryCreate) and the init (InitializeRequested) hand the driver the same shape.
var rawTagsByDriver = BuildRawTagsByDriver(root);
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
@@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// value maps so stale condition state never leaks across redeploys.</summary>
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
/// <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.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
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.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
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
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
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 */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
}
@@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <summary>
/// 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
/// 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.
@@ -2917,16 +2905,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
foreach (var id in plan.ToStop) StopChild(id);
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
// v3 (WP7): a dependency-consumer driver's declared dependency refs change when its tags/scripts are
// edited — the adapter must re-register the NEW set on the mux. Newly-spawned adapters register in
// PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished
// reinitialising (DriverInstanceActor.DeltaApplied → HandleDeltaApplied). That notification is
// sequenced AFTER ReinitializeAsync rebuilds DependencyRefs — a synchronous re-register HERE would
// re-read the STALE ref set (ApplyDelta runs asynchronously on the child), so it is deliberately NOT
// done inline.
// edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
// adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
}
/// <summary>
@@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
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)
{
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>
/// </list>
///
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize,
/// per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// state machine so the Phase 6 control-plane integration tests can target it.
/// </summary>
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
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
/// events. Fire-and-forget.</summary>
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
/// <summary>
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <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>
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 WriteAttributeResult(bool Success, string? Reason);
/// <summary>
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
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
/// 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>
private int _initGeneration;
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Stubbed drivers accept the standard message contracts but return deterministic
// success without touching real hardware. Read returns null; Write succeeds.
Receive<InitializeRequested>(_ => { /* no-op */ });
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
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).
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).
Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg =>
{
if (msg.Generation != _initGeneration) return;
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -522,7 +500,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
_connectedSinceUtc = DateTime.UtcNow;
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
Receive<DisconnectObserved>(msg =>
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
@@ -771,14 +748,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg =>
{
if (msg.Generation != _initGeneration) return;
Timers.Cancel("retry-connect");
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
}
/// <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)
{
var replyTo = Sender;
@@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// Pure diff between the currently-running driver children (keyed by
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
/// deployment artifact. The DriverHostActor consumes the three lists and calls
/// spawn / ApplyDelta / stop on its child actors accordingly.
/// deployment artifact. The DriverHostActor consumes the two lists and calls
/// spawn / stop on its child actors accordingly. There is no in-place config path: since #516 a config
/// change is a stop + respawn, and #665 deleted the unreachable in-place <c>ApplyDelta</c> seam.
/// </summary>
/// <param name="ToSpawn">Specs with no current child — 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="ToSpawn">Specs with no current child (or whose child is being replaced) — create a new actor.</param>
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
public sealed record DriverSpawnPlan(
IReadOnlyList<DriverInstanceSpec> ToSpawn,
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
IReadOnlyList<string> ToStop);
public static class DriverSpawnPlanner
{
/// <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,
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
/// </summary>
/// <param name="current">The currently running driver children keyed by ID.</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(
IReadOnlyDictionary<string, DriverChildSnapshot> current,
IReadOnlyList<DriverInstanceSpec> target)
{
var toSpawn = new List<DriverInstanceSpec>();
var toDelta = new List<DriverInstanceSpec>();
var toStop = new List<string>();
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
@@ -83,7 +76,7 @@ public static class DriverSpawnPlanner
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;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
/// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + runtime. It now releases them first.
/// </summary>
[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
/// 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>
[Fact]
public async Task GetSkippedTags_updates_after_reinitialize()
@@ -414,9 +414,9 @@ public sealed class CalculationDriverTests
}
/// <summary>
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a LIVE driver with a new config when an ApplyDelta lands
/// mid-connect (<c>DriverInstanceActor.AdoptConfigDuringInit</c>). It used to ignore the JSON, so the
/// driver kept its constructor tag set while the deploy reported the change applied. A config with a body
/// Gitea #664 — <c>InitializeAsync</c> must serve the config document it is handed (the <c>IDriver</c>
/// contract). It used to ignore the JSON, so a live driver re-initialised with a new config kept its
/// constructor tag set. A config with a body
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
/// </summary>
[Fact]
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
/// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
@@ -209,9 +209,8 @@ public sealed class GalaxyDriverFactoryTests
}
/// <summary>
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a live driver with a NEW config when an ApplyDelta lands
/// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD
/// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly
/// Gitea #664 — <c>InitializeAsync</c> must not silently ignore the config it is handed. Galaxy cannot
/// hot-swap its options, and it used to ignore the JSON — connecting on the OLD endpoint. A non-equivalent config is now refused, exactly
/// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises.
/// </summary>
[Fact]
@@ -265,9 +265,8 @@ public sealed class MTConnectDriverLifecycleTests
[Fact]
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
{
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
// MTConnect config change a silent no-op that still seals the deployment green.
// The IDriver contract: the driver serves the config document it is handed, not only its
// constructor options — ignoring it would turn an in-place config change into a silent no-op.
MTConnectDriverOptions? seen = null;
var driver = new MTConnectDriver(Opts(), "mt1", o =>
{
@@ -533,7 +532,7 @@ public sealed class MTConnectDriverLifecycleTests
[Fact]
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
{
// Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
// Blast radius: an unparseable in-place config edit fails the reinitialize call; it must
// not down a driver that is happily serving its previous, valid configuration.
var (driver, client) = await InitializedDriverAsync();
@@ -225,9 +225,8 @@ public sealed class ModbusLifecycleHygieneTests
// -------------------- Re-initialize transport hygiene --------------------
/// <summary>
/// A second <c>InitializeAsync</c> on a live driver — the shape
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> produces when an ApplyDelta lands while the
/// driver is Connecting/Reconnecting — must dispose the transport the previous initialize
/// A second <c>InitializeAsync</c> on a live driver with no teardown between (the <c>IDriver</c>
/// contract allows it) must dispose the transport the previous initialize
/// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten,
/// stranding a connected transport no later dispose could reach.
/// </summary>
@@ -251,7 +250,7 @@ public sealed class ModbusLifecycleHygieneTests
});
await drv.InitializeAsync("{}", CancellationToken.None);
// No ShutdownAsync in between — exactly what AdoptConfigDuringInit does.
// No ShutdownAsync in between.
await drv.InitializeAsync("{}", CancellationToken.None);
handed.Count.ShouldBe(2, "each initialize must build its own transport");
@@ -415,8 +414,8 @@ public sealed class ModbusLifecycleHygieneTests
/// <summary>
/// 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>
/// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
/// dispatching initialize on a fire-and-forget <c>Task.Run</c>, where a reconnect retry tick can
/// overlap a still-running attempt — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
/// initialize disposes the transport the other just published (or nulls <c>_transport</c>
/// mid-connect), leaving the driver holding a disposed transport.
/// </summary>
@@ -28,10 +28,9 @@ public sealed class MqttDriverSessionLifecycleTests
/// </summary>
/// <remarks>
/// <para>
/// Reachable with no teardown of the driver's own: <c>DriverInstanceActor</c> re-runs
/// <c>InitializeAsync</c> with no <c>ShutdownAsync</c> when an <c>ApplyDelta</c> arrives while
/// the driver is Connecting/Reconnecting (<c>AdoptConfigDuringInit</c>). The
/// <c>teardownFirst</c> flag on the #527 retry path covers only the retry path.
/// The <c>IDriver</c> contract allows re-initialising a live driver with no
/// <c>ShutdownAsync</c>, and the #527 retry path's <c>teardownFirst</c> flag does not stop a
/// retry overlapping a still-running attempt, so the driver must not rely on a teardown.
/// </para>
/// <para>
/// 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>
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between — the
/// <c>IDriver</c> contract allows it) must stop the first probe loop and
/// dispose the first connection rather than orphan them.
/// </summary>
[Fact]
@@ -31,7 +31,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
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>
private static string ConfigJson(params RawTagEntry[] tags) =>
System.Text.Json.JsonSerializer.Serialize(new { rawTags = tags });
@@ -130,7 +130,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
// ---- REDEPLOY (in-place ApplyDelta) ----
// ---- IN-PLACE RE-BIND (driver ReinitializeAsync) ----
// New authored set: "keep" script EDITED to src/a*1000; "add" is a NEW tag reading a NEW dep src/b;
// "remove" is dropped entirely.
var newConfig = ConfigJson(
@@ -141,8 +141,9 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
// The rebuilt dependency-ref set reflects the NEW authored tags (src/a from keep, src/b from add).
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
// Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied).
// Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b).
// The adapter re-reads the driver's refs only when told to; re-register AFTER the reinit so it adopts the
// NEW ref set (incl. the brand-new src/b). (The runtime respawns on a config change, #665, so a fresh
// adapter registers the new set in PreStart; this exercises the driver + adapter contract directly.)
adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
@@ -369,56 +369,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
.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 ----------------------------------------------------------------------------
private class StubDriver : IDriver
@@ -439,7 +389,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
}
/// <summary>Reinitializes the driver.</summary>
public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <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>
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>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var handle = new StubHandle();
lock (_liveLock) _liveData.Add(handle);
return Task.FromResult<ISubscriptionHandle>(handle);
}
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_subHandle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
lock (_liveLock) _liveData.Remove(handle);
return Task.CompletedTask;
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
private int _subscribeAlarmsCallCount;
@@ -530,9 +439,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
if (call <= AlarmSubscribeFailuresBeforeSuccess)
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
var handle = new StubAlarmHandle();
lock (_liveLock) _liveAlarms.Add(handle);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
return Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
}
/// <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; }
/// <summary>Cancels an alarm subscription.</summary>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
lock (_liveLock) _liveAlarms.Remove(handle);
return Task.CompletedTask;
}
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Acknowledges a batch of alarms.</summary>
public Task AcknowledgeAsync(
@@ -11,28 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
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>
[Fact]
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
@@ -305,109 +283,6 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
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>
/// 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>.
@@ -19,7 +19,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.Count.ShouldBe(2);
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
@@ -36,7 +35,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
@@ -65,7 +63,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
// 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>
@@ -83,7 +80,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBe(new[] { "b" });
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <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.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that disabled new drivers are not spawned.</summary>
@@ -113,7 +108,6 @@ public sealed class DriverSpawnPlannerTests
var plan = DriverSpawnPlanner.Compute(current, target);
plan.ToSpawn.ShouldBeEmpty();
plan.ToApplyDelta.ShouldBeEmpty();
plan.ToStop.ShouldBeEmpty();
}
@@ -131,7 +125,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
@@ -153,7 +146,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
@@ -173,7 +165,6 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>
@@ -196,6 +187,5 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.Single().ShouldBe("a");
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
plan.ToApplyDelta.ShouldBeEmpty();
}
}