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:
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
@@ -14,6 +16,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly ILogger<AbLegacyDriver> _logger;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -29,12 +32,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
|
||||
IAbLegacyTagFactory? tagFactory = null)
|
||||
IAbLegacyTagFactory? tagFactory = null,
|
||||
ILogger<AbLegacyDriver>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
@@ -92,6 +97,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
// Driver.AbLegacy-005 — structured log of the init failure so a field operator sees
|
||||
// the exception in the rolling Serilog file rather than only as a transient Detail
|
||||
// string on DriverHealth.
|
||||
_logger.LogError(ex,
|
||||
"AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
|
||||
// Tear down any probe loops and cached state that were created before the failure so
|
||||
// that a caller who catches and abandons (rather than retrying via ReinitializeAsync)
|
||||
// doesn't leave orphaned background tasks, CancellationTokenSources, and libplctag
|
||||
@@ -192,8 +202,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
// Driver.AbLegacy-005 — log the FIRST non-zero libplctag status per device so
|
||||
// a field operator can correlate a comms problem with a structured log
|
||||
// entry. Detail on DriverHealth is overwritten by the very next read; the
|
||||
// log entry persists. Subsequent occurrences on the same device stay quiet so
|
||||
// a permanently-bad PLC doesn't flood the rolling file.
|
||||
if (!device.FirstNonZeroStatusLogged)
|
||||
{
|
||||
device.FirstNonZeroStatusLogged = true;
|
||||
_logger.LogWarning(
|
||||
"AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
|
||||
_driverInstanceId, def.DeviceHostAddress, reference, status);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Healthy read — re-arm the per-device first-failure log so a future non-zero
|
||||
// status logs again rather than being suppressed by an old flag from a prior outage.
|
||||
device.FirstNonZeroStatusLogged = false;
|
||||
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
@@ -313,6 +338,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
// Driver.AbLegacy-013 (tracked follow-up) — PCCC files are inherently arrays of
|
||||
// elements (a single N7 file is up to 256 words), but the current tag-definition
|
||||
// surface only addresses one element. IsArray/ArrayDim are hard-wired false/null
|
||||
// until multi-element addressing lands; tags that genuinely span a range have to
|
||||
// be enumerated one element at a time today. This is consistent with the
|
||||
// PR-staged scope documented in docs/v2/driver-specs.md (AbLegacy ships with thin
|
||||
// array coverage); when array support is added, ArrayCount on the tag definition
|
||||
// will flow through here as it already does on the Modbus driver.
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
@@ -397,12 +430,38 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Driver.AbLegacy-005 — structured log of every probe-driven transition. Operators can
|
||||
// grep the rolling Serilog file for the device address to see when a PLC was last
|
||||
// reachable. Downgrades to Stopped log as Warning; recoveries log as Information.
|
||||
if (newState == HostState.Stopped)
|
||||
_logger.LogWarning(
|
||||
"AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}",
|
||||
_driverInstanceId, state.Options.HostAddress, old, newState);
|
||||
else
|
||||
_logger.LogInformation(
|
||||
"AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}",
|
||||
_driverInstanceId, state.Options.HostAddress, old, newState);
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Map a full reference to the host string used as the resilience-pipeline breaker key.
|
||||
/// Driver.AbLegacy-013 — the contract on <see cref="IPerCallHostResolver"/> requires that
|
||||
/// implementations never throw on an unknown reference. The fallback chain is therefore:
|
||||
/// <list type="number">
|
||||
/// <item>Known tag → its <c>DeviceHostAddress</c>.</item>
|
||||
/// <item>Unknown reference but devices configured → the first device's host address
|
||||
/// (multi-device drivers degrade to single-host behaviour rather than failing).</item>
|
||||
/// <item>Unknown reference and no devices configured → the driver instance id, which
|
||||
/// the dispatch layer treats as the single-host key per the interface
|
||||
/// documentation. Reaching this branch indicates a misconfigured driver (no
|
||||
/// devices) so callers that want to surface that should validate
|
||||
/// <see cref="DeviceCount"/> before relying on per-tag routing.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
@@ -549,7 +608,36 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-011 — synchronous teardown. Mirrors the body of
|
||||
/// <see cref="ShutdownAsync"/> but never wraps the async path in
|
||||
/// <c>.AsTask().GetAwaiter().GetResult()</c>. The poll engine's <c>DisposeAsync</c> is
|
||||
/// drained with a <c>ConfigureAwait(false)</c> awaiter so a captured single-threaded
|
||||
/// <see cref="SynchronizationContext"/> can never be the resumption target — the
|
||||
/// classic sync-over-async deadlock cannot occur. Any other awaitable cleanup is
|
||||
/// translated to direct synchronous calls (cancel probe CTSs, dispose runtimes).
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine
|
||||
// shutdown on the current thread without capturing the SynchronizationContext. The
|
||||
// engine cancels every loop CTS up-front then either completes immediately (no
|
||||
// subscriptions, common case for the test fixture) or awaits a bounded WhenAll on the
|
||||
// already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown
|
||||
// can't poison the rest of the disposal chain.
|
||||
try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeRuntimes();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
internal sealed class DeviceState(
|
||||
@@ -626,6 +714,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Driver.AbLegacy-005 — per-device latch for the structured "first non-zero
|
||||
/// libplctag status" log. Reset to <see langword="false"/> on a successful read so a
|
||||
/// future outage re-fires the warning rather than being suppressed by a stale flag.
|
||||
/// Concurrent readers on the same device may race the unlatched check + set, but the
|
||||
/// worst case is a small finite number of duplicate warnings at outage onset (one per
|
||||
/// racing reader) — which is preferable to either silently losing the first warning
|
||||
/// or paying lock contention on the hot read path.
|
||||
/// </summary>
|
||||
public bool FirstNonZeroStatusLogged { get; set; }
|
||||
|
||||
public void DisposeRuntimes()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
@@ -15,13 +16,23 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "AbLegacy";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
/// <summary>
|
||||
/// Register the AbLegacy factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to
|
||||
/// construct an <see cref="ILogger{AbLegacyDriver}"/> per driver instance — without it,
|
||||
/// the driver runs with the null logger (existing tests and standalone callers stay
|
||||
/// unchanged). Mirrors the Modbus driver registration pattern.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -63,7 +74,10 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(options, driverInstanceId);
|
||||
return new AbLegacyDriver(
|
||||
options, driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
|
||||
Reference in New Issue
Block a user