From 8a6f0e3c84eb5a62ca036c616646e158930e13d2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:38:48 -0500 Subject: [PATCH] fix(mtconnect): the Agent refuses count ABOVE bufferSize, not at it (#825) Live gate against the cppagent 2.7 fixture: count=16384 with bufferSize 16384 streams; 16385 is OUT_OF_RANGE ('count' must be less than 16385). Warn only when SampleCount > bufferSize, pin both sides of the edge against the live Agent, and correct the #804 wording that said 'at or above'. --- .claude/rules/drivers.md | 2 +- docs/drivers/MTConnect.md | 7 ++++--- .../MTConnectDriver.SampleLag.cs | 9 +++++---- .../MTConnectDriver.cs | 4 ++-- .../MTConnectDriverProbe.cs | 2 +- .../MTConnectDtos.cs | 2 +- .../MTConnectXml.cs | 2 +- .../Drivers/DriverTestConnectButton.razor | 2 +- .../MTConnectAgentIntegrationTests.cs | 19 ++++++++++++++----- .../MTConnectBufferSizeWarningTests.cs | 19 ++++++++++--------- .../MTConnectSubscribeTests.cs | 2 +- 11 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..8047bfd87 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -296,7 +296,7 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `InvalidDataException` — before #804 the 400 was a transport fault and the `OUT_OF_RANGE` re-baseline never ran live. The restart (`instanceId`) test also runs on every re-baseline snapshot (on 2.x that is where a restart is first seen). 3 re-baselines with no `/sample` chunk ⇒ Degraded with `LastSuccessfulRead` still advancing (no - watchdog reconnect loop over a `SampleCount` ≥ `bufferSize`). Every parse prohibits DTDs; the streams walk is + watchdog reconnect loop over a `SampleCount` > `bufferSize` — live: `count == bufferSize` is accepted; Test Connect + init warn, #825). Every parse prohibits DTDs; the streams walk is depth-bounded (64 — an unbounded recursion is a process-killing `StackOverflowException`). Non-finite floats are never Good; integer-valued decimals serve integral tags losslessly. Matrix: `docs/drivers/MTConnect.md`. - **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig` diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 81641b708..90b4f4cbd 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -362,7 +362,7 @@ what the driver's cursor expects) is detected **two ways**, both triggering a **Re-baselines without progress are bounded *and visible* (#804).** The reconnect backoff already paced a stream that only ever re-baselines (`A_rebaseline_that_delivers_nothing…`), but every `/current` also marked the stream delivered, so the driver read **Healthy** -for ever while no `/sample` chunk arrived — the live case is a `SampleCount` at or +for ever while no `/sample` chunk arrived — the live case is a `SampleCount` above the Agent's `bufferSize`, refused `OUT_OF_RANGE` (`'count' must be less than N`) on every open. After **3** consecutive re-baselines with no chunk in between (`MaxRebaselinesWithoutProgress`, the Java driver's `MAX_REBASELINES_WITHOUT_PROGRESS`) @@ -371,11 +371,12 @@ observations in between"` and one Warning naming the `bufferSize` cause; it stay Degraded across further re-baselines until a chunk is delivered. Values keep flowing off the re-baselines and `LastSuccessfulRead` keeps advancing — each `/current` is the Agent answering — so the comms-loss watchdog does not reconnect-loop a configuration -problem. Size `SampleCount` below the Agent's `bufferSize` (in the `/probe` header). +problem. Size `SampleCount` at most the Agent's `bufferSize` (in the `/probe` header). **It is caught before it happens (#825):** the `/probe` header's `bufferSize` is parsed (`MTConnectProbeModel.BufferSize`, advisory — missing/malformed is null, never a parse failure); **Test Connect** reads `sampleCount` (default 1000 when absent) and answers Ok **with a warning** (an amber -"OK with warning" chip showing the text) when it is at or above the buffer, and the driver's **initialize** +"OK with warning" chip showing the text) when it is **above** the buffer (live, cppagent 2.7: `count == bufferSize` is +accepted, `bufferSize + 1` refused `'count' must be less than {bufferSize+1}`), and the driver's **initialize** (every deploy) logs the same Warning and puts `agentBufferSize` on the `/hosts` diagnostics bag. **An Agent `instanceId` change means the Agent restarted**, and is checked diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs index 3d6594792..1d130fbd1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs @@ -61,7 +61,8 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics /// /// lmxopcua#825 — the configuration warning for a the Agent /// will refuse. An Agent answers /sample?count=N with OUT_OF_RANGE ("'count' must be less than - /// bufferSize") when N ≥ bufferSize, so such a driver never streams: it re-baselines from + /// bufferSize + 1") when N > bufferSize — live-measured on cppagent 2.7 (bufferSize 16384: 16384 is + /// accepted, 16385 refused) — so such a driver never streams: it re-baselines from /// /current and reports Degraded after three of them. Shared by /// (Test Connect) and the driver's initialize (deploy), so both say the same thing. /// @@ -69,10 +70,10 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics /// The Agent's buffer size, or null when its header did not say. /// The warning text, or when the count fits (or the buffer size is unknown). internal static string? SampleCountBufferWarning(int sampleCount, long? bufferSize) => - bufferSize is { } size && sampleCount >= size - ? $"SampleCount {sampleCount.ToString(CultureInfo.InvariantCulture)} is at or above the Agent's bufferSize " + bufferSize is { } size && sampleCount > size + ? $"SampleCount {sampleCount.ToString(CultureInfo.InvariantCulture)} is above the Agent's bufferSize " + $"{size.ToString(CultureInfo.InvariantCulture)}: the Agent refuses every /sample stream (OUT_OF_RANGE), so " - + "values will only re-baseline from /current and the driver reports Degraded. Set SampleCount below " + + "values will only re-baseline from /current and the driver reports Degraded. Set SampleCount to at most " + $"{size.ToString(CultureInfo.InvariantCulture)}." : null; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 352220eb5..8cc6566a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -2350,7 +2350,7 @@ public sealed partial class MTConnectDriver // #804 — the backoff above PACES a stream that only ever re-baselines, but each /current // also marked the stream delivered, so it read Healthy for ever while no /sample chunk - // arrived (a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on + // arrived (a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on // EVERY open). After MaxRebaselinesWithoutProgress in a row it is reported Degraded, and // stays so (holdStalled) until a chunk is delivered. if (!outcome.DeliveredAny && @@ -2363,7 +2363,7 @@ public sealed partial class MTConnectDriver _logger.LogWarning( outcome.Error, - "MTConnect driver {DriverInstanceId}: {Count} consecutive /current re-baselines of {AgentUri} without a single /sample chunk in between; reporting the stream Degraded until one is delivered. Values are re-baselined, not streamed. An Agent answering every /sample with OUT_OF_RANGE usually means SampleCount is at or above its bufferSize.", + "MTConnect driver {DriverInstanceId}: {Count} consecutive /current re-baselines of {AgentUri} without a single /sample chunk in between; reporting the stream Degraded until one is delivered. Values are re-baselined, not streamed. An Agent answering every /sample with OUT_OF_RANGE usually means SampleCount is above its bufferSize.", _driverInstanceId, rebaselinesWithoutProgress, AgentDisplay(options)); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs index cc402eb07..62cd96dc0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs @@ -135,7 +135,7 @@ public sealed class MTConnectDriverProbe : IDriverProbe /// /// The /sample count (lmxopcua#825). Not needed to reach the Agent, but read so Test Connect - /// can warn when it is at or above the Agent's bufferSize — a config the Agent will refuse on every + /// can warn when it is above the Agent's bufferSize — a config the Agent will refuse on every /// stream. Absent ⇒ the driver's own default. /// public int? SampleCount { get; set; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index a7b6ef792..3a1b48c4c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// The Agent's observation ring-buffer size (the /probe Header's bufferSize), or /// when the header omits it or it is not a positive integer. A /sample -/// count at or above it is refused OUT_OF_RANGE by the Agent (lmxopcua#825). +/// count above it is refused OUT_OF_RANGE by the Agent (lmxopcua#825; live: count == bufferSize is accepted). /// public sealed record MTConnectProbeModel(IReadOnlyList Devices, long? BufferSize = null); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs index c9237a7fd..8285b25dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs @@ -76,7 +76,7 @@ internal static class MTConnectXml /// /// Why this exists (#804). A modern Agent (cppagent 2.x, the fixture's /// mtconnect/agent:2.7) does not serve its error documents under HTTP 200 the way older - /// ones did: it answers an out-of-range /sample?from= — and a count at or above its + /// ones did: it answers an out-of-range /sample?from= — and a count above its /// buffer — with HTTP 400, and an unknown device with 404, each carrying a /// well-formed MTConnectError body. A status check alone turns those into an /// , which the pump treats as a transient transport loss and diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor index f133dae8f..356b8ef7f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor @@ -14,7 +14,7 @@ { @if (_result.Ok && WarningOf(_result.Message) is { } warning) { - @* Reachable but misconfigured (e.g. MTConnect SampleCount >= the Agent's bufferSize, #825): a probe + @* Reachable but misconfigured (e.g. MTConnect SampleCount > the Agent's bufferSize, #825): a probe that answers Ok with a "WARNING:" message is shown amber with the warning, never a plain green OK. *@ OK with warning · @_result.LatencyMs?.ToString("F0") ms diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 85639ebcd..b77ade7cd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -868,7 +868,7 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) } /// - /// #804 — a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on + /// #804 — a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on /// EVERY /sample open (live: 'count' must be less than N). The driver keeps serving values off /// /current re-baselines, and — after three in a row with no /sample chunk — reports the stream /// Degraded with LastSuccessfulRead still advancing, rather than Healthy for ever (before #804 the @@ -949,7 +949,8 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) /// /// #825 — Test Connect reads the live Agent's /probe bufferSize and warns (still Ok) when the - /// configured SampleCount would be refused; one below the buffer is the falsifiability control. + /// configured SampleCount would be refused. The live Agent accepts count == bufferSize and refuses + /// bufferSize + 1, so both sides of that edge are checked against the Agent itself as well as the probe. /// [Fact(Timeout = TestTimeoutMs)] public async Task Test_Connect_warns_when_SampleCount_reaches_the_live_agents_buffer_size() @@ -963,14 +964,22 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) Assert.Skip($"The Agent at {agent.AgentUri} declares no usable bufferSize in its /probe header."); } + // The Agent's own edge, so the warning threshold is pinned to what the Agent actually does. + using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }) + { + (await http.GetStringAsync($"{agent.AgentUri}/sample?count={bufferSize.Value}", Ct)).ShouldNotContain("OUT_OF_RANGE"); + var over = await http.GetAsync($"{agent.AgentUri}/sample?count={bufferSize.Value + 1}", Ct); + (await over.Content.ReadAsStringAsync(Ct)).ShouldContain("OUT_OF_RANGE"); + } + var refused = await new MTConnectDriverProbe().ProbeAsync( - JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value }), + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value + 1 }), TimeSpan.FromSeconds(15), Ct); refused.Ok.ShouldBeTrue(refused.Message); - refused.Message!.ShouldContain($"WARNING: SampleCount {bufferSize.Value} is at or above the Agent's bufferSize {bufferSize.Value}"); + refused.Message!.ShouldContain($"WARNING: SampleCount {bufferSize.Value + 1} is above the Agent's bufferSize {bufferSize.Value}"); var fits = await new MTConnectDriverProbe().ProbeAsync( - JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value - 1 }), + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value }), TimeSpan.FromSeconds(15), Ct); fits.Ok.ShouldBeTrue(fits.Message); fits.Message!.ShouldNotContain("WARNING"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs index 1f6c72e67..dbe00e969 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs @@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// -/// lmxopcua#825 — a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on +/// lmxopcua#825 — a SampleCount above the Agent's bufferSize is refused OUT_OF_RANGE on /// every /sample stream. The /probe header carries bufferSize, so Test Connect and the /// driver's initialize (deploy) both warn instead of leaving it to surface as a Degraded stream. /// @@ -37,15 +37,15 @@ public sealed class MTConnectBufferSizeWarningTests MTConnectProbeParser.Parse(ProbeXml(attribute)).BufferSize.ShouldBe(expected); [Theory] - [InlineData(16383, 16384L, false)] - [InlineData(16384, 16384L, true)] + [InlineData(16384, 16384L, false)] // live, cppagent 2.7: count == bufferSize is accepted + [InlineData(16385, 16384L, true)] // ... and bufferSize + 1 refused OUT_OF_RANGE [InlineData(20000, 16384L, true)] [InlineData(1_000_000, null, false)] - public void The_warning_fires_exactly_at_and_above_the_buffer_size(int sampleCount, long? bufferSize, bool warns) + public void The_warning_fires_only_above_the_buffer_size(int sampleCount, long? bufferSize, bool warns) { var warning = MTConnectDriver.SampleCountBufferWarning(sampleCount, bufferSize); (warning is not null).ShouldBe(warns); - if (warns) warning!.ShouldContain($"below {bufferSize}"); + if (warns) warning!.ShouldContain($"at most {bufferSize}"); } [Fact] @@ -53,7 +53,7 @@ public sealed class MTConnectBufferSizeWarningTests { var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); var r = await probe.ProbeAsync( - "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16384}", TimeSpan.FromSeconds(3), + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16385}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); r.Ok.ShouldBeTrue("the Agent answered — reachability is not the problem"); @@ -70,7 +70,7 @@ public sealed class MTConnectBufferSizeWarningTests "{\"agentUri\":\"http://stub-agent:5000\"}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); r.Ok.ShouldBeTrue(); - r.Message!.ShouldContain("SampleCount 1000 is at or above the Agent's bufferSize 512"); + r.Message!.ShouldContain("SampleCount 1000 is above the Agent's bufferSize 512"); } [Fact] @@ -78,7 +78,7 @@ public sealed class MTConnectBufferSizeWarningTests { var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); var r = await probe.ProbeAsync( - "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":1000}", TimeSpan.FromSeconds(3), + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16384}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); r.Ok.ShouldBeTrue(); @@ -86,7 +86,8 @@ public sealed class MTConnectBufferSizeWarningTests } [Theory] - [InlineData(131072, true)] + [InlineData(131073, true)] + [InlineData(131072, false)] [InlineData(1000, false)] public async Task Initialize_warns_and_surfaces_the_buffer_size_on_the_diagnostics_bag(int sampleCount, bool warns) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs index ccaccf26a..b60d1683e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs @@ -1168,7 +1168,7 @@ public sealed class MTConnectSubscribeTests /// /// #804 (Java-driver parity: MAX_REBASELINES_WITHOUT_PROGRESS). An Agent that answers EVERY - /// /sample open with OUT_OF_RANGE (live: a count at or above its bufferSize) is + /// /sample open with OUT_OF_RANGE (live: a count above its bufferSize) is /// re-baselined from /current each time, and the backoff paces that — but every re-baseline also marked /// the stream delivered, so the driver read Healthy for ever while not one /sample chunk arrived. /// After in a row it is Degraded and stays so