diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index beeca1a4..f960bb9d 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -90,7 +90,7 @@ request/response handshake — is a different mechanism and remains deferred; se | `IReadable` | `ReadAsync` → the live `/sample` pump's index while it is delivering and healthy, else one `/current` per call | **Not the production data path** — see below | | `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused | | `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** | -| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) | +| `IHostConnectivityProbe` | periodic headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick) | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) | | `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) | | `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) | | `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) | diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs index f5d9cf03..76f46b7c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs @@ -76,6 +76,17 @@ public interface IMTConnectAgentClient : IDisposable /// The parsed device hierarchy. Task ProbeAsync(CancellationToken ct); + /// + /// A connectivity check: does the Agent answer /probe with a success status? Reads the response + /// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity + /// probe used to parse the whole /probe document every tick and throw it away). Throws exactly when + /// would for an unreachable or refusing Agent. The default delegates to + /// and discards the model. + /// + /// Cancellation token (the caller's deadline). + /// A task completing when the Agent has answered with a success status. + async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false); + /// /// Issues an Agent /current request and returns the latest observed value for every /// data item. Used both to prime the observation index at startup and to re-baseline after diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs index 0d8dea7c..ab946fcb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs @@ -182,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable return MTConnectProbeParser.Parse(body); } + /// + public async Task PingAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + deadline.CancelAfter(_requestTimeout); + + using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri); + HttpResponseMessage response; + try + { + // ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model — + // the largest document the Agent serves) is never buffered or parsed; disposing the response drops it. + response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!lifetime.IsCancellationRequested) + { + throw TimedOut(_probeUri); + } + + using (response) + { + response.EnsureSuccessStatusCode(); + } + } + /// public async Task CurrentAsync(CancellationToken ct) { 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 ec469619..ae1ac7bf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -1632,9 +1632,9 @@ public sealed partial class MTConnectDriver /// free, but it only exists while something is subscribed — an instance with no /// subscriptions would report indefinitely, and host /// reachability would become a function of OPC UA client behaviour. A dedicated tick is - /// the one signal that is true whether or not anyone is watching. Its cost is a parse of - /// the device model per interval, which is why - /// exists. + /// the one signal that is true whether or not anyone is watching. Its cost used to be a parse + /// of the whole device model per interval; it is now one request whose response headers are + /// read and whose body is dropped (, R06:T-7). /// /// /// It waits first, then probes. has just completed a @@ -1674,8 +1674,8 @@ public sealed partial class MTConnectDriver using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct); deadline.CancelAfter(options.Probe.Timeout); - // The answer is deliberately discarded — see the remarks. - _ = await client.ProbeAsync(deadline.Token).ConfigureAwait(false); + // Headers only — the device model is neither downloaded in full nor parsed (R06:T-7). + await client.PingAsync(deadline.Token).ConfigureAwait(false); reachable = true; } catch (OperationCanceledException) when (ct.IsCancellationRequested) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 1a80e651..164457a8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -272,6 +272,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable return answer; } + /// Number of connectivity pings (headers-only /probe) issued against this client. + public int PingCallCount => Volatile.Read(ref _pingCallCount); + + private int _pingCallCount; + + /// + /// Same failure surface as (), counted separately. + public Task PingAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _pingCallCount); + + return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure; + } + /// public async Task CurrentAsync(CancellationToken ct) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs index fc96b5c3..46caf10d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs @@ -385,19 +385,22 @@ public sealed class MTConnectHostAndRediscoverTests { var (_, client, ticker) = await ProbingDriverAsync(); - // Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is - // the only call so far. + // Parked before the first tick: the loop waits, THEN probes, so nothing has been pinged yet. + client.PingCallCount.ShouldBe(0); + + await TickAsync(ticker); + client.PingCallCount.ShouldBe(1); + + await TickAsync(ticker); + client.PingCallCount.ShouldBe(2); + + await TickAsync(ticker); + client.PingCallCount.ShouldBe(3); + + // Review 2026-10-21 T-7: a tick is a headers-only ping — the full /probe (download + parse of the device + // model) is still initialize's one call, never a tick's. client.ProbeCallCount.ShouldBe(1); - await TickAsync(ticker); - client.ProbeCallCount.ShouldBe(2); - - await TickAsync(ticker); - client.ProbeCallCount.ShouldBe(3); - - await TickAsync(ticker); - client.ProbeCallCount.ShouldBe(4); - // …and it waits the authored interval between them, rather than a hard-coded cadence. ticker.LastInterval.ShouldBe(AuthoredInterval); } @@ -535,11 +538,12 @@ public sealed class MTConnectHostAndRediscoverTests // The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach // nobody — no probe against a disposed client, and no further waits on the scheduler. - var calls = client.ProbeCallCount; + var calls = client.PingCallCount; + calls.ShouldBeGreaterThan(0, "positive control: the tick before shutdown did reach the Agent"); var waits = ticker.Waits; ticker.Tick(); - client.ProbeCallCount.ShouldBe(calls); + client.PingCallCount.ShouldBe(calls); ticker.Waits.ShouldBe(waits); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs index 60f41b44..eba125ea 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs @@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); } + // -------------------------------------------------------------- MTConnectAgentClient.PingAsync + + /// R06:T-7 — the ping reads the status only: a body that is not a device model does not matter. + [Fact] + public async Task PingAsync_succeeds_on_a_success_status_without_parsing_the_body() + { + var handler = StubHandler.RespondingWith("this is not an MTConnectDevices document"); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + await client.PingAsync(Ct); + + handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe"); + // Positive control: the full probe DOES parse that body, and refuses it. + await Should.ThrowAsync(async () => await client.ProbeAsync(Ct)); + } + + /// A non-success status is an unreachable / refusing Agent, exactly as for the full probe. + [Fact] + public async Task PingAsync_throws_on_a_non_success_status() + { + var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + await Should.ThrowAsync(async () => await client.PingAsync(Ct)); + } + // -------------------------------------------------------------- MTConnectAgentClient.SampleAsync [Fact]