Driver config edits are silently discarded on Modbus/FOCAS/OpcUaClient (deployment still seals green) #516
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while building the MTConnect driver (PR #506) and verified independently end-to-end against current
master. Deliberately not fixed there — out of scope for that plan.Symptom
An operator edits a Modbus / FOCAS / OpcUaClient driver's config and deploys. The driver keeps running the old config.
ApplyResult(true)is returned and the deployment seals green.Silent no-op — the worst shape, because it looks successful.
Cause
DriverInstanceActorassigns_driveronce (DriverInstanceActor.cs:307) and never rebuilds it from the factory. Both config-change paths therefore hand the new JSON to that already-constructed instance:HandleApplyDeltaAsync(L592) →_driver.ReinitializeAsync(msg.DriverConfigJson, …)AdoptConfigDuringInit→ the actor'sInitializeAsync(L554) →_driver.InitializeAsync(driverConfigJson, …)So
driverConfigJsonis the only channel by which a config change can reach a running driver.But these three never read that parameter — they use the ctor-injected
_optionsexclusively:ModbusDriver.InitializeAsync(L195)FocasDriver.InitializeAsync(L104)OpcUaClientDriver.InitializeAsync(L160)Their
ReinitializeAsyncdelegates toInitializeAsync, so the new config is dropped on the floor.Prior art in-tree
S7 does it correctly and documents why — see the comment at
S7Driver.cs:283. MTConnect follows S7, and this was confirmed live on the PR #506 gate: a corrected tag config took effect on redeploy with no container restart.Still to check
The per-driver survey was inconclusive for AbCip, TwinCAT and Galaxy — verify each one's
InitializeAsyncactually re-parses before closing this.Suggested fix
Make
InitializeAsyncre-parsedriverConfigJsonfollowing S7's pattern (an empty/blank body keeps the ctor options). Add a per-driver regression test: reinitialise with a changed config and assert the change is observable, not merely that the call returned true.RESOLVED — fixed for all 12 drivers. Merged to
masterin914d70bf(seam respawn + per-driver re-parse,d32d89c3; Sql/FOCAS atomic re-parse,5184a2e1).The survey in this issue was incomplete
It named Modbus, FOCAS and OpcUaClient. The defect also affected AbLegacy (missed here) and Sql (where discarding was deliberate and documented — on a premise that turned out to be false). AbCip and TwinCAT already re-parsed correctly; Galaxy fails loud (throws
NotSupportedException) rather than silently.Two halves, because reading the drivers split them
ParseOptionsextracted from each factory and called fromInitializeAsyncbehind aHasConfigBodyguard, so"{}"still means "keep the constructor options".ISqlDialect+ resolved connection string; FOCAS's client-factory backend, injected at construction). Adopting new options alone would run a new tag set against an old connection — a half-applied change is worse than a discarded one, because it looks like it worked. Each factory now exposes aParseBindingreturning every config-derived dependency as one value, adopted together.The load-bearing half was the seam, not the drivers
DriverSpawnPlannernow routes a changedDriverConfigtoToStop+ToSpawn, making the factory the single parse authority. This reverses the deliberate decision documented atDriverSpawnPlan.cs:49-50("a pure DriverConfig change stays an in-place delta — no reconnect"). That reasoning was right about resilience and wrong about the driver.⚠️ Accepted, operationally visible price: a config edit now costs a driver reconnect.
Two green seals were removed from
ApplyChildDeltaen route: it overwrote the cachedSpecsynchronously, before the child had dequeued the message, so the next reconcile computed no delta and sealed the drift permanently; and itTelld with noReceive<ApplyResult>registered, so a failed reinit (including Galaxy's deliberate throw) dead-lettered.Why this survived review for so long
Every pre-existing reinit test in the five suites passed
"{}"— precisely the input a guarded re-parser treats as "keep the constructor options". They were blind to this defect by construction. The new tests pass changed JSON, and the Modbus one was verified falsifiable by deleting the re-parse line and watching it go red. The Sql/FOCAS tests pin atomicity, not merely "a re-parse happened" — verified by simulating the half-fix (adopt options, skip the backend): 2 of the 3 FOCAS tests go red.Side benefit worth knowing
A rotated credential is now picked up without a process restart, because the driver re-resolves
connectionStringRefwhen it re-parses.