Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
T
Joseph Doherty 1ea1da8236 refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:23:16 -04:00

1350 lines
68 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
/// <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
/// host-resolver capabilities ship in PRs 2 and 3.
/// </summary>
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
private 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);
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver<AbLegacyTagDefinition> _resolver;
// volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync /
// InitializeAsync write it from worker / poll threads. The record-reference assignment is
// atomic on all .NET platforms, but without a memory barrier a reader can see a stale
// snapshot indefinitely. volatile enforces acquire/release ordering so GetHealth() always
// observes the most recently written value.
private volatile DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
/// Occurs when data values change.
/// </summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs when host status changes.
/// </summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>
/// Initializes a new instance of the <see cref="AbLegacyDriver"/> class.
/// </summary>
/// <param name="options">The driver options.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="tagFactory">The tag factory, or <c>null</c> to use the default.</param>
/// <param name="logger">The logger, or <c>null</c> to use the null logger.</param>
public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
IAbLegacyTagFactory? tagFactory = null,
ILogger<AbLegacyDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
_transport = AbLegacyTransportResolver.Resolve(options);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.AbLegacy),
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbLegacyTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).
/// Describes the OPTION only: an injected <c>tagFactory</c> (tests) is used regardless.</summary>
internal AbLegacyTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbLegacyTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
private readonly string _transportDescription;
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
private bool _limitedFamilyWarned;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
var current = _health;
if (current.State != DriverState.Faulted)
_health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "AbLegacy";
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green.
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
// the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
// A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
_tagsByRawPath[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
};
}
_skippedTags.Commit();
// Validate tag types against their device's family profile. Long (32-bit integer)
// and String (ST-file) are not supported by all PCCC families; reject them early
// so a misconfigured tag fails at init time with a clear message rather than
// surfacing an opaque comms error at runtime.
foreach (var tag in _tagsByRawPath.Values)
{
if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
var profile = deviceForTag.Profile;
if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile)
throw new InvalidOperationException(
$"Tag '{tag.Name}' is typed as Long but device '{tag.DeviceHostAddress}' " +
$"(family {deviceForTag.Options.PlcFamily}) does not support L-files.");
if (tag.DataType == AbLegacyDataType.String && !profile.SupportsStringFile)
throw new InvalidOperationException(
$"Tag '{tag.Name}' is typed as String but device '{tag.DeviceHostAddress}' " +
$"(family {deviceForTag.Options.PlcFamily}) does not support ST-files.");
}
// Probe loops — one per device when enabled + probe address configured.
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
{
foreach (var state in _devices.Values)
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
_logger.LogError(ex,
"AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
// Release cached state created before the failure so a caller who catches and abandons
// (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and
// libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of
// the try and nothing after that can throw, so a failure never has a running loop to join.
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
throw;
}
}
/// <summary>
/// Cancels, bounded-joins and disposes every existing device state, then clears the table.
/// Device-state part of <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_devices.Count == 0) return;
foreach (var state in _devices.Values)
try { state.ProbeCts?.Cancel(); } catch { }
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
"AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
loops.Count, LoopJoinTimeout, _driverInstanceId);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Upper bound on how long shutdown waits for the probe loops to observe cancellation.</summary>
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
/// The loop body uses <c>ConfigureAwait(false)</c> throughout, so blocking here cannot deadlock.
/// </summary>
private void JoinProbeLoopsSync()
{
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToArray();
if (loops.Length == 0) return;
try { Task.WaitAll(loops, LoopJoinTimeout); }
catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
// STAB-6: cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
// outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
foreach (var state in _devices.Values)
try { state.ProbeCts?.Cancel(); } catch { }
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
"AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}",
loops.Count, LoopJoinTimeout, _driverInstanceId);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <summary>
/// Gets the device count.
/// </summary>
internal int DeviceCount => _devices.Count;
/// <summary>
/// Gets the device state for the specified host address.
/// </summary>
/// <param name="hostAddress">The host address.</param>
/// <returns>The device state, or <c>null</c> if not found.</returns>
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
// ---- IReadable ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
if (!_resolver.TryResolve(reference, out var def))
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
try
{
var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
if (preParsed is null)
{
WarnMalformedOnce(device, def);
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
if (preParsed.IsIndirect)
{
// Indirect (#646): read the index word(s) NOW (never cached - the index can change between
// polls), compose the concrete address, then run the normal path against it.
var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
if (res.Def is null)
{
results[i] = new DataValueSnapshot(null, res.Status, null, now);
if (res.TransportFault)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"transport fault resolving indirect address for {reference}");
continue;
}
def = res.Def;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
int status;
object? value;
// Serialise the Read → GetStatus → DecodeValue sequence against the shared
// runtime — the server read path and the poll loop both call ReadAsync against
// the same cached libplctag Tag handle, which is not concurrency-safe.
var opLock = device.GetRuntimeLock(def.Name);
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
if (status != 0)
value = null;
else if (IsArrayTag(def))
value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
else
value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
}
finally
{
opLock.Release();
}
if (status != 0)
{
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.MapStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"transport status {status} reading {reference}");
if (!device.FirstNonZeroStatusLogged)
{
device.FirstNonZeroStatusLogged = true;
_logger.LogWarning(
"AbLegacy non-zero transport 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);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_resolver.TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
continue;
}
if (!def.Writable)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
continue;
}
try
{
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// A malformed address is a config error, not a comms fault (BadNodeIdUnknown, warned once per tag).
if (parsed is null)
{
WarnMalformedOnce(device, def);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
continue;
}
// Indirect (#646): resolve the index first (non-atomic with the write - the index may change
// between the two PLC accesses), then write the concrete address through the normal path.
if (parsed.IsIndirect)
{
var res = await ResolveIndirectAsync(device, def, parsed, cancellationToken).ConfigureAwait(false);
if (res.Def is null)
{
results[i] = new WriteResult(res.Status);
if (res.TransportFault)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"transport fault resolving indirect address for {w.FullReference}");
continue;
}
def = res.Def;
parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)!;
}
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
// or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal (#593 follow-up).
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
}
// PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N
// bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every
// bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files
// (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements
// (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
// to the PLC like any other write — the device drives the input image from physical inputs and
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
{
results[i] = new WriteResult(
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
continue;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
int status;
// Serialise Encode → Write → GetStatus against the shared runtime — the same
// cached Tag handle may be in use by a concurrent ReadAsync or poll loop.
var opLock = device.GetRuntimeLock(def.Name);
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
finally
{
opLock.Release();
}
if (status != 0)
{
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
}
else
{
results[i] = new WriteResult(AbLegacyStatusMapper.Good);
}
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
}
catch (OverflowException)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
}
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- ITagDiscovery ----
/// <inheritdoc />
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var root = builder.Folder("AbLegacy", "AbLegacy");
foreach (var device in _options.Devices)
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
var isArray = tag.ArrayLength is int len && len >= 1;
var arrayDim = isArray
? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements)
: (uint?)null;
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: isArray,
ArrayDim: arrayDim,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent));
}
}
return Task.CompletedTask;
}
// ---- ISubscribable (polling overlay via shared engine) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
// Async teardown: the engine drains the in-flight poll without blocking the caller's
// thread — this runs inside a DriverInstanceActor mailbox turn.
await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
}
// ---- IHostConnectivityProbe ----
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
var probeParams = new AbLegacyTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.EffectiveCipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
// so the transport reports the failure as it always did.
TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily)
?? _options.Probe.ProbeAddress!,
Timeout: _options.Probe.Timeout);
IAbLegacyTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
probeRuntime ??= _tagFactory.Create(probeParams);
if (!state.ProbeInitialized)
{
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
state.ProbeInitialized = true;
}
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
success = probeRuntime.GetStatus() == 0;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch
{
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
try { probeRuntime?.Dispose(); } catch { }
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
lock (state.ProbeLock)
{
old = state.HostState;
if (old == newState) return;
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
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));
}
/// <summary>
/// Resolve the <see cref="RawTagEntry.DeviceName"/> a raw tag carries to the host-address key of
/// the device it should route to. The AB Legacy driver is multi-device and keys its
/// <c>_devices</c> table by <c>HostAddress</c>, so this maps the tag's owning device name to that
/// key: it matches the name against each configured <see cref="AbLegacyDeviceOptions"/> by its
/// <see cref="AbLegacyDeviceOptions.DeviceName"/> first, then by <c>HostAddress</c> as a fallback,
/// and returns the matched device's <c>HostAddress</c>. When the name matches nothing but the
/// driver has exactly one device, that sole device is used (single-device deployments never need
/// to name their tag's device). Otherwise returns empty — the tag is unroutable and its
/// read/write surfaces <c>BadNodeIdUnknown</c>.
/// </summary>
/// <remarks>
/// <see cref="AbLegacyDriverOptions.Devices"/> is already the per-device <c>DeviceConfig</c>
/// (<c>DriverDeviceConfigMerger</c> rebuilds it at deploy time), so routing matches the delivered
/// <see cref="RawTagEntry.DeviceName"/> against <c>_options.Devices</c> and reuses that entry's
/// <c>HostAddress</c> as the connection key. The HostAddress fallback is deliberate: the Client CLI
/// passes the host as the DeviceName.
/// </remarks>
/// <param name="deviceName">The owning device name delivered on the <see cref="RawTagEntry"/>.</param>
/// <returns>The matched device's <c>HostAddress</c>; empty when unroutable.</returns>
private string ResolveDeviceHost(string deviceName)
{
if (!string.IsNullOrEmpty(deviceName))
{
foreach (var d in _options.Devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
foreach (var d in _options.Devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
}
// Best-available fallback: a single-device driver routes every tag to its sole device.
return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : "";
}
// ---- IPerCallHostResolver ----
/// <summary>
/// Resolve a reference to the device host that keys its per-host resilience
/// (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
/// JSON) resolves to its OWN authored device rather than the first-device fallback. The
/// resolver caches parses, so this adds no per-call cost after first use.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <returns>The device host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
/// <summary>
/// Read-modify-write one bit within a PCCC word. Strips the /N bit suffix to form the
/// parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime typed at
/// the parent's native width (Int16 for N/I/O/S/A files, Int32 for the 32-bit L file),
/// and serialises concurrent bit writers against the same parent via a per-parent
/// <see cref="SemaphoreSlim"/>. The parent word is masked to its native width before the
/// bit operation so a sign-extended decode never corrupts the high bits.
/// </summary>
private async Task<uint> WriteBitInWordAsync(
AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
{
// The parent word width follows the file letter: an L-file element is 32-bit, every
// other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
// range-checked by AbLegacyAddress.TryParse (0..15 for 16-bit files, 0..31 for L), so
// 1 << bit can never overflow the chosen width here.
var isLong = bitAddress.FileLetter == "L";
var parentType = isLong ? AbLegacyDataType.Long : AbLegacyDataType.Int;
var widthMask = isLong ? unchecked((int)0xFFFFFFFF) : 0xFFFF;
var parentAddress = bitAddress with { BitIndex = null };
var parentName = parentAddress.ToLibplctagName();
var rmwLock = device.GetRmwLock(parentName);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
// RMW arithmetic to exactly the parent's bits.
var current = Convert.ToInt32(parentRuntime.DecodeValue(parentType, bitIndex: null) ?? 0) & widthMask;
var updated = Convert.ToBoolean(value)
? current | (1 << bit)
: current & ~(1 << bit);
updated &= widthMask;
if (isLong)
parentRuntime.EncodeValue(AbLegacyDataType.Long, bitIndex: null, updated);
else
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbLegacyStatusMapper.Good
: AbLegacyStatusMapper.MapStatus(writeStatus);
}
finally
{
rmwLock.Release();
}
}
/// <summary>Largest word/element index an indirect source may yield (PCCC element offset is one byte).</summary>
private const int MaxIndirectElement = AbLegacyArray.MaxElements - 1;
/// <summary>PLC-5 data files hold up to 1000 words (extended PCCC offset), unlike SLC/MicroLogix (256).</summary>
private const int MaxIndirectElementPlc5 = 999;
/// <summary>Largest data-file number an indirect source may yield (PLC-5 allows up to 999).</summary>
private const int MaxIndirectFile = 999;
/// <summary>
/// Resolves an indirect address (#646). Reads each index source through the same runtime/transport path
/// (same factory, so libplctag and Native alike), range-checks it, and composes a concrete tag definition
/// whose <c>Name</c> is keyed by the resolved address so the normal runtime cache applies. Nothing about the
/// index is cached: every call re-reads it. A bad index is <c>BadOutOfRange</c> (per-tag, no health impact);
/// an inner-read failure propagates its mapped status (<c>TransportFault</c> true for a wire failure).
/// </summary>
private async Task<IndirectResolution> ResolveIndirectAsync(
DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed, CancellationToken ct)
{
var fileNumber = parsed.FileNumber;
var word = parsed.WordNumber;
var maxElement = device.Options.PlcFamily is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc
? MaxIndirectElementPlc5 : MaxIndirectElement;
if (parsed.IndirectFileSource is { } fileSrc)
{
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
if (r.Value is < 0 or > MaxIndirectFile)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
fileNumber = (int)r.Value!;
}
if (parsed.IndirectWordSource is { } wordSrc)
{
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
if (r.Value < 0 || r.Value > maxElement)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
word = (int)r.Value!;
}
var concrete = parsed with
{
FileNumber = fileNumber, WordNumber = word, IndirectFileSource = null, IndirectWordSource = null,
};
var concreteText = concrete.ToLibplctagName();
var resolvedName = $"{def.Name}=>{concreteText}";
// The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
// libplctag handle per value it ever took.
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
device.IndirectLastName[def.Name] = resolvedName;
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
{
var oldLock = device.GetRuntimeLock(previous);
await oldLock.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
/// <summary>Reads one direct index address as an integer through the shared parent-runtime cache.</summary>
private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
{
AbLegacyDataType type;
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
else type = AbLegacyDataType.Int;
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
var runtimeName = (inner with { BitIndex = null }).ToLibplctagName();
var gate = device.GetRmwLock(runtimeName);
await gate.WaitAsync(ct).ConfigureAwait(false);
try
{
IAbLegacyTagRuntime runtime;
try
{
runtime = await EnsureParentRuntimeAsync(device, runtimeName, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
return (null, AbLegacyStatusMapper.BadNotSupported, false);
}
catch
{
EvictParentRuntime(device, runtimeName);
return (null, AbLegacyStatusMapper.BadCommunicationError, true);
}
var status = runtime.GetStatus();
if (status != 0)
{
EvictParentRuntime(device, runtimeName);
return (null, AbLegacyStatusMapper.MapStatus(status), true);
}
var raw = runtime.DecodeValue(type, inner.BitIndex);
try
{
return raw switch
{
bool b => (b ? 1L : 0L, 0u, false),
null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
_ => (Convert.ToInt64(raw), 0u, false),
};
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
return (null, AbLegacyStatusMapper.BadOutOfRange, false);
}
}
finally
{
gate.Release();
}
}
private static void EvictParentRuntime(DeviceState device, string name)
{
if (device.ParentRuntimes.TryRemove(name, out var stale))
{
try { stale.Dispose(); } catch { /* best-effort */ }
}
}
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
{
// Fast path: runtime already cached.
if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
// Slow path: serialise creation per key so concurrent callers don't each create a
// runtime and one of them gets overwritten + leaked. Only one caller initialises; the
// others find the entry on the second TryGetValue inside the lock.
var creationLock = device.GetCreationLock($"parent:{parentName}");
await creationLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
{
creationLock.Release();
}
}
/// <summary>
/// Whether a tag definition is an ARRAY. The canonical contract: a tag is
/// an array ⟺ its <see cref="AbLegacyTagDefinition.ArrayLength"/> is non-null (the parser
/// sets it ≥1 only when isArray:true), so a 1-element array (ArrayLength:1) IS an array
/// (review I-3). <c>null</c> ArrayLength ⇒ scalar.
/// </summary>
private static bool IsArrayTag(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1;
/// <summary>
/// The effective libplctag element count for a tag definition: the tag's
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> clamped to the PCCC file maximum
/// (<see cref="AbLegacyArray.MaxElements"/> = 256) when it is an array (≥1, INCLUDING 1),
/// or <c>1</c> when the tag is scalar (null ArrayLength). Used both to size the runtime at
/// create time and as the element count the read path decodes into an array.
/// </summary>
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
/// <summary>
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
/// device's reachability, so it must not evict the handle or degrade driver health.
/// </summary>
internal static bool IsLocalRefusal(Exception ex) =>
ex is AbTransportException { Status: AbStatus.Unsupported };
/// <summary>
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
/// </summary>
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
{
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
{
keep();
throw;
}
catch
{
runtime.Dispose();
throw;
}
keep();
}
/// <summary>
/// A tag whose address does not parse for its device's PLC family (e.g. a PLC-5 octal I/O digit 8/9, a
/// function-file letter off MicroLogix) is a configuration error: answered <c>BadNodeIdUnknown</c> with the
/// handle/health untouched, and warned once per tag so a polled tag does not flood the log.
/// </summary>
private void WarnMalformedOnce(DeviceState device, AbLegacyTagDefinition def)
{
if (!device.MalformedAddressLogged.TryAdd(def.Name, 0)) return;
_logger.LogWarning(
"AbLegacy tag address is not valid for the device's PLC family. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Family={Family}",
_driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily);
}
/// <summary>The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
/// the refusal is asked about exactly the runtime a write would otherwise create.</summary>
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
new(Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def));
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
// Fast path: runtime already cached.
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
// Slow path: serialise creation per tag name so concurrent callers for the same tag
// (server read path + poll loop) don't both create a runtime and one gets leaked.
var creationLock = device.GetCreationLock($"tag:{def.Name}");
await creationLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
{
creationLock.Release();
}
}
/// <summary>
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose it so
/// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called
/// after a non-zero libplctag status or a transport exception on a read/write — a non-zero
/// status can mean the controller dropped the connection or the handle became permanently
/// invalid (e.g. after a PLC download), and reusing it would return the same failure forever.
/// Mirrors <c>AbCipDriver.EvictRuntime</c> and the probe loop's recreate-on-failure behaviour
/// (previously AbLegacy leaned only on the probe loop + libplctag's internal recovery, so a
/// data-path fault recovered more slowly than in AbCip).
/// </summary>
/// <param name="device">The device whose runtime cache holds the stale handle.</param>
/// <param name="tagName">The tag name key of the runtime to evict.</param>
private static void EvictRuntime(DeviceState device, string tagName)
{
if (device.Runtimes.TryRemove(tagName, out var stale))
{
try { stale.Dispose(); } catch { /* best-effort */ }
}
}
/// <summary>
/// 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 { }
JoinProbeLoopsSync();
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
/// <summary>
/// Disposes the driver asynchronously.
/// </summary>
/// <returns>A task representing the asynchronous disposal.</returns>
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
internal sealed class DeviceState(
AbLegacyHostAddress parsedAddress,
AbLegacyDeviceOptions options,
AbLegacyPlcFamilyProfile profile)
{
/// <summary>
/// Gets the parsed host address.
/// </summary>
public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;
/// <summary>
/// Gets the device options.
/// </summary>
public AbLegacyDeviceOptions Options { get; } = options;
/// <summary>
/// Gets the PLC family profile.
/// </summary>
public AbLegacyPlcFamilyProfile Profile { get; } = profile;
/// <summary>
/// The CIP path to pass to libplctag. When the parsed host address has an empty CIP
/// path (e.g. <c>ab://10.0.0.5/</c>), the profile-supplied default is used instead so
/// that a SLC 500 misconfigured without an explicit path still gets the required
/// <c>1,0</c> backplane route. MicroLogix has an empty default by design (direct EIP).
/// </summary>
public string EffectiveCipPath => ParsedAddress.CipPath.Length > 0
? ParsedAddress.CipPath
: Profile.DefaultCipPath;
/// <summary>
/// Per-tag cached runtimes. <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// avoids the check-then-act race present on a plain <c>Dictionary</c>: two concurrent
/// <c>EnsureTagRuntimeAsync</c> callers for the same key both miss the lookup on a
/// plain dict and both create + store a runtime, leaking the loser. Access is guarded
/// by a per-key creation semaphore (<see cref="GetCreationLock"/>) so exactly one
/// runtime is created per tag name.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Parent-word runtimes for bit-within-word RMW writes. Keyed by the
/// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
/// single parent runtime for N7:0.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Indirect tag name to the resolved-address runtime key it last used (#646), so a moved index evicts its stale handle.</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
/// sequence so that a concurrent caller waits rather than creating a duplicate runtime
/// that would be leaked on <see cref="DisposeRuntimes"/>.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _creationLocks = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or creates the creation lock for the specified key.
/// </summary>
/// <param name="key">The lock key.</param>
/// <returns>The semaphore slim for the key.</returns>
public SemaphoreSlim GetCreationLock(string key) =>
_creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
/// <summary>
/// Gets or creates the read-modify-write lock for the specified parent name.
/// </summary>
/// <param name="parentName">The parent name.</param>
/// <returns>The semaphore slim for the parent.</returns>
public SemaphoreSlim GetRmwLock(string parentName) =>
_rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _runtimeLocks = new();
/// <summary>
/// Per-runtime operation lock. A libplctag <c>Tag</c> handle is not safe for
/// concurrent Read/GetStatus/DecodeValue from multiple threads — the server read
/// path and the poll loop both call <see cref="AbLegacyDriver.ReadAsync"/> against
/// the same cached runtime. Callers hold this lock around the whole
/// Read → GetStatus → Decode (or Encode → Write → GetStatus) sequence so a status
/// or value is never observed mid-update by another thread. Keyed by tag name, which
/// is also the <see cref="Runtimes"/> dictionary key.
/// </summary>
/// <param name="tagName">The tag name.</param>
/// <returns>The semaphore slim for the tag.</returns>
public SemaphoreSlim GetRuntimeLock(string tagName) =>
_runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));
/// <summary>
/// Gets the probe synchronization lock.
/// </summary>
public object ProbeLock { get; } = new();
/// <summary>
/// Gets or sets the host state.
/// </summary>
public HostState HostState { get; set; } = HostState.Unknown;
/// <summary>
/// Gets or sets the UTC time when the host state last changed.
/// </summary>
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the cancellation token source for the probe loop.
/// </summary>
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>
/// Gets or sets the probe loop task, retained so shutdown can join it (STAB-6).
/// </summary>
public Task? ProbeTask { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the probe has been initialized.
/// </summary>
public bool ProbeInitialized { get; set; }
/// <summary>
/// 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; }
/// <summary>Tags already warned about for a malformed address (once-per-tag log).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> MalformedAddressLogged { get; } = new();
/// <summary>
/// Disposes all cached tag runtimes.
/// </summary>
public void DisposeRuntimes()
{
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
IndirectLastName.Clear();
// Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
// ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
// wait handle once contended).
foreach (var sem in _rmwLocks.Values) sem.Dispose();
_rmwLocks.Clear();
foreach (var sem in _creationLocks.Values) sem.Dispose();
_creationLocks.Clear();
foreach (var sem in _runtimeLocks.Values) sem.Dispose();
_runtimeLocks.Clear();
}
}
}