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
@@ -272,6 +272,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
return answer;
}
/// <summary>Number of connectivity pings (headers-only <c>/probe</c>) issued against this client.</summary>
public int PingCallCount => Volatile.Read(ref _pingCallCount);
private int _pingCallCount;
/// <inheritdoc/>
/// <remarks>Same failure surface as <see cref="ProbeAsync"/> (<see cref="ProbeFailure"/>), counted separately.</remarks>
public Task PingAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _pingCallCount);
return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure;
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
@@ -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);
}
@@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests
await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
}
// -------------------------------------------------------------- MTConnectAgentClient.PingAsync
/// <summary>R06:T-7 — the ping reads the status only: a body that is not a device model does not matter.</summary>
[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<Exception>(async () => await client.ProbeAsync(Ct));
}
/// <summary>A non-success status is an unreachable / refusing Agent, exactly as for the full probe.</summary>
[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<HttpRequestException>(async () => await client.PingAsync(Ct));
}
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
[Fact]