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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user