fix(driver-ablegacy): resolve Low code-review findings (Driver.AbLegacy-005,011,013)
- Driver.AbLegacy-005: optional ILogger<AbLegacyDriver> ctor parameter, logged init failure / probe transitions / first non-zero libplctag status per device. - Driver.AbLegacy-011: Dispose() runs the synchronous teardown directly instead of bridging via DisposeAsync().AsTask().GetAwaiter().GetResult() to remove the documented sync-over-async deadlock pattern. - Driver.AbLegacy-013: documented the ResolveHost three-tier fallback chain in XML and pointed DiscoverAsync's IsArray=false comment at the Modbus ArrayCount pattern for the eventual multi-element follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-011 — synchronous <see cref="IDisposable.Dispose"/> must perform real
|
||||
/// synchronous teardown rather than blocking via <c>DisposeAsync().AsTask().GetAwaiter().GetResult()</c>.
|
||||
/// Driver.AbLegacy-013 — <see cref="AbLegacyDriver.ResolveHost"/> fallback when the reference
|
||||
/// is unknown and no devices are configured is the documented single-host fallback per the
|
||||
/// <c>IPerCallHostResolver</c> contract.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDisposeAndResolveHostTests
|
||||
{
|
||||
// ---- Driver.AbLegacy-011 ----
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_runs_teardown_without_blocking_on_async_wait()
|
||||
{
|
||||
// Build a driver with a real device + tag so InitializeAsync registers state, then Dispose.
|
||||
// The teardown must clear the device dictionary just like ShutdownAsync would, but without
|
||||
// round-tripping through AsTask().GetAwaiter().GetResult() (which would deadlock under a
|
||||
// single-threaded synchronization context).
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-dispose", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Materialise one runtime so DisposeRuntimes has work to do.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
|
||||
|
||||
drv.Dispose();
|
||||
|
||||
// The cached libplctag tag must be disposed and the device map cleared.
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_is_idempotent()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.Dispose();
|
||||
Should.NotThrow(() => drv.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_under_single_threaded_sync_context_does_not_deadlock()
|
||||
{
|
||||
// The legacy sync-over-async pattern (DisposeAsync().AsTask().GetAwaiter().GetResult())
|
||||
// can deadlock if any awaited continuation marshals back to a captured single-threaded
|
||||
// context. Drive a single-threaded SynchronizationContext + Dispose() and ensure it
|
||||
// returns within a short timeout.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
using var ctx = new SingleThreadSynchronizationContext();
|
||||
var prior = SynchronizationContext.Current;
|
||||
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||
try
|
||||
{
|
||||
var disposed = new ManualResetEventSlim(false);
|
||||
ctx.Post(_ => { drv.Dispose(); disposed.Set(); }, null);
|
||||
ctx.RunUntil(disposed);
|
||||
disposed.IsSet.ShouldBeTrue("Dispose must return without blocking on the single-threaded context");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(prior);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal cooperative single-threaded SynchronizationContext for the deadlock-regression
|
||||
/// test. The thread that calls <see cref="RunUntil"/> pumps queued callbacks until the
|
||||
/// stop event is set.
|
||||
/// </summary>
|
||||
private sealed class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable
|
||||
{
|
||||
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
|
||||
public override void Send(SendOrPostCallback d, object? state) => d(state);
|
||||
|
||||
public void RunUntil(ManualResetEventSlim stop)
|
||||
{
|
||||
while (!stop.IsSet)
|
||||
{
|
||||
if (_queue.TryTake(out var item, TimeSpan.FromSeconds(2)))
|
||||
item.Item1(item.Item2);
|
||||
else
|
||||
throw new TimeoutException("Dispose did not complete — likely sync-over-async deadlock");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _queue.Dispose();
|
||||
}
|
||||
|
||||
// ---- Driver.AbLegacy-013 ----
|
||||
|
||||
[Fact]
|
||||
public void ResolveHost_known_reference_returns_tag_device()
|
||||
{
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
}, "drv-1");
|
||||
drv.ResolveHost("X").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHost_unknown_reference_with_devices_returns_first_device()
|
||||
{
|
||||
// Multi-device fallback: an unknown reference returns the first configured device so the
|
||||
// resilience pipeline keys on a real ab:// host rather than the instance id.
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbLegacyDeviceOptions("ab://10.0.0.6/"),
|
||||
],
|
||||
}, "drv-1");
|
||||
drv.ResolveHost("unknown").ShouldBe("ab://10.0.0.5/1,0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHost_unknown_reference_no_devices_returns_driver_instance_id()
|
||||
{
|
||||
// Per IPerCallHostResolver: implementations MUST NOT throw on an unknown reference; they
|
||||
// must return the driver's default-host string. With no devices configured the driver
|
||||
// instance id is the documented single-host fallback.
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-singleton");
|
||||
drv.ResolveHost("anything").ShouldBe("drv-singleton");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-005 — verifies the driver accepts and uses an optional
|
||||
/// <see cref="ILogger{AbLegacyDriver}"/>. Probe transitions, init failures, and the first
|
||||
/// non-zero libplctag status per device must be logged (rather than only folded into the
|
||||
/// transient <see cref="DriverHealth.Detail"/> string) so a field operator can correlate a
|
||||
/// PCCC comms problem with a structured log entry.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyLoggerInjectionTests
|
||||
{
|
||||
private sealed class CapturingLogger : ILogger<AbLegacyDriver>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_accepts_optional_logger_parameter()
|
||||
{
|
||||
// Constructor must accept an ILogger<AbLegacyDriver> as an optional named arg, matching
|
||||
// the Modbus/S7/Galaxy driver pattern. The driver runs with NullLogger when omitted.
|
||||
var logger = new CapturingLogger();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-logged", tagFactory: null, logger: logger);
|
||||
drv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_failure_emits_error_log()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
|
||||
}, "drv-logged", tagFactory: null, logger: logger);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
|
||||
var errors = logger.Entries.Where(e => e.Level >= LogLevel.Error).ToList();
|
||||
errors.ShouldNotBeEmpty("init failure must surface as an Error-level log");
|
||||
errors[0].Message.ShouldContain("drv-logged");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_nonzero_libplctag_status_per_device_is_logged()
|
||||
{
|
||||
// The driver should log the first occurrence of a non-zero libplctag status per device,
|
||||
// so that a field operator can correlate a comms problem with a structured log entry
|
||||
// even though Detail is overwritten by the next read.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var logger = new CapturingLogger();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-logged", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -32 }; // timeout
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
warnings.ShouldNotBeEmpty("first non-zero libplctag status must emit a structured warning");
|
||||
warnings.Any(w => w.Message.Contains("ab://10.0.0.5/1,0") || w.Message.Contains("drv-logged"))
|
||||
.ShouldBeTrue("warning must identify the device or driver instance");
|
||||
|
||||
// Second read with the same status — the per-device de-dupe should suppress.
|
||||
var warningCountAfterFirst = warnings.Count;
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var newWarnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
newWarnings.Count.ShouldBe(warningCountAfterFirst, "subsequent same-device non-zero status stays quiet");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user