fix(worker): handle AckMsg add/advise/write COM failures and server death
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m55s
ci / java (push) Failing after 2m2s
ci / portable (push) Failing after 2m17s

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:25:43 -04:00
parent 7900df3dce
commit 8199553734
2 changed files with 135 additions and 10 deletions
@@ -93,6 +93,68 @@ public sealed class AlarmAckWriterTests
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
}
[Fact]
public void Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice());
server.AdviseException = null;
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome);
Assert.Equal(unchecked((int)0x80004005), first.NativeStatus);
Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal);
Assert.Contains("RemoveItem", server.Operations);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
writer.Acknowledge("T.A.AckMsg", "one", Alice());
server.WriteException = new COMException("x", unchecked((int)0x80004005));
AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice());
server.WriteException = null;
writer.Acknowledge("T.A.AckMsg", "three", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenWriteDisconnected_TearsDownServer()
{
// The test seam has no COM factory, so re-registration itself is not
// exercised here; this verifies the teardown that makes it possible.
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", unchecked((int)0x80010108)) };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
Assert.Contains("Unregister", server.Operations);
}
[Fact]
public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(-1, result.NativeStatus);
}
[Fact]
public void AlarmAckOperator_ToString_OmitsPassword()
{
@@ -110,6 +172,10 @@ internal sealed class RecordingAckServer : IMxAccessServer
public Exception? AuthenticateException { get; set; }
public Exception? AdviseException { get; set; }
public Exception? WriteException { get; set; }
public string? LastPassword { get; private set; }
public Action<int, int>? OnWrite { get; set; }
@@ -132,7 +198,14 @@ internal sealed class RecordingAckServer : IMxAccessServer
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem");
public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise");
public void Advise(int serverHandle, int itemHandle)
{
Operations.Add("Advise");
if (AdviseException is not null)
{
throw AdviseException;
}
}
public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise");
@@ -143,6 +216,11 @@ internal sealed class RecordingAckServer : IMxAccessServer
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
Operations.Add("Write:" + value + ":" + userId);
if (WriteException is not null)
{
throw WriteException;
}
OnWrite?.Invoke(serverHandle, itemHandle);
}