fix(drivers): harden operator TimeoutMs handling + AbLegacy evict parity
v2-ci / build (pull_request) Successful in 4m5s
v2-ci / unit-tests (pull_request) Failing after 10m45s

Follow-ups from the fleet-wide read-timeout audit that the S7 R2-01 read-leg
fix (PR #453) prompted. The audit confirmed S7 was the ONLY driver with the
async-read-ignores-socket-timeout hang; these are the two adjacent (non-hang)
findings it surfaced.

1. FOCAS TimeoutMs:0 footgun (the risky one): a non-positive Timeout made
   SynchronizedFocasClient DISABLE its per-call wall-clock ceiling, reverting to
   the caller's long-lived poll token — reintroducing exactly the frozen-peer
   wedge S7 just eliminated, under misconfig. Clamp non-positive TimeoutMs to the
   2s default at the config boundary so the deadline can never be authored away.

2. TimeoutMs validation symmetry: apply the same clamp in the AbCip + AbLegacy
   factories. libplctag's Tag.Timeout setter throws on <=0, faulting tag creation
   on every read/write; clamping keeps a misconfigured TimeoutMs:0 running on the
   default bound instead. (Shared PositiveTimeoutOrDefault helper per factory.)

3. AbLegacy reconnect parity with AbCip: AbLegacy evicted the cached libplctag
   runtime on neither the non-zero-status nor transport-exception read/write path
   (AbCip evicts on both), so a data-path fault recovered only via the probe loop
   / libplctag internals. Added EvictRuntime + wired it into both read and write
   failure paths so a fresh handle is created on the next call.

Tests: FOCAS 265->269 (clamp theory + positive), AbCip 336->339 (clamp theory +
positive), AbLegacy 209->212 (read-nonzero / read-exception / write-nonzero evict).
No production regressions; all three driver suites green.
This commit is contained in:
Joseph Doherty
2026-07-15 07:35:17 -04:00
parent c21e21423f
commit a5a0f96d49
7 changed files with 254 additions and 9 deletions
@@ -75,10 +75,10 @@ public static class AbCipDriverFactoryExtensions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000),
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
@@ -86,6 +86,20 @@ public static class AbCipDriverFactoryExtensions
};
}
/// <summary>
/// Maps an operator-supplied timeout (ms) to a positive <see cref="TimeSpan"/>, substituting
/// <paramref name="defaultMs"/> for a null / zero / negative value. libplctag's managed
/// <c>Tag.Timeout</c> setter throws <see cref="ArgumentOutOfRangeException"/> for a
/// non-positive value, which would otherwise fault tag creation on every read/write; clamping
/// here keeps a misconfigured <c>TimeoutMs: 0</c> running on the default bound instead. (Driver
/// timeout hardening, sibling of the S7 R2-01 read-leg fix.)
/// </summary>
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
/// <returns>A strictly positive timeout.</returns>
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
@@ -264,6 +264,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
@@ -287,6 +289,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
catch (OperationCanceledException) { throw; }
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);
@@ -360,9 +364,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
opLock.Release();
}
results[i] = new WriteResult(status == 0
? AbLegacyStatusMapper.Good
: AbLegacyStatusMapper.MapLibplctagStatus(status));
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.MapLibplctagStatus(status));
}
else
{
results[i] = new WriteResult(AbLegacyStatusMapper.Good);
}
}
catch (OperationCanceledException) { throw; }
catch (NotSupportedException nse)
@@ -380,6 +391,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
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);
}
@@ -685,6 +698,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
/// <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
@@ -83,10 +83,10 @@ public static class AbLegacyDriverFactoryExtensions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
};
return new AbLegacyDriver(
@@ -95,6 +95,20 @@ public static class AbLegacyDriverFactoryExtensions
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
}
/// <summary>
/// Maps an operator-supplied timeout (ms) to a positive <see cref="TimeSpan"/>, substituting
/// <paramref name="defaultMs"/> for a null / zero / negative value. libplctag's managed
/// <c>Tag.Timeout</c> setter throws <see cref="ArgumentOutOfRangeException"/> for a
/// non-positive value, which would otherwise fault tag creation on every read/write; clamping
/// here keeps a misconfigured <c>TimeoutMs: 0</c> running on the default bound instead. (Driver
/// timeout hardening, sibling of the S7 R2-01 read-leg fix.)
/// </summary>
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
/// <returns>A strictly positive timeout.</returns>
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
{
@@ -84,9 +84,9 @@ public static class FocasDriverFactoryExtensions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000),
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
@@ -96,6 +96,20 @@ public static class FocasDriverFactoryExtensions
return new FocasDriver(options, driverInstanceId, clientFactory);
}
/// <summary>
/// Maps an operator-supplied timeout (ms) to a positive <see cref="TimeSpan"/>, substituting
/// <paramref name="defaultMs"/> for a null / zero / negative value. A non-positive
/// <c>Timeout</c> would make <see cref="SynchronizedFocasClient"/> DISABLE its per-call
/// wall-clock ceiling (falling back to the caller's long-lived poll token), reintroducing the
/// exact unbounded-read-on-a-frozen-peer wedge the S7 R2-01 read-leg fix eliminated. Clamping
/// here at the config boundary guarantees the deadline can never be authored away.
/// </summary>
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
/// <returns>A strictly positive timeout.</returns>
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
/// <summary>
/// Builds the appropriate <see cref="IFocasClientFactory"/> based on the config DTO's backend selection.
/// </summary>