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:
@@ -34,9 +34,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
/// </summary>
|
||||
public const int DefaultMaxCachedItems = 256;
|
||||
|
||||
/// <summary>E_INVALIDARG, which the interop raises as ArgumentException.</summary>
|
||||
private const int EInvalidArg = unchecked((int)0x80070057);
|
||||
|
||||
private readonly IMxAccessComObjectFactory? factory;
|
||||
private readonly MxAccessWriteCompletionCache completionCache;
|
||||
private readonly Action pumpStep;
|
||||
@@ -48,6 +45,11 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
|
||||
// Most-recently-used first; the tail is the eviction candidate.
|
||||
private readonly LinkedList<AckItem> itemLru = new LinkedList<AckItem>();
|
||||
|
||||
// Item handles currently added on this registration. A completion for any
|
||||
// other handle (a dropped one whose OnWriteComplete arrives late) is ignored
|
||||
// so it cannot recreate a completion-cache entry for a dead handle.
|
||||
private readonly HashSet<int> heldItemHandles = new HashSet<int>();
|
||||
private object? comObject;
|
||||
private LMXProxyServerClass? comEventSource;
|
||||
private IMxAccessServer? server;
|
||||
@@ -107,9 +109,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
|
||||
// Resolve every argument before the interop try blocks: their catch
|
||||
// filters accept E_INVALIDARG ArgumentExceptions as MXAccess failures, so
|
||||
// an argument error raised by this code must fail before reaching them.
|
||||
if (ackOperator.UserName is null) throw new ArgumentException("Operator user name is required.", nameof(ackOperator));
|
||||
string verifyUser = ackOperator.VerifyUser;
|
||||
string password = ackOperator.Password ?? string.Empty;
|
||||
@@ -120,12 +119,10 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
int userId;
|
||||
try
|
||||
{
|
||||
userId = mx.AuthenticateUser(serverHandle, verifyUser, password);
|
||||
userId = Interop("AuthenticateUser", () => mx.AuthenticateUser(serverHandle, verifyUser, password));
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
catch (AckInteropException ex)
|
||||
{
|
||||
// Bad credentials surface as ArgumentException (E_INVALIDARG) from the
|
||||
// interop, not only COMException, so classify by the carried HRESULT.
|
||||
int authHresult = ex.HResult;
|
||||
if (IsServerGone(authHresult))
|
||||
{
|
||||
@@ -156,7 +153,6 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
int itemHandle = 0;
|
||||
LinkedListNode<AckItem>? node;
|
||||
bool cached = itemHandles.TryGetValue(itemAddress, out node);
|
||||
string step = "AddItem";
|
||||
try
|
||||
{
|
||||
if (cached && node!.Value.Dirty)
|
||||
@@ -185,23 +181,20 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
}
|
||||
else
|
||||
{
|
||||
itemHandle = mx.AddItem(serverHandle, itemAddress);
|
||||
itemHandle = Interop("AddItem", () => mx.AddItem(serverHandle, itemAddress));
|
||||
node = CacheItem(mx, itemAddress, itemHandle);
|
||||
step = "Advise";
|
||||
int added = itemHandle;
|
||||
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
|
||||
mx.Advise(serverHandle, itemHandle);
|
||||
Interop("Advise", () => mx.Advise(serverHandle, added));
|
||||
}
|
||||
|
||||
pumpStep();
|
||||
step = "Write";
|
||||
baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle);
|
||||
mx.Write(serverHandle, itemHandle, commentValue, userId);
|
||||
int target = itemHandle;
|
||||
Interop("Write", () => mx.Write(serverHandle, target, commentValue, userId));
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
catch (AckInteropException ex)
|
||||
{
|
||||
// The interop maps E_INVALIDARG to ArgumentException (and
|
||||
// E_ACCESSDENIED to UnauthorizedAccessException), so the HRESULT, not
|
||||
// the exception type, is what identifies the MXAccess failure.
|
||||
int hresult = ex.HResult;
|
||||
if (itemHandles.TryGetValue(itemAddress, out LinkedListNode<AckItem>? failed))
|
||||
{
|
||||
@@ -217,7 +210,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.WriteRejected,
|
||||
hresult,
|
||||
$"AckMsg {step} failed (HRESULT 0x{hresult:X8}).");
|
||||
$"AckMsg {ex.Step} failed (HRESULT 0x{hresult:X8}).");
|
||||
}
|
||||
|
||||
ulong baseline = baselineVersion;
|
||||
@@ -262,28 +255,52 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exceptions the MXAccess COM interop raises for a failed HRESULT: the
|
||||
/// runtime maps well-known HRESULTs to typed exceptions (E_INVALIDARG to
|
||||
/// ArgumentException, E_ACCESSDENIED to UnauthorizedAccessException) and
|
||||
/// the rest to COMException. Each carries the HRESULT in Exception.HResult.
|
||||
/// Runs one MXAccess COM call and converts a failure the interop raised
|
||||
/// for its HRESULT into <see cref="AckInteropException"/>. Only the COM
|
||||
/// call itself runs inside the filter, so an exception thrown by this
|
||||
/// writer's own code (argument checks, caching, the pump) propagates as
|
||||
/// a bug instead of being reported as an MXAccess failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ArgumentException arm exists because a wrong password or unknown
|
||||
/// The runtime maps well-known HRESULTs to typed exceptions and the rest
|
||||
/// to COMException; each carries the HRESULT in Exception.HResult. The
|
||||
/// ArgumentException arm is needed because a wrong password or unknown
|
||||
/// user makes <c>AuthenticateUser</c> throw ArgumentException with
|
||||
/// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19. It
|
||||
/// is narrowed to that HRESULT, and excludes ArgumentNullException and
|
||||
/// ArgumentOutOfRangeException, so a programming error inside the try
|
||||
/// blocks propagates as a bug instead of being reported as an MXAccess
|
||||
/// authentication or write failure. The interop never raises those two
|
||||
/// subtypes for a COM HRESULT.
|
||||
/// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19.
|
||||
/// ArgumentNullException and ArgumentOutOfRangeException are excluded:
|
||||
/// the interop does not raise them for an HRESULT, so one escaping the
|
||||
/// call is an argument bug in the adapter, not an MXAccess failure.
|
||||
/// </remarks>
|
||||
private static T Interop<T>(string step, Func<T> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return call();
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
throw new AckInteropException(step, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Interop(string step, Action call)
|
||||
{
|
||||
try
|
||||
{
|
||||
call();
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
throw new AckInteropException(step, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInteropFailure(Exception ex) =>
|
||||
ex is ExternalException
|
||||
|| ex is UnauthorizedAccessException
|
||||
|| (ex is ArgumentException
|
||||
&& ex is not ArgumentNullException
|
||||
&& ex is not ArgumentOutOfRangeException
|
||||
&& ex.HResult == EInvalidArg);
|
||||
&& ex is not ArgumentOutOfRangeException);
|
||||
|
||||
private static bool IsServerGone(int hresult) =>
|
||||
hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED
|
||||
@@ -315,6 +332,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
|
||||
itemHandles.Clear();
|
||||
itemLru.Clear();
|
||||
heldItemHandles.Clear();
|
||||
server = null;
|
||||
comEventSource = null;
|
||||
object? toRelease = comObject;
|
||||
@@ -335,6 +353,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
|
||||
LinkedListNode<AckItem> node = itemLru.AddFirst(new AckItem(itemAddress, itemHandle));
|
||||
itemHandles[itemAddress] = node;
|
||||
heldItemHandles.Add(itemHandle);
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -343,6 +362,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
{
|
||||
itemHandles.Remove(node.Value.Address);
|
||||
itemLru.Remove(node);
|
||||
heldItemHandles.Remove(node.Value.Handle);
|
||||
try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
|
||||
@@ -397,7 +417,37 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
{
|
||||
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
|
||||
rows.AddRange(statusConverter.ConvertMany(pVars));
|
||||
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
|
||||
RecordWriteCompletion(hLMXServerHandle, phItemHandle, rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a write completion only for an item handle this writer
|
||||
/// currently holds on its live registration. A late completion for a
|
||||
/// dropped handle would otherwise recreate the cache entry that
|
||||
/// <see cref="DropItem"/> removed. Internal so tests can stand in for
|
||||
/// the COM event.
|
||||
/// </summary>
|
||||
internal void RecordWriteCompletion(int completionServerHandle, int itemHandle, RepeatedField<MxStatusProxy> rows)
|
||||
{
|
||||
if (server is null || completionServerHandle != serverHandle || !heldItemHandles.Contains(itemHandle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
completionCache.Record(completionServerHandle, itemHandle, rows);
|
||||
}
|
||||
|
||||
/// <summary>An MXAccess failure raised by one COM call, carrying that call's HRESULT.</summary>
|
||||
private sealed class AckInteropException : Exception
|
||||
{
|
||||
public AckInteropException(string step, Exception inner)
|
||||
: base("MXAccess " + step + " failed.", inner)
|
||||
{
|
||||
Step = step;
|
||||
HResult = inner.HResult;
|
||||
}
|
||||
|
||||
public string Step { get; }
|
||||
}
|
||||
|
||||
/// <summary>A cached ack item. Plain class (not a record) for the net48 worker target.</summary>
|
||||
|
||||
Reference in New Issue
Block a user