fix(twincat): review — a connect failure no longer burns the sum-chunk warning; doc precision

- ReadBucketAsync: the once-per-device "sum-read chunk failed" Warning fires only after a successful
  connect; a connect/backoff failure is the connect path's to report. TwinCATSumChunkWarningTests pins
  it (no warning across connect failures, exactly one across two later failing sum polls; red without
  the guard) plus a source guard that ReadGroupAsync builds its SumRead only via CreateSumRead.
- Docs: diagnostics live-gated via the driver harness (/hosts not exercised); TC2 costs 8 reads on the
  first tick and after each reconnect, then <= 4; read counts attributed to the unit tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 21:26:26 -04:00
parent 664ff75ee2
commit a92fb81f00
4 changed files with 118 additions and 9 deletions
+4 -3
View File
@@ -1151,9 +1151,10 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
(`CheckNotification`) rides a batch; value-converted pairs (DInt over INT, Real over LREAL/DINT) read singly. Only overall
ADS 1793/1794 latch the device to single reads; any other non-comms overall code (1792/1795/1797/1798...) reads that chunk singly for the poll without latching, with a once-per-device Warning; a comms failure neither.
Opt-in `diagnosticsEnabled` (default off) puts ADS state/app/online-change/cycle/exec/`HeadroomMs` on the `/hosts`
`IDriverDiagnostics` bag (up to 4 extra reads per device per probe tick; TC2 falls back to `.SYSTEMINFO` /
`.SYSTEMTASKINFOARR[1]`). Live-gated read-only 2026-10-08 on Z35474 (TC3) + Z34834 (TC2, which answers sum
commands); the gate found ⚠️ Beckhoff's `SumRead` ctor throws for a `List<>` of entities (only an array survives
`IDriverDiagnostics` bag (≤ 4 extra reads per device per probe tick once settled; TC2 falls back to `.SYSTEMINFO` /
`.SYSTEMTASKINFOARR[1]`, costing 8 on the first tick and after each reconnect). Live-gated read-only 2026-10-08 on
Z35474 (TC3) + Z34834 (TC2, which answers sum commands) via a harness driving `TwinCATDriver` — `/hosts` end-to-end
not run (a stock host has no AMS router); the gate found ⚠️ Beckhoff's `SumRead` ctor throws for a `List<>` of entities (only an array survives
its internal cast) — `AdsTwinCATClient.CreateSumRead` owns it. Flat browse stays (VirtualTree is a strict subset
on TC3). Sum-write not ported.
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
+10 -5
View File
@@ -224,12 +224,16 @@ command is unverified, hence opt-in.
### `/hosts` diagnostics (`IDriverDiagnostics`, opt-in)
Behind `diagnosticsEnabled` (default **off** — not yet live-gated). The driver implements the optional
Behind `diagnosticsEnabled` (default **off**). Live-gated read-only 2026-10-08 on Z35474 (TC3) and Z34834 (TC2) by
driving `TwinCATDriver` from a scratch harness with an in-process AMS router; the `/hosts` page end-to-end was
**not** exercised (a stock host has no AMS router). The driver implements the optional
`IDriverDiagnostics` bag (the S7 `NegotiatedPduSize` precedent, #640), deliberately not synthetic raw tags.
When enabled, after each healthy probe tick **and after the host-state transition** it reads up to four
`TwinCAT_SystemInfoVarList` symbols per device — `_AppInfo.AppName`, `_AppInfo.OnlineChangeCnt`,
`_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks) — so up to **4 extra read requests per
device per probe interval** (default 5 s). The ADS state is the one `ProbeAsync` already read; no second
`_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks) — so at most **4 extra read requests per
device per probe interval** (default 5 s) once settled. On **TwinCAT 2** (fallback below) the first tick, and the
first tick after every reconnect, costs **8** (the four TC3 names answer "not found", then the four TC2 names);
after that ≤ 4. The ADS state is the one `ProbeAsync` already read; no second
ReadState. One device: flat keys `TwinCAT.AdsState`, `.AppName`, `.OnlineChangeCnt`, `.CycleTimeMs`,
`.LastExecTimeMs`, `.HeadroomMs` (cycle − last exec; negative = the task overran; this is spare time, not
jitter). Several devices: one line per device, `TwinCAT.<DeviceName or host>` =
@@ -239,8 +243,9 @@ best-effort: a runtime that does not export one keeps the others, and a symbol a
found" is not re-asked until a new client is installed (reconnect). **TwinCAT 2** has no
`TwinCAT_SystemInfoVarList`; once a TC3 name answers "not found" the driver reads the TC2 globals instead —
`.SYSTEMINFO.PROJECTNAME` (AppName), `.SYSTEMINFO.ONLINECHANGECOUNT` (UINT), `.SYSTEMTASKINFOARR[1].CYCLETIME` /
`.LASTEXECTIME` (same 100 ns units). Live on TC2 2.11: 8 reads on the first tick, 4 after; a TC3 target never
reads the TC2 names.
`.LASTEXECTIME` (same 100 ns units). The read counts — 8 on the first tick and after each reconnect, 4 after, and
none of the TC2 names on a TC3 target — are pinned by `TwinCATDiagnosticsTests`; the live run proved the values
(all six keys on TC2 2.11), not the counts.
## Capability surface
@@ -386,9 +386,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
continue;
}
// Set once the connect succeeded: a connect / backoff failure is the connect path's to report (it
// logs its own Warning) and must not consume the once-per-device sum-chunk-failure Warning below.
var connected = false;
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
connected = true;
var items = new TwinCATBulkReadItem[chunk.Count];
for (var k = 0; k < chunk.Count; k++)
items[k] = new TwinCATBulkReadItem(chunk[k].SymbolName, chunk[k].Def.DataType, chunk[k].BitIndex, chunk[k].Def.ArrayLength);
@@ -429,7 +433,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
// Once per device: the health message is overwritten by the next good read in the same poll, so
// without this a sum-read path failing on every poll left no trace (hw2 live gate, 2026-10-08).
if (!device.SumChunkFailureWarned)
if (connected && !device.SumChunkFailureWarned)
{
device.SumChunkFailureWarned = true;
_logger.LogWarning(ex,
@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// hw2 review of the live-gate fix: the once-per-device "sum-read chunk failed" Warning exists to surface a
/// sum-read path that fails on every poll (the gate's silent failure). A device that is merely unreachable must
/// not consume it — the connect path reports that itself — or the later real defect is logged nowhere.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSumChunkWarningTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string ChunkWarning = "an ADS sum-read chunk";
[Fact]
public async Task A_connect_failure_does_not_burn_the_warning_and_a_later_real_chunk_failure_warns_once()
{
var connectFails = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = connectFails,
Exception = new InvalidOperationException("device down"),
SumReadException = new InvalidOperationException("sum path broken"),
},
};
var logger = new CapturingLogger();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseSumRead = true,
SumReadMaxItems = 10,
}, "drv-warn", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
// Unreachable: the connect attempt fails, then the backoff window rejects further attempts.
for (var i = 0; i < 3; i++)
(await drv.ReadAsync(["A", "B"], CancellationToken.None))
.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError);
logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(0);
// Device comes back, but the sum path itself fails: wait out the 1 s connect backoff, then two polls.
connectFails = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!factory.Clients.Any(c => c.IsConnected) && DateTime.UtcNow < deadline)
{
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await Task.Delay(50);
}
factory.Clients.Any(c => c.IsConnected).ShouldBeTrue("the device must reconnect for the second half to mean anything");
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(1);
}
/// <summary>Source guard: the driver's only Beckhoff <c>SumRead</c> construction is inside
/// <c>CreateSumRead</c>, so re-inlining a <c>List</c>-based construction in <c>ReadGroupAsync</c> goes red.</summary>
[Fact]
public void ReadGroupAsync_builds_its_sum_command_through_CreateSumRead_only()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
dir.ShouldNotBeNull("repo root not found");
var src = File.ReadAllText(Path.Combine(dir!.FullName,
"src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.TwinCAT", "AdsTwinCATClient.cs"));
Regex.Matches(src, @"new\s+SumRead\s*\(").Count.ShouldBe(1);
var create = src.IndexOf("internal static SumRead CreateSumRead(", StringComparison.Ordinal);
create.ShouldBeGreaterThan(0);
var ctor = Regex.Match(src, @"new\s+SumRead\s*\(").Index;
ctor.ShouldBeGreaterThan(create);
src.IndexOf("private const uint SymbolValueByHandle", StringComparison.Ordinal).ShouldBeGreaterThan(ctor);
Regex.IsMatch(src, @"await\s+CreateSumRead\(_client").ShouldBeTrue();
}
private sealed class CapturingLogger : ILogger<TwinCATDriver>
{
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
}
}
}