fix(focas): fail-fast at init on unimplemented backend (operator footgun)
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.
This commit is contained in:
@@ -89,6 +89,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||||
try
|
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)
|
foreach (var device in _options.Devices)
|
||||||
{
|
{
|
||||||
var addr = FocasHostAddress.TryParse(device.HostAddress)
|
var addr = FocasHostAddress.TryParse(device.HostAddress)
|
||||||
|
|||||||
@@ -282,6 +282,18 @@ public interface IFocasClientFactory
|
|||||||
/// <summary>Creates a new FOCAS client instance.</summary>
|
/// <summary>Creates a new FOCAS client instance.</summary>
|
||||||
/// <returns>A new IFocasClient instance.</returns>
|
/// <returns>A new IFocasClient instance.</returns>
|
||||||
IFocasClient Create();
|
IFocasClient Create();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Config-time usability probe called by <see cref="FocasDriver.InitializeAsync"/> before
|
||||||
|
/// any background loops start. Must NOT create a live wire client. Implementations that
|
||||||
|
/// are permanently non-functional (e.g. <see cref="UnimplementedFocasClientFactory"/>)
|
||||||
|
/// throw <see cref="NotSupportedException"/> here so the driver faults immediately at
|
||||||
|
/// init rather than lazily on the first read.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="NotSupportedException">
|
||||||
|
/// Thrown by stub/unimplemented backends to force a fail-fast init fault.
|
||||||
|
/// </exception>
|
||||||
|
void EnsureUsable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -292,12 +304,22 @@ public interface IFocasClientFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UnimplementedFocasClientFactory : 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.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Config-time probe — throws immediately so <see cref="FocasDriver.InitializeAsync"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="NotSupportedException">Always thrown.</exception>
|
||||||
|
public void EnsureUsable() => throw new NotSupportedException(Message);
|
||||||
|
|
||||||
/// <summary>Creates a new client instance (always throws NotSupportedException).</summary>
|
/// <summary>Creates a new client instance (always throws NotSupportedException).</summary>
|
||||||
/// <returns>Never returns; always throws NotSupportedException.</returns>
|
/// <returns>Never returns; always throws NotSupportedException.</returns>
|
||||||
/// <exception cref="NotSupportedException">Always thrown to indicate backend is not yet provisioned.</exception>
|
/// <exception cref="NotSupportedException">Always thrown to indicate backend is not yet provisioned.</exception>
|
||||||
public IFocasClient Create() => throw new NotSupportedException(
|
public IFocasClient Create() => throw new NotSupportedException(Message);
|
||||||
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
|
|
||||||
"once the CNC is provisioned — see docs/drivers/FOCAS.md.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -427,6 +427,12 @@ public sealed class WireFocasClientFactory : IFocasClientFactory
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op usability probe — the wire backend is always usable at config time.
|
||||||
|
/// Implements <see cref="IFocasClientFactory.EnsureUsable"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureUsable() { }
|
||||||
|
|
||||||
/// <summary>Creates a new WireFocasClient instance.</summary>
|
/// <summary>Creates a new WireFocasClient instance.</summary>
|
||||||
/// <returns>A new IFocasClient implementation.</returns>
|
/// <returns>A new IFocasClient implementation.</returns>
|
||||||
public IFocasClient Create() => new WireFocasClient(_logger);
|
public IFocasClient Create() => new WireFocasClient(_logger);
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
|||||||
/// <summary>Gets or sets a customization function for creating clients.</summary>
|
/// <summary>Gets or sets a customization function for creating clients.</summary>
|
||||||
public Func<FakeFocasClient>? Customise { get; set; }
|
public Func<FakeFocasClient>? Customise { get; set; }
|
||||||
|
|
||||||
|
/// <summary>No-op usability probe — the fake factory is always usable.</summary>
|
||||||
|
public void EnsureUsable() { }
|
||||||
|
|
||||||
/// <summary>Creates a fake FOCAS client.</summary>
|
/// <summary>Creates a fake FOCAS client.</summary>
|
||||||
public IFocasClient Create()
|
public IFocasClient Create()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -261,4 +261,36 @@ public sealed class FocasScaffoldingTests
|
|||||||
ex.Message.ShouldContain("wire");
|
ex.Message.ShouldContain("wire");
|
||||||
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<NotSupportedException>(
|
||||||
|
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("unimplemented");
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies WireFocasClientFactory.EnsureUsable is a no-op — the wire backend
|
||||||
|
/// must still initialize clean (no false-positive faults from the probe).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Wire_factory_EnsureUsable_does_not_throw()
|
||||||
|
{
|
||||||
|
var factory = new Wire.WireFocasClientFactory();
|
||||||
|
Should.NotThrow(() => factory.EnsureUsable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user