fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.
Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.
Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
from each factory, called from InitializeAsync behind a HasConfigBody guard
so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
than options from config (Sql's dialect + resolved connection string,
FOCAS's client-factory backend, both injected at construction), so adopting
new options alone would run a NEW tag set against an OLD connection. Half a
re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
premise — "config parsing belongs to the factory, which builds a fresh
instance" — which was false when written and is true only now.
Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.
Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.
Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.
Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
This commit is contained in:
+54
-3
@@ -77,7 +77,10 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
|
||||
- `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct.
|
||||
- `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C".
|
||||
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
|
||||
- `SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`. Should be corrected alongside #516.
|
||||
- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh
|
||||
instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed** —
|
||||
the comment now says the premise was false when written and is true *now* because a config change
|
||||
respawns. See §9.
|
||||
|
||||
---
|
||||
|
||||
@@ -350,7 +353,8 @@ appear in §7.1 as well.
|
||||
2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
|
||||
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
|
||||
so it was deleted rather than fixed. See §9.
|
||||
3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.
|
||||
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
|
||||
✅ **Done.** See §9. #489 is still open and should now be re-scoped.
|
||||
4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
|
||||
have caught G-1 and G-2 for free, and this class has now recurred twice.
|
||||
5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
|
||||
@@ -369,7 +373,7 @@ the work, so this register never disagrees with the tree.
|
||||
|---|---|---|
|
||||
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
|
||||
| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
|
||||
| 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — |
|
||||
| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` |
|
||||
| 4. G-4 dispatch-map parity | ⏳ Not started | — |
|
||||
| 5. Bookkeeping sweep | ⏳ Not started | — |
|
||||
| 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — |
|
||||
@@ -461,6 +465,53 @@ Two traps worth keeping:
|
||||
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
|
||||
inference would be wrong. Split to Gitea **#521** with both options costed.
|
||||
|
||||
### 2026-07-27 — §8.3 #516 driver config edits silently discarded
|
||||
|
||||
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
|
||||
"per-driver" half in two, which the register did not anticipate:
|
||||
|
||||
- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each
|
||||
factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the
|
||||
constructor options.
|
||||
- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options**
|
||||
from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both
|
||||
injected at construction. Adopting new options alone would run a **new tag set against an old
|
||||
connection**. Half a re-parse is worse than none. Their doc-comments now say so.
|
||||
|
||||
**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed
|
||||
`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority.
|
||||
`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate
|
||||
decision documented at `DriverSpawnPlan.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; the
|
||||
accepted price is a reconnect on every config edit.
|
||||
|
||||
Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the
|
||||
child had dequeued the message**, so the host immediately believed the new config was live and the
|
||||
next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no
|
||||
`Receive<ApplyResult>` registered, so a failed reinit (including Galaxy's deliberate
|
||||
`NotSupportedException`) dead-lettered.
|
||||
|
||||
**A new visibility gap this change exposed, not created:** a factory throw is a *config* error
|
||||
(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently
|
||||
substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can.
|
||||
Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the
|
||||
deployment — making it do so would let one malformed driver block a fleet deploy, so that is a
|
||||
deliberate follow-up rather than a drive-by change.
|
||||
|
||||
**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a
|
||||
guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by
|
||||
construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by
|
||||
deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were
|
||||
rewritten: `DriverSpawnPlannerTests` (two cases), and
|
||||
`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription` —
|
||||
a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync`
|
||||
to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its
|
||||
desired set. Same event, different route; the control still calibrates the settle window.
|
||||
|
||||
Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3,
|
||||
verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker
|
||||
fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite).
|
||||
|
||||
---
|
||||
|
||||
*Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
|
||||
|
||||
Reference in New Issue
Block a user