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
@@ -17,6 +17,33 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFactoryArrayTagTests
{
/// <summary>
/// A non-positive <c>TimeoutMs</c> clamps to the 2 s default rather than reaching libplctag's
/// <c>Tag.Timeout</c> setter (which throws <see cref="ArgumentOutOfRangeException"/> for a
/// non-positive value, faulting every read/write). Driver timeout hardening.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void ParseOptions_clamps_non_positive_TimeoutMs_to_the_default(int timeoutMs)
{
var json = $$$"""{"TimeoutMs":{{{timeoutMs}}},"Probe":{"TimeoutMs":{{{timeoutMs}}}}}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
opts.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
opts.Timeout.ShouldBeGreaterThan(TimeSpan.Zero);
opts.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
}
/// <summary>A positive <c>TimeoutMs</c> is honoured verbatim.</summary>
[Fact]
public void ParseOptions_honours_a_positive_TimeoutMs()
{
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", """{"TimeoutMs":500}""");
opts.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500));
}
/// <summary>A driver-config tag declaring isArray/elementCount produces an array definition.</summary>
[Fact]
public void ParseOptions_threads_isArray_and_elementCount_into_tag_definition()
@@ -0,0 +1,109 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Driver-timeout-hardening parity with AbCip: a read/write that fails with a non-zero libplctag
/// status or a transport exception must EVICT the cached runtime so the next call re-creates a
/// fresh handle — rather than returning the same failure forever until the probe loop happens to
/// recycle it. Previously AbLegacy evicted on neither path (unlike AbCip).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyEvictOnFailureTests
{
private const string Device = "ab://10.0.0.5/1,0";
// Probe disabled — the probe loop also calls _tagFactory.Create, which would perturb the
// creation count these tests assert on.
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Device)],
Tags = tags,
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>A non-zero libplctag status on read evicts the runtime; the next read creates a fresh handle and recovers.</summary>
[Fact]
public async Task Read_nonzero_status_evicts_runtime_so_next_read_creates_fresh_handle()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Counter", Device, "N7:0", AbLegacyDataType.Int));
var callCount = 0;
factory.Customise = p =>
{
callCount++;
return callCount == 1
? new FakeAbLegacyTag(p) { Status = (int)Status.ErrorBadConnection }
: new FakeAbLegacyTag(p) { Status = 0, Value = 42 };
};
await drv.InitializeAsync("{}", CancellationToken.None);
// First read — bad status → runtime evicted, no reuse.
var first = await drv.ReadAsync(["Counter"], CancellationToken.None);
first.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
// Second read — fresh handle, succeeds.
var second = await drv.ReadAsync(["Counter"], CancellationToken.None);
second.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
second.Single().Value.ShouldBe(42);
callCount.ShouldBe(2); // one failed handle + one fresh handle
}
/// <summary>A transport exception on read evicts the runtime; the next read creates a fresh handle and recovers.</summary>
[Fact]
public async Task Read_transport_exception_evicts_runtime_so_next_read_creates_fresh_handle()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Counter", Device, "N7:0", AbLegacyDataType.Int));
var callCount = 0;
factory.Customise = p =>
{
callCount++;
return callCount == 1
? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException() }
: new FakeAbLegacyTag(p) { Status = 0, Value = 7 };
};
await drv.InitializeAsync("{}", CancellationToken.None);
var first = await drv.ReadAsync(["Counter"], CancellationToken.None);
first.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
var second = await drv.ReadAsync(["Counter"], CancellationToken.None);
second.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
second.Single().Value.ShouldBe(7);
callCount.ShouldBe(2);
}
/// <summary>A non-zero libplctag status on write evicts the runtime; the next write creates a fresh handle and recovers.</summary>
[Fact]
public async Task Write_nonzero_status_evicts_runtime_so_next_write_creates_fresh_handle()
{
var (drv, factory) = NewDriver(
new AbLegacyTagDefinition("Counter", Device, "N7:0", AbLegacyDataType.Int));
var callCount = 0;
factory.Customise = p =>
{
callCount++;
return callCount == 1
? new FakeAbLegacyTag(p) { Status = (int)Status.ErrorBadConnection }
: new FakeAbLegacyTag(p) { Status = 0 };
};
await drv.InitializeAsync("{}", CancellationToken.None);
var first = await drv.WriteAsync([new WriteRequest("Counter", (short)5)], CancellationToken.None);
first.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
var second = await drv.WriteAsync([new WriteRequest("Counter", (short)5)], CancellationToken.None);
second.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
callCount.ShouldBe(2);
}
}
@@ -57,6 +57,40 @@ public sealed class FocasFactoryConfigTests
drv.Options.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
}
// ---- Driver timeout hardening: a non-positive TimeoutMs must not disable the per-call ceiling ----
/// <summary>
/// A misconfigured <c>TimeoutMs: 0</c> (or negative) must clamp to the 2 s default, NOT map to
/// <see cref="TimeSpan.Zero"/> — a zero/negative <c>Timeout</c> makes <c>SynchronizedFocasClient</c>
/// disable its per-call wall-clock ceiling, reintroducing the unbounded-read-on-a-frozen-peer
/// wedge the S7 R2-01 read-leg fix eliminated. Guards the factory clamp.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-5000)]
public void CreateInstance_clamps_non_positive_TimeoutMs_to_the_default(int timeoutMs)
{
var json = $$$"""{"Backend":"unimplemented","TimeoutMs":{{{timeoutMs}}},"Probe":{"TimeoutMs":{{{timeoutMs}}}}}""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
drv.Options.Timeout.ShouldBeGreaterThan(TimeSpan.Zero);
drv.Options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
}
/// <summary>A positive <c>TimeoutMs</c> is honoured verbatim (the clamp only rewrites non-positive values).</summary>
[Fact]
public void CreateInstance_honours_a_positive_TimeoutMs()
{
const string json = """{"Backend":"unimplemented","TimeoutMs":750}""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(750));
}
/// <summary>Verifies that the AlarmProjection configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_AlarmProjection_section_onto_options()