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:
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <c>AbLegacyDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
|
||||
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
|
||||
/// was silently discarded while the deployment still sealed green.
|
||||
/// <para>Every pre-existing reinit test in this suite passes <c>"{}"</c> — the exact input the guarded
|
||||
/// re-parser treats as "keep the constructor options" — so they were blind to the defect.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver validates every device's <c>HostAddress</c> during init. Reinitializing with a config
|
||||
/// whose device address is structurally invalid must therefore THROW — a driver still serving the
|
||||
/// constructor's valid device list would validate that instead and succeed, which is precisely how
|
||||
/// the discarded edit stayed invisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_device_list()
|
||||
{
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
|
||||
"ab1",
|
||||
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
loggerFactory: null);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.ReinitializeAsync(
|
||||
"""{"devices":[{"hostAddress":"not-a-valid-ab-address","plcFamily":"Slc500"}]}""",
|
||||
CancellationToken.None),
|
||||
"AbLegacyDriver kept its constructor-supplied device list after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document must still keep the constructor options, so the many
|
||||
/// lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
|
||||
"ab1",
|
||||
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
loggerFactory: null);
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <see cref="ModbusDriver.InitializeAsync"/> used to serve the options its CONSTRUCTOR
|
||||
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
|
||||
/// was silently discarded while the deployment still sealed green.
|
||||
/// <para><b>This test passes a CHANGED config on purpose.</b> Every pre-existing reinit test in this
|
||||
/// suite passes <c>"{}"</c> — exactly the input the guarded re-parser treats as "keep the constructor
|
||||
/// options" — so those tests were blind to the defect by construction.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>The transport factory receives the options the driver actually decided to use, so it is a
|
||||
/// direct read of which config won — no log-string matching.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_changed_host_rebuilds_the_transport_against_the_new_host()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
|
||||
var driver = new ModbusDriver(options, "m1", opts =>
|
||||
{
|
||||
seen.Add($"{opts.Host}:{opts.Port}");
|
||||
return new NeverConnectingTransport();
|
||||
});
|
||||
|
||||
try { await driver.InitializeAsync("""{"host":"10.0.0.1","port":502}""", CancellationToken.None); }
|
||||
catch { /* connect failure is expected and irrelevant */ }
|
||||
|
||||
try { await driver.ReinitializeAsync("""{"host":"10.0.0.99","port":1502}""", CancellationToken.None); }
|
||||
catch { /* ditto */ }
|
||||
|
||||
seen.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
seen[^1].ShouldBe("10.0.0.99:1502",
|
||||
"ModbusDriver kept its constructor-supplied host after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>A placeholder / empty document must still keep the constructor options — the guard exists so
|
||||
/// the many lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
|
||||
var driver = new ModbusDriver(options, "m1", opts =>
|
||||
{
|
||||
seen.Add($"{opts.Host}:{opts.Port}");
|
||||
return new NeverConnectingTransport();
|
||||
});
|
||||
|
||||
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
|
||||
catch { /* connect failure is expected */ }
|
||||
|
||||
seen.ShouldAllBe(s => s == "10.0.0.1:502");
|
||||
}
|
||||
|
||||
private sealed class NeverConnectingTransport : IModbusTransport
|
||||
{
|
||||
public bool IsConnected => false;
|
||||
public Task ConnectAsync(CancellationToken cancellationToken)
|
||||
=> throw new IOException("test transport never connects");
|
||||
public Task DisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken cancellationToken)
|
||||
=> throw new IOException("test transport never connects");
|
||||
public void Dispose() { }
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <c>OpcUaClientDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
|
||||
/// captured. Its own comment claimed "resolving on every InitializeAsync … picks up rotations", which
|
||||
/// was true of SECRET rotation only: the endpoint, the security policy and the tag set were all frozen
|
||||
/// at construction, so an operator's edit was discarded while the deployment still sealed green.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>ValidateNamespaceKind</c> runs at the top of init and rejects <c>TargetNamespaceKind=Equipment</c>
|
||||
/// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver
|
||||
/// still serving the constructor's valid options would validate those and succeed, which is exactly
|
||||
/// how the discarded edit stayed invisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_config()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
|
||||
"opc1",
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.ReinitializeAsync(
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""",
|
||||
CancellationToken.None),
|
||||
"OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document must still keep the constructor options.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
|
||||
"opc1",
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
|
||||
|
||||
// "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as
|
||||
// far as the connect — which fails against an unreachable endpoint. Anything BUT the validation
|
||||
// throw proves the constructor options survived.
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
|
||||
(ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user