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:
@@ -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)
|
||||
{
|
||||
|
||||
+17
-13
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user