fix(mtconnect): an unanswered write degrades; one success writer for reads and writes (review I-3, m7)

Coordinator ruling on F3: a write with no reply (transport failure / deadline)
sets Degraded with the stamp preserved and a type-only LastError, through a new
_writeDegraded flag folded into PublishHealthy. One success writer is shared:
RecordReadAnswered (a /current answer, an Agent refusal, a live-index read)
clears the read AND write degradations; RecordWriteAnswered clears the write
one; neither clears the other path's stream degradation. Tests both directions
(answered write clears it, good read clears it) plus the converse (an answered
write does not clear a failed read); red with each half removed.
m7: a good /current resets the refusal-dedup latch, so a refusal recurring after a
recovery is logged again (tested).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:31:26 -04:00
parent 31af7acde0
commit b2c064bec3
4 changed files with 144 additions and 16 deletions
@@ -104,4 +104,65 @@ public sealed class MTConnectBatchedWriteTests
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Review I-3 (coordinator ruling): a write that got NO reply degrades health, with the stamp preserved.</summary>
[Fact]
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
{
var (driver, client) = await WritableAsync();
var before = driver.GetHealth();
before.State.ShouldBe(DriverState.Healthy, "positive control: Healthy before the failed write");
client.WriteFailure = new HttpRequestException("connection refused");
var results = await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
results.Single().StatusCode.ShouldBe(BadCommunicationError);
var h = driver.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
h.LastError.ShouldNotBeNull().ShouldContain(nameof(HttpRequestException));
}
/// <summary>The unanswered-write degradation is cleared by the next ANSWERED write (it cannot latch).</summary>
[Fact]
public async Task An_answered_write_clears_an_unanswered_write_degradation()
{
var (driver, client) = await WritableAsync();
client.WriteFailure = new HttpRequestException("connection refused");
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
client.WriteFailure = null;
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>…and by the next good READ (the other direction of the shared health writer).</summary>
[Fact]
public async Task A_good_read_clears_an_unanswered_write_degradation()
{
var (driver, client) = await WritableAsync();
client.WriteFailure = new HttpRequestException("connection refused");
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
(await driver.ReadAsync([PosRaw], Ct)).Single().StatusCode.ShouldBe(Good);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>The converse: a failed READ is not cleared by an answered write (each path clears only what it proves).</summary>
[Fact]
public async Task An_answered_write_does_not_clear_a_failed_read()
{
var (driver, client) = await WritableAsync();
client.CurrentFailure = new HttpRequestException("connection refused");
await driver.ReadAsync([PosRaw], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -228,4 +228,34 @@ public sealed class MTConnectReadPathTests
[Fact]
public void A_garbage_answer_is_not_an_agent_refusal() =>
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
/// <summary>Review m7: a refusal that recurs AFTER a good read is news again and is logged again.</summary>
[Fact]
public async Task A_refusal_recurring_after_a_good_read_is_logged_again()
{
var logger = new RecordingDriverLogger();
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-refusal-again", _ => client, logger);
await driver.InitializeAsync("{}", Ct);
try
{
var refusal = ParseFailure(NoDeviceError);
client.CurrentFailure = refusal;
await driver.ReadAsync(["dev1_pos"], Ct);
await driver.ReadAsync(["dev1_pos"], Ct);
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "positive control: deduplicated while it persists");
client.CurrentFailure = null;
await driver.ReadAsync(["dev1_pos"], Ct);
client.CurrentFailure = refusal;
await driver.ReadAsync(["dev1_pos"], Ct);
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(2);
}
finally
{
client.CurrentFailure = null;
await driver.ShutdownAsync(CancellationToken.None);
}
}
}