perf(mtconnect): headers-only connectivity probe (R06:T-7)

The connectivity probe parsed the full /probe device model every 5 s and discarded
it. It now calls a new IMTConnectAgentClient.PingAsync: one GET /probe read with
ResponseHeadersRead and a status check; the body is dropped unread. Same failure
surface as ProbeAsync (the default implementation delegates to it). The probe-tick
tests now count pings and pin that no tick issues a full /probe; the shutdown
test's absence assertion gained a positive control.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:57:43 -04:00
parent f05e3bf6f4
commit d4b9fa1c29
7 changed files with 105 additions and 19 deletions
@@ -76,6 +76,17 @@ public interface IMTConnectAgentClient : IDisposable
/// <returns>The parsed device hierarchy.</returns>
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
/// <summary>
/// A connectivity check: does the Agent answer <c>/probe</c> 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 <c>/probe</c> document every tick and throw it away). Throws exactly when
/// <see cref="ProbeAsync"/> would for an unreachable or refusing Agent. The default delegates to
/// <see cref="ProbeAsync"/> and discards the model.
/// </summary>
/// <param name="ct">Cancellation token (the caller's deadline).</param>
/// <returns>A task completing when the Agent has answered with a success status.</returns>
async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
/// <summary>
/// Issues an Agent <c>/current</c> 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
@@ -182,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return MTConnectProbeParser.Parse(body);
}
/// <inheritdoc/>
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();
}
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
@@ -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 <see cref="HostState.Unknown"/> 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 <see cref="MTConnectProbeOptions.Interval"/>
/// 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 (<see cref="IMTConnectAgentClient.PingAsync"/>, R06:T-7).
/// </para>
/// <para>
/// <b>It waits first, then probes.</b> <see cref="StartCoreAsync"/> 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)