Auto: ablegacy-9 — per-device timeout / retry overrides

Closes #252
This commit is contained in:
Joseph Doherty
2026-04-26 03:32:45 -04:00
parent 4ff1537d8a
commit c292dcc1db
9 changed files with 585 additions and 51 deletions

View File

@@ -0,0 +1,68 @@
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.IntegrationTests;
/// <summary>
/// PR 9 — per-device timeout integration scaffold. Build-only at PR 9 time: the ab_server
/// PCCC simulator answers in &lt;100 ms locally so a 500 ms per-device timeout doesn't
/// normally trip. Either an <c>iptables --delay</c> sidecar or a <c>tc qdisc</c> netem
/// filter must be wired up first; until then the test asserts that a <i>generous</i>
/// per-device timeout still completes successfully (the precedence path itself is
/// exercised), with the slow-path failure case expressed in unit tests via
/// <see cref="FakeAbLegacyTag"/>.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyPerDeviceTimeoutTests(AbLegacyServerFixture sim)
{
[AbLegacyFact]
public async Task Per_device_Timeout_override_flows_into_runtime_against_ab_server()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
// Driver-wide tight 500 ms; per-device override gives the simulator 5 s headroom
// to demonstrate the precedence rule in a wire-level setting.
Timeout = TimeSpan.FromMilliseconds(500),
Devices = [new AbLegacyDeviceOptions(
deviceUri,
AbLegacyPlcFamily.Slc500,
Timeout: TimeSpan.FromSeconds(5))],
Tags = [new AbLegacyTagDefinition(
Name: "IntCounter",
DeviceHostAddress: deviceUri,
Address: "N7:0",
DataType: AbLegacyDataType.Int)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: "ablegacy-pr9-timeout");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(["IntCounter"], TestContext.Current.CancellationToken);
// Per-device override picked up; the read against the simulator succeeds because the
// 5 s per-device cap supersedes the otherwise-too-tight 500 ms driver-wide default.
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
}
/// <summary>
/// Skeleton for the inverse — slow-link (<c>tc qdisc</c> / <c>iptables --delay</c>) +
/// tight per-device timeout. Skipped pending the netem sidecar work tracked in
/// <see href="../Docker/README.md#per-device-timeout-fixture">Docker/README.md</see>.
/// </summary>
[AbLegacyFact(Skip = "Pending netem / iptables-delay sidecar — see Docker/README.md")]
public Task Per_device_Timeout_below_simulated_delay_surfaces_BadCommunicationError()
{
// Future shape:
// docker compose --profile slc500-slow up -d (adds netem qdisc on the egress)
// override Timeout: TimeSpan.FromMilliseconds(100)
// ReadAsync ⇒ snapshots.Single().StatusCode == BadCommunicationError
// while a sibling device (no override → 5 s) keeps reading Good.
return Task.CompletedTask;
}
}

View File

@@ -139,6 +139,38 @@ bit writes to real hardware or RSEmulate 500 until upstream resolves.
See [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
for the full coverage map.
## Per-device timeout fixture (PR 9 / #252) — TODO
`AbLegacyPerDeviceTimeoutTests.Per_device_Timeout_below_simulated_delay_surfaces_BadCommunicationError`
needs a slow-link sidecar before it can run for real. The simulator answers
in &lt;100 ms locally, so a 500 ms per-device timeout never trips against
the unmodified container.
Two options, neither wired up at PR 9 time:
1. **`tc qdisc` netem inside the container** — add to `docker-compose.yml`:
```yaml
# services:
# ablegacy-slc500-slow:
# extends: ablegacy-slc500
# cap_add: [NET_ADMIN]
# command: >
# sh -c "tc qdisc add dev eth0 root netem delay 800ms &&
# ab_server --plc=SLC500 --port=44818 --path=1,0 --tag=N7[200]:INT16"
```
`--cap-add=NET_ADMIN` is required because `tc qdisc` mutates the
container's egress queue. Combine with `AB_LEGACY_COMPOSE_PROFILE=slc500-slow`
to point the suite at the slow profile.
2. **`iptables --delay` shim** — sidecar container that NATs port 44818 and
adds a fixed delay on the SYN/ACK + payload path. More portable than
netem (no `NET_ADMIN` on the simulator itself) but adds a hop.
When either lands, drop the `Skip = …` on the integration test and assert
the precedence rule end-to-end.
## References
- [libplctag on GitHub](https://github.com/libplctag/libplctag) — `ab_server`

View File

@@ -0,0 +1,283 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// PR 9 — per-device <c>Timeout</c> + <c>Retries</c> overrides. SLC 5/01 needs ~5 s,
/// SLC 5/05 ~2 s, MicroLogix 1100 ~3 s — a single driver-wide timeout always misfires on
/// at least one chassis. Verifies the precedence rules (device > driver-wide > default),
/// that the resolved timeout flows into <see cref="AbLegacyTagCreateParams.Timeout"/>, and
/// that the retry loop honours the per-device count.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyPerDeviceTimeoutTests
{
private const string Host = "ab://10.0.0.5/1,0";
[Fact]
public async Task Per_device_Timeout_flows_into_AbLegacyTagCreateParams()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
// Driver-wide default 2 s — the device override below should win.
Timeout = TimeSpan.FromSeconds(2),
Devices =
[
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500,
DeviceName: "slc-501",
Timeout: TimeSpan.FromSeconds(5)),
],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Absent_per_device_Timeout_falls_back_to_driver_wide()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Timeout = TimeSpan.FromSeconds(7),
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
}
[Fact]
public async Task Two_devices_each_use_their_own_Timeout_override()
{
const string fastHost = "ab://10.0.0.5/1,0";
const string slowHost = "ab://10.0.0.6/";
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Timeout = TimeSpan.FromSeconds(2),
Devices =
[
new AbLegacyDeviceOptions(fastHost, AbLegacyPlcFamily.Slc500,
Timeout: TimeSpan.FromMilliseconds(500)),
new AbLegacyDeviceOptions(slowHost, AbLegacyPlcFamily.MicroLogix,
Timeout: TimeSpan.FromSeconds(5)),
],
Tags =
[
new AbLegacyTagDefinition("Fast", fastHost, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Slow", slowHost, "N7:1", AbLegacyDataType.Int),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Fast", "Slow"], CancellationToken.None);
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500));
factory.Tags["N7:1"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Per_device_Retries_2_yields_3_attempts_before_failure()
{
var factory = new FakeAbLegacyTagFactory();
factory.Customise = p => new FakeAbLegacyTag(p)
{
ThrowOnRead = true,
Exception = new TimeoutException("simulated transient"),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices =
[
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 2),
],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
// 1 initial + 2 retries = 3 attempts.
factory.Tags["N7:0"].ReadCount.ShouldBe(3);
}
[Fact]
public async Task No_Retries_yields_single_attempt()
{
var factory = new FakeAbLegacyTagFactory();
factory.Customise = p => new FakeAbLegacyTag(p)
{
ThrowOnRead = true,
Exception = new TimeoutException("simulated transient"),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
// Both null — defaults to 0 retries (single attempt).
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Driver_wide_Retries_applies_when_device_omits_override()
{
var factory = new FakeAbLegacyTagFactory();
factory.Customise = p => new FakeAbLegacyTag(p)
{
ThrowOnRead = true,
Exception = new TimeoutException("simulated transient"),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Retries = 1, // driver-wide → 2 attempts total
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
}
[Fact]
public async Task Per_device_Retries_overrides_driver_wide_default()
{
var factory = new FakeAbLegacyTagFactory();
factory.Customise = p => new FakeAbLegacyTag(p)
{
ThrowOnRead = true,
Exception = new TimeoutException("simulated transient"),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Retries = 5, // driver-wide
// Per-device says zero retries — should win, single attempt.
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 0)],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Successful_read_after_one_transient_does_not_burn_remaining_retries()
{
// Verifies retries stop once the call succeeds — we shouldn't keep hammering.
var factory = new FakeAbLegacyTagFactory();
var attemptsBeforeSuccess = 1;
factory.Customise = p =>
{
var fake = new FlappyFake(p)
{
FailFirstN = attemptsBeforeSuccess,
FinalValue = 42,
};
return fake;
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 3)],
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots.Single().Value.ShouldBe(42);
// 1 throw + 1 success = 2 attempts (we should NOT use all 4).
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
}
[Fact]
public async Task Dto_round_trip_preserves_TimeoutMs_and_Retries_at_both_levels()
{
const string json = """
{
"TimeoutMs": 4000,
"Retries": 1,
"Devices": [
{
"HostAddress": "ab://10.0.0.5/1,0",
"PlcFamily": "Slc500",
"TimeoutMs": 5000,
"Retries": 2
}
],
"Probe": { "Enabled": false },
"Tags": [
{ "Name": "X", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0", "DataType": "Int" }
]
}
""";
// Use the static factory so we exercise the deserialisation path used in production.
var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-roundtrip", json);
await drv.InitializeAsync(json, CancellationToken.None);
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
state.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(5000));
state.Options.Retries.ShouldBe(2);
// Per-device override wins over driver-wide.
drv.ResolveRetries(state).ShouldBe(2);
drv.ResolveTimeout(state).ShouldBe(TimeSpan.FromMilliseconds(5000));
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A fake that throws the first <c>FailFirstN</c> reads then succeeds. Used to assert
/// the retry loop stops once a call succeeds — it should not exhaust the retry budget.
/// </summary>
private sealed class FlappyFake : FakeAbLegacyTag
{
public int FailFirstN { get; set; }
public object? FinalValue { get; set; }
private int _calls;
public FlappyFake(AbLegacyTagCreateParams p) : base(p) { }
public override Task ReadAsync(CancellationToken ct)
{
_calls++;
// Increment ReadCount via the base accessor (it does its own increment + throw
// bookkeeping). Toggle ThrowOnRead based on the call number so the base helper does
// the throw for us.
if (_calls <= FailFirstN)
{
ThrowOnRead = true;
Exception = new TimeoutException("flap");
}
else
{
ThrowOnRead = false;
Value = FinalValue;
}
return base.ReadAsync(ct);
}
}
}