test(otopcua): cover discovered-node rebind drop + clarify re-apply scope

This commit is contained in:
Joseph Doherty
2026-06-26 09:01:01 -04:00
parent 1aa13ebd27
commit 5104540e32
2 changed files with 60 additions and 5 deletions
@@ -1245,11 +1245,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_lastComposition = composition;
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, every redeploy /
// bootstrap-restore would drop the injected FixedTree routes + materialised nodes until the driver
// happens to reconnect and re-discover. Re-resolve each cached driver's equipment from the CURRENT
// composition; drop the cache entry if the driver/equipment no longer resolves to exactly one (a rebind
// or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's
// post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's equipment from the
// CURRENT composition; drop the cache entry if the driver/equipment no longer resolves to exactly one
// (a rebind or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
{
var plan = _discoveredByDriver[driverId];
@@ -1267,6 +1270,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var equipmentId = equipmentIds[0];
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
// KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a
// CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's
// EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery —
// ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is
// never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment
// until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger
// logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the
// safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse).
var planEquipmentConsistent = plan.Variables.Count > 0
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
if (!planEquipmentConsistent)