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:
Joseph Doherty
2026-10-22 16:03:38 -04:00
parent d67634cc4f
commit ac01c88c0e
7 changed files with 137 additions and 4 deletions
@@ -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();
}