fix(mtconnect): back off a /sample re-baseline that resolved nothing; scope Test Connect to the configured device

Driver.MTConnect-001 (Medium): StreamVerdict.Reopen reset the reconnect ladder
unconditionally. The InvalidDataException catch that routes an Agent OUT_OF_RANGE
to a /current re-baseline also catches an unparseable chunk and an over-sized
multipart part - neither of which a re-baseline can resolve - so the pump reopened
and failed with no delay at any point: two HTTP requests and one Warning per
iteration, forever. The Reopen arm now uses the same discriminator the transient
arm does (did THIS connection deliver a chunk?), so a buffer roll behind a working
stream still reopens instantly while a re-baseline that resolved nothing advances
BackoffFor.

Driver.MTConnect-002 (Medium): MTConnectDriverProbe requested {AgentUri}/probe
while MTConnectAgentClient requests {AgentUri}/{DeviceName}/probe, so a mistyped or
since-renamed deviceName showed GREEN on Test Connect and then Faulted the driver
at InitializeAsync (an unknown device path answers MTConnectError under HTTP 200).
The probe's minimal DTO now reads deviceName - the rule is 'which keys compose the
URL', not 'which keys are cheap to read'.

Driver.MTConnect-008 (Low): corrected two stale claims that a since-changed
RawBrowseCommitMapper invalidated (see Driver.MTConnect-003, which remains open and
belongs in AdminUI), and made DiscoverAsync log the options snapshot it
deliberately read once instead of the field.

Tests: 538 pass (534 base + 4 new, each red before its fix); AdminUI.Tests 1466 pass.
This commit is contained in:
Joseph Doherty
2026-08-10 06:51:39 -04:00
parent b990adae55
commit 6c63bf8fab
7 changed files with 239 additions and 33 deletions
@@ -301,6 +301,65 @@ public sealed class MTConnectDriverProbeTests
r.Latency.ShouldBeNull();
}
// ── review follow-up: the probe must request the URL the DRIVER will request ──
/// <summary>
/// <b>Test Connect has to probe the driver's own request path, device scope included.</b>
/// <c>MTConnectAgentClient.BuildRequestUri</c> narrows every read to
/// <c>{AgentUri}/{DeviceName}/probe</c>, so a probe that requested the agent-wide
/// <c>{AgentUri}/probe</c> answers a different question from the one the operator asked. A
/// mistyped or since-renamed <c>deviceName</c> then shows GREEN on Test Connect and Faults the
/// driver at <c>InitializeAsync</c> — the Agent answers that path with an <c>MTConnectError</c>
/// under HTTP 200, which the parser surfaces as <see cref="InvalidDataException"/>.
/// <para>
/// This is a different class from the accepted "reachable ≠ deployable" gap the type
/// documents (a bad tag entry, a non-positive timeout): those do not change the URL, and
/// the whole point of a reachability probe is which endpoint it reaches.
/// </para>
/// </summary>
[Fact]
public async Task Configured_deviceName_scopes_the_probe_request_the_way_the_driver_does()
{
Uri? requested = null;
var probe = ProbeWithHandler((request, _) =>
{
requested = request.RequestUri;
return Task.FromResult(XmlResponse(ValidProbeXml));
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\",\"deviceName\":\"VMC-1\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
requested.ShouldNotBeNull();
requested.AbsolutePath.ShouldBe("/VMC-1/probe");
}
/// <summary>
/// …and with no <c>deviceName</c> the probe stays agent-wide, exactly as the driver does. The
/// falsification control for the test above: unconditionally inserting a segment would pass it
/// while breaking every unscoped config.
/// </summary>
[Fact]
public async Task An_absent_deviceName_leaves_the_probe_agent_wide()
{
Uri? requested = null;
var probe = ProbeWithHandler((request, _) =>
{
requested = request.RequestUri;
return Task.FromResult(XmlResponse(ValidProbeXml));
});
var r = await probe.ProbeAsync(
"{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeTrue();
requested.ShouldNotBeNull();
requested.AbsolutePath.ShouldBe("/probe");
}
// ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
[Fact]
@@ -990,6 +990,78 @@ public sealed class MTConnectSubscribeTests
}
}
/// <summary>
/// <b>A re-baseline that never resolves anything must not reopen without a backoff.</b>
/// <c>StreamVerdict.Reopen</c> is "a protocol event, not a fault", so it reopens immediately
/// and resets the ladder — correct for the two cases it was written for (a sequence gap and an
/// <c>OUT_OF_RANGE</c> rejection), both of which the <c>/current</c> re-baseline genuinely
/// fixes, so they cannot recur.
/// <para>
/// But the <see cref="InvalidDataException"/> catch that routes <c>OUT_OF_RANGE</c> to the
/// re-baseline also catches an <b>unparseable chunk</b> and an oversized part — conditions a
/// <c>/current</c> cannot fix. Reopening those with the ladder reset is an unbounded hot
/// loop against the Agent (one <c>/sample</c> AND one <c>/current</c> per iteration, as fast
/// as they complete, one Warning each), which is exactly what the reconnect backoff exists
/// to prevent everywhere else in this pump.
/// </para>
/// <para>
/// The discriminator is the one the transient path already uses: did THIS connection deliver
/// a chunk? A re-baseline behind a working stream still reopens instantly; one on a
/// connection that has never delivered advances the ladder. Asserted as monotonic growth,
/// never as a duration.
/// </para>
/// </summary>
[Fact]
public async Task A_rebaseline_that_delivers_nothing_still_advances_the_reconnect_backoff()
{
// A 1 ms cap keeps the failing loop from actually sleeping; nothing here asserts a duration.
var options = Opts(new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 1 });
var (driver, client) = await InitializedDriverAsync(options: options);
// Sticky, and NOT a transient stream fault: a chunk this driver can never parse. /current keeps
// answering, so every re-baseline "succeeds" and the pump reopens — forever.
client.SampleFailure = new InvalidDataException("chunk is not a well-formed MTConnectStreams document");
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
try
{
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
var early = driver.ReconnectAttempts;
await client.WaitForSampleCallsAsync(6).WaitAsync(Watchdog, Ct);
early.ShouldBeGreaterThanOrEqualTo(2);
driver.ReconnectAttempts.ShouldBeGreaterThan(early);
}
finally
{
await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct);
}
}
/// <summary>
/// …and the falsification control for the test above: a re-baseline BEHIND a stream that was
/// delivering keeps its immediate reopen. Without this, "advance the ladder on every re-baseline"
/// would pass the test above while making a healthy Agent's ordinary buffer roll cost a backoff.
/// </summary>
[Fact]
public async Task A_rebaseline_after_a_delivered_chunk_still_reopens_immediately()
{
var (driver, client) = await InitializedDriverAsync();
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
// One good chunk, so this connection has provably delivered…
await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct);
// …then the Agent's buffer rolls past the cursor. The re-baseline is a protocol event behind a
// working stream: no backoff, no ladder.
client.CurrentAnswers.Enqueue(Chunk(5000, 5005, ("dev1_pos", "2.5")));
await client.PumpAsync(Chunk(5000, 5005, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct);
driver.ReconnectAttempts.ShouldBe(0);
}
/// <summary>
/// <b>I3 — a non-positive cap is an operator-authorable hot loop.</b> Clamping to it (the
/// obvious <c>Math.Max(0, …)</c>) makes EVERY delay zero, so the pump reconnect-spins as fast