fix(worker): classify ack interop failures at the COM call; ignore late completions
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m32s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m38s
ci / portable (push) Failing after 2m52s
ci / portable (pull_request) Failing after 2m53s
ci / windows-x86 (pull_request) Successful in 8m8s
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m32s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m38s
ci / portable (push) Failing after 2m52s
ci / portable (pull_request) Failing after 2m53s
ci / windows-x86 (pull_request) Successful in 8m8s
Address review of the #164-#166 fixes: - The HRESULT check could not tell interop from programming errors (a managed ArgumentException already defaults to 0x80070057). Wrap each MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow filter that converts an interop failure to a private marker exception, and catch only that marker, so exceptions from the writer's own code (argument checks, caching, the pump) propagate. - A late OnWriteComplete for a dropped handle recreated the cache entry that DropItem removed. The writer now records completions only for item handles it holds on its live registration. - The item-handle-reuse probe now asserts that handles within one registration strictly increase, so a rerun is a regression check. Refs #164, #165, #166 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -187,24 +187,6 @@ public sealed class AlarmAckWriterTests
|
||||
Assert.DoesNotContain("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithEInvalidArg_ReturnsAuthenticationFailed()
|
||||
{
|
||||
// The HRESULT is what the narrowed filter keys on; set it explicitly so the
|
||||
// test does not depend on ArgumentException's default HResult.
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new HResultArgumentException("bad credentials", unchecked((int)0x80070057)),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentNullException_Propagates()
|
||||
{
|
||||
@@ -220,16 +202,17 @@ public sealed class AlarmAckWriterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentExceptionWithOtherHResult_Propagates()
|
||||
public void Acknowledge_WhenOwnCodeOutsideComCallThrowsArgumentException_Propagates()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new HResultArgumentException("not interop", unchecked((int)0x80131500)),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
// The pump runs between Advise and Write. An ArgumentException from it carries
|
||||
// the same default HRESULT (0x80070057) as an interop E_INVALIDARG, so only
|
||||
// catching at the COM call itself keeps it from being reported as WriteRejected.
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => throw new ArgumentException("bug"), TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
Assert.Throws<HResultArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
||||
ArgumentException thrown = Assert.Throws<ArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
||||
Assert.Equal("bug", thrown.Message);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -353,6 +336,24 @@ public sealed class AlarmAckWriterTests
|
||||
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateCompletionForDroppedHandle_DoesNotRecreateCacheEntry()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway");
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed
|
||||
server.OnWrite = (handle, item) => writer.RecordWriteCompletion(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101, applied
|
||||
|
||||
writer.RecordWriteCompletion(7, 100, Rows(MxStatusCategory.Ok, 0)); // 100's late completion
|
||||
writer.RecordWriteCompletion(8, 101, Rows(MxStatusCategory.Ok, 0)); // another registration
|
||||
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
||||
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_PastCacheCap_ForgetsEvictedHandleCompletion()
|
||||
{
|
||||
@@ -424,16 +425,6 @@ public sealed class AlarmAckWriterTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ArgumentException with an explicit HRESULT (the setter is protected on net48).</summary>
|
||||
internal sealed class HResultArgumentException : ArgumentException
|
||||
{
|
||||
public HResultArgumentException(string message, int hresult)
|
||||
: base(message)
|
||||
{
|
||||
HResult = hresult;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingAckServer : IMxAccessServer
|
||||
{
|
||||
private int nextItemHandle = 100;
|
||||
|
||||
@@ -46,6 +46,9 @@ public sealed class ItemHandleReuseProbeTests
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly List<string> log = new List<string>();
|
||||
|
||||
// Item handles returned on the first registration, in the order they were handed out.
|
||||
private readonly List<int> firstRegistrationHandles = new List<int>();
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ItemHandleReuseProbeTests"/> class.</summary>
|
||||
/// <param name="output">Test output helper for logging.</param>
|
||||
public ItemHandleReuseProbeTests(ITestOutputHelper output)
|
||||
@@ -76,6 +79,16 @@ public sealed class ItemHandleReuseProbeTests
|
||||
{
|
||||
throw new InvalidOperationException("Probe failed: " + threadException.Message, threadException);
|
||||
}
|
||||
|
||||
// The ack writer's drop-and-re-add mitigation depends on this: within one
|
||||
// registration a handle number is never handed out twice.
|
||||
Assert.Equal((2 * Rounds) + 5, firstRegistrationHandles.Count);
|
||||
for (int i = 1; i < firstRegistrationHandles.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
firstRegistrationHandles[i] > firstRegistrationHandles[i - 1],
|
||||
$"Item handle {firstRegistrationHandles[i]} did not increase past {firstRegistrationHandles[i - 1]}; MXAccess reused or reordered item handles within one registration.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
@@ -90,7 +103,7 @@ public sealed class ItemHandleReuseProbeTests
|
||||
// A: the AlarmAckWriter drop path (Advise, then UnAdvise + RemoveItem).
|
||||
for (int i = 0; i < Rounds; i++)
|
||||
{
|
||||
int h = mx.AddItem(server, AckItem);
|
||||
int h = Held(mx.AddItem(server, AckItem));
|
||||
mx.Advise(server, h);
|
||||
Log($"A{i}: AddItem+Advise {AckItem} -> {h}");
|
||||
mx.UnAdvise(server, h);
|
||||
@@ -101,28 +114,28 @@ public sealed class ItemHandleReuseProbeTests
|
||||
// B: RemoveItem without Advise.
|
||||
for (int i = 0; i < Rounds; i++)
|
||||
{
|
||||
int h = mx.AddItem(server, AckItem);
|
||||
int h = Held(mx.AddItem(server, AckItem));
|
||||
Log($"B{i}: AddItem {AckItem} -> {h}");
|
||||
mx.RemoveItem(server, h);
|
||||
Log($"B{i}: RemoveItem {h}");
|
||||
}
|
||||
|
||||
// C: interleave another item to see whether a freed number is handed to any item.
|
||||
int c1 = mx.AddItem(server, AckItem);
|
||||
int c1 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"C: AddItem {AckItem} -> {c1}");
|
||||
mx.RemoveItem(server, c1);
|
||||
Log($"C: RemoveItem {c1}");
|
||||
int c2 = mx.AddItem(server, OtherItem);
|
||||
int c2 = Held(mx.AddItem(server, OtherItem));
|
||||
Log($"C: AddItem {OtherItem} -> {c2}");
|
||||
int c3 = mx.AddItem(server, AckItem);
|
||||
int c3 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"C: AddItem {AckItem} -> {c3}");
|
||||
mx.RemoveItem(server, c2);
|
||||
mx.RemoveItem(server, c3);
|
||||
Log($"C: RemoveItem {c2}, {c3}");
|
||||
|
||||
// D: the same item added twice while the first is still held.
|
||||
int d1 = mx.AddItem(server, AckItem);
|
||||
int d2 = mx.AddItem(server, AckItem);
|
||||
int d1 = Held(mx.AddItem(server, AckItem));
|
||||
int d2 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"D: AddItem {AckItem} twice while held -> {d1}, {d2}");
|
||||
mx.RemoveItem(server, d1);
|
||||
mx.RemoveItem(server, d2);
|
||||
@@ -159,6 +172,12 @@ public sealed class ItemHandleReuseProbeTests
|
||||
}
|
||||
}
|
||||
|
||||
private int Held(int itemHandle)
|
||||
{
|
||||
firstRegistrationHandles.Add(itemHandle);
|
||||
return itemHandle;
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line);
|
||||
|
||||
Reference in New Issue
Block a user