fix(runtime): a changed alarm set re-subscribes instead of latching (review F1)

The alarm subscribe gated on 'a handle exists', which is correct for an idempotent
re-push and wrong for a deploy that widens/narrows the set. Combined with the
spawn-time desired set, a respawning deploy could latch the PREVIOUS revision's
alarm refs for the life of the connection; sourceNodeIds-filtering drivers
(OpcUaClient/AbCip/Focas) would never deliver the new alarm.

Seen RED: LastSubscribedAlarmRefs stayed [alarmX] after a deploy widening to
[alarmX, alarmY].
This commit is contained in:
Joseph Doherty
2026-08-20 10:24:52 -04:00
parent c1ad9e24d2
commit edd01fcc34
3 changed files with 135 additions and 6 deletions
+13
View File
@@ -211,6 +211,19 @@ Things to know before touching any of this:
(`HandleSubscribeAsync` drops the prior subscription before establishing the new one); a real device
driver, whose initialise opens a socket, rarely wins that race. Withholding the spawn-time send to avoid
it would simply restore 03/S16, since the Restart and supervision routes have no bulk push coming.
- ⚠️ **Convergence is not free on the ALARM side, and "a subscription already exists" is the wrong guard
there.** The value path converges by construction — `StoreDesiredSubscriptions` is a full replace and
`HandleSubscribeAsync` drops the prior subscription before establishing the new one — but
`SubscribeDesiredAlarms` / `HandleSubscribeAlarmsAsync` used to gate purely on
`_alarmSubscriptionHandle is not null`, so once a handle existed the alarm set **latched for the life of
the connection**. Combined with the transient above, a deploy widening a driver's alarm refs `{X}` →
`{X, Y}` could leave a fast-initialising driver subscribed to `{X}` forever, and the drivers that filter
their feed on the subscribed `sourceNodeIds` (OPC UA Client, AB CIP, FOCAS) would simply never deliver
the new alarm — silently. The guard is now "the desired refs differ from the ones the live handle was
established with" (`_subscribedAlarmRefs`), and a changed set **releases the old subscription before
re-subscribing** so a filtering driver does not accumulate two overlapping ones. There is still
deliberately no unsubscribe-on-**empty**: a removed alarm tag's condition node is torn down on the
address-space rebuild and `ForwardNativeAlarm` drops any transition whose `ConditionId` no longer maps.
- **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