fix(mtconnect): an Agent refusal of /current is a reply, not a comms fault
Health contract: an MTConnectError document (NO_DEVICE, INVALID_REQUEST, ...) is the Agent answering. The parsers now mark such an InvalidDataException (MTConnectXml.IsAgentRefusal - the type is sealed, so a Data marker, every other handler unchanged); ReadAsync reports the batch Bad but does not degrade the read path, advances LastSuccessfulRead, clears a prior read degradation and warns once per distinct refusal. An unparseable answer stays a comms fault. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -159,4 +159,73 @@ public sealed class MTConnectReadPathTests
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private const string NoDeviceError =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-10-22T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors><Error errorCode="NO_DEVICE">Could not find the device 'VMC-9'</Error></Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
private static Exception ParseFailure(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
MTConnectStreamsParser.Parse(xml);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("expected the parse to fail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health contract: an MTConnectError answer to /current is the Agent REFUSING (it answered) — the batch is Bad,
|
||||
/// but health is restored / kept Healthy with the stamp advanced, and the refusal is logged once, not per read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_agent_refusal_of_current_is_a_reply_not_a_comms_fault()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-refusal", _ => client, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A comms fault first, so the refusal has a Degraded to restore.
|
||||
client.CurrentFailure = new HttpRequestException("connection refused");
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault does degrade");
|
||||
var stamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
var refusal = ParseFailure(NoDeviceError);
|
||||
MTConnectXml.IsAgentRefusal(refusal).ShouldBeTrue();
|
||||
client.CurrentFailure = refusal;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= stamp && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
var first = await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
first.Single().StatusCode.ShouldBe(0x80050000u, "the batch is still Bad");
|
||||
var h = driver.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(stamp);
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "a persistent refusal warns once");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An answer that is not an MTConnect document at all is NOT marked a refusal (it stays a comms fault).</summary>
|
||||
[Fact]
|
||||
public void A_garbage_answer_is_not_an_agent_refusal() =>
|
||||
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user