From 5c5aaef6090b70f6bcd42df4358a9f21b28bddea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 05:24:41 -0400 Subject: [PATCH] fix(focas): fail-fast at init on unimplemented backend (operator footgun) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add IFocasClientFactory.EnsureUsable() — a config-time probe called by FocasDriver.InitializeAsync before any background loops start. The UnimplementedFocasClientFactory throws NotSupportedException immediately (faulting the driver at init), eliminating the footgun where a driver on the 'unimplemented' backend appeared Healthy then failed every read/write/ subscribe silently. WireFocasClientFactory and FakeFocasClientFactory are no-ops. Backstop Create() throw remains in place. --- .../FocasDriver.cs | 5 +++ .../IFocasClient.cs | 28 ++++++++++++++-- .../Wire/WireFocasClient.cs | 6 ++++ .../FakeFocasClient.cs | 3 ++ .../FocasScaffoldingTests.cs | 32 +++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 22a58824..7273c002 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -89,6 +89,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); try { + // Fail fast if the factory is a stub/unimplemented backend — the operator must + // see an actionable error at init rather than a phantom-Healthy driver that fails + // every read/write/subscribe silently (Driver.FOCAS-009). + _clientFactory.EnsureUsable(); + foreach (var device in _options.Devices) { var addr = FocasHostAddress.TryParse(device.HostAddress) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index dbc46cef..fedca8d4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -282,6 +282,18 @@ public interface IFocasClientFactory /// Creates a new FOCAS client instance. /// A new IFocasClient instance. IFocasClient Create(); + + /// + /// Config-time usability probe called by before + /// any background loops start. Must NOT create a live wire client. Implementations that + /// are permanently non-functional (e.g. ) + /// throw here so the driver faults immediately at + /// init rather than lazily on the first read. + /// + /// + /// Thrown by stub/unimplemented backends to force a fail-fast init fault. + /// + void EnsureUsable(); } /// @@ -292,12 +304,22 @@ public interface IFocasClientFactory /// public sealed class UnimplementedFocasClientFactory : IFocasClientFactory { + private const string Message = + "FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " + + "once the CNC is provisioned — see docs/drivers/FOCAS.md."; + + /// + /// Config-time probe — throws immediately so + /// faults the driver before any background loops start. This prevents the footgun where + /// the driver appears Healthy at init but every read/write/subscribe fails. + /// + /// Always thrown. + public void EnsureUsable() => throw new NotSupportedException(Message); + /// Creates a new client instance (always throws NotSupportedException). /// Never returns; always throws NotSupportedException. /// Always thrown to indicate backend is not yet provisioned. - public IFocasClient Create() => throw new NotSupportedException( - "FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " + - "once the CNC is provisioned — see docs/drivers/FOCAS.md."); + public IFocasClient Create() => throw new NotSupportedException(Message); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index fd6f70de..58eb4682 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -427,6 +427,12 @@ public sealed class WireFocasClientFactory : IFocasClientFactory _logger = logger; } + /// + /// No-op usability probe — the wire backend is always usable at config time. + /// Implements . + /// + public void EnsureUsable() { } + /// Creates a new WireFocasClient instance. /// A new IFocasClient implementation. public IFocasClient Create() => new WireFocasClient(_logger); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index d5d61351..3cd1ed28 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -169,6 +169,9 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory /// Gets or sets a customization function for creating clients. public Func? Customise { get; set; } + /// No-op usability probe — the fake factory is always usable. + public void EnsureUsable() { } + /// Creates a fake FOCAS client. public IFocasClient Create() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs index f1ba244d..4d5e6c30 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs @@ -261,4 +261,36 @@ public sealed class FocasScaffoldingTests ex.Message.ShouldContain("wire"); ex.Message.ShouldContain("docs/drivers/FOCAS.md"); } + + /// + /// Verifies a driver configured with the unimplemented backend faults at + /// InitializeAsync — not lazily on the first read. The operator must get an + /// actionable error immediately rather than a phantom-Healthy driver that + /// fails every read silently. + /// + [Fact] + public async Task Unimplemented_backend_faults_at_InitializeAsync_not_on_first_read() + { + var drv = new FocasDriver( + new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")] }, + "drv-unimpl", + clientFactory: new UnimplementedFocasClientFactory()); + + var ex = await Should.ThrowAsync( + () => drv.InitializeAsync("{}", CancellationToken.None)); + + ex.Message.ShouldContain("unimplemented"); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + /// + /// Verifies WireFocasClientFactory.EnsureUsable is a no-op — the wire backend + /// must still initialize clean (no false-positive faults from the probe). + /// + [Fact] + public void Wire_factory_EnsureUsable_does_not_throw() + { + var factory = new Wire.WireFocasClientFactory(); + Should.NotThrow(() => factory.EnsureUsable()); + } }