graccesscli: fix attribute value reader (typed IMxValue + IgnoreCase)

The MxValueDetails reader was the root of the false "writeback gap"
diagnosis. Three problems compounded:

1. ReadProperty(attr, "Value") used case-sensitive late-binding via
   InvokeMember. The COM-side property is exposed as `value` (lowercase)
   on IAttribute, so the lookup silently returned null.

2. Even if the MxValue was retrieved, the accessor probe loop
   (Invoke(value, "GetBoolean") -> "GetInteger" -> ... -> "GetString")
   relied on IDispatch late-binding. The MxValue interface's accessors
   are vtable-only — they aren't reachable through IDispatch — so every
   call threw, and the reader reported "no supported scalar accessor
   succeeded" even when the typed accessor would have returned the
   value cleanly.

3. The probe loop ordering put GetString last, so for any value that
   somehow survived the type errors silently, an earlier accessor could
   have returned a wrong-typed result.

Fix:

- Add `BindingFlags.IgnoreCase` to ReadProperty + Invoke. COM IDispatch
  is case-insensitive at the IDL level; .NET InvokeMember is not unless
  explicitly told to be. Comment explains the trap.
- AttributeValueDetails now uses typed `attr.value` (and falls back to
  the same lookup via obj.ConfigurableAttributes when the runtime proxy
  returns null), bypassing the late-binding gap entirely.
- MxValueDetails casts to IMxValue and dispatches on
  GetDataType().ToString(), calling the typed scalar accessor for the
  declared type (GetBoolean / GetInteger / GetFloat / GetDouble /
  GetString / GetElapsedTime). MxBigString and MxInternationalizedString
  share the GetString path. MxTime is left as a follow-up; its
  VBFILETIME field layout differs across interop builds.
- Replaced the misleading "Attribute value is not exposed" / "no
  scalar accessor succeeded" messages with specific diagnostics that
  identify the actual failure (wrong type cast, typed accessor exception,
  unhandled MxDataType).

Live verification on $DelmiaReceiver.ProcessRecipe.DeclarationsText:

  BEFORE fix: `attribute value get` → Supported: false, Value: null,
              "Attribute value is not exposed by this GRAccess attribute."
  AFTER fix:  `attribute value get` → Supported: true, DataType: MxString,
              Value: "// roundtrip-test marker (graccesscli scripts set
              --field DeclarationsText)\n"

The reader now correctly surfaces the value graccesscli's writes have
been persisting all along. This is the gap that produced the "writeback
gap" mirage in the previous round of investigation.

Tests still 66/66.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 21:50:16 -04:00
parent c52d8d0171
commit 842b94fb39
@@ -995,6 +995,20 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
?? throw new InvalidOperationException($"Attribute '{attributeName}' was not found.");
}
private static object TryReadAttributeValue(IAttribute attr)
{
if (attr == null) return null;
try { return attr.value; }
catch { return null; }
}
private static string SafeAttributeName(IAttribute attr)
{
if (attr == null) return string.Empty;
try { return attr.Name ?? string.Empty; }
catch { return string.Empty; }
}
private static IAttribute TryFindAttribute(IAttributes attributes, string attributeName)
{
if (attributes == null)
@@ -1340,7 +1354,25 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
private static object AttributeValueDetails(IGalaxy galaxy, IgObject obj, IAttribute attr)
{
var unavailable = new List<LlmUnavailableField>();
var direct = MxValueDetails(ReadProperty(attr, "Value"));
// Use the typed `IAttribute.value` accessor. For ScriptExtension /
// PackageOnly fields the IAttribute proxy must come from
// `ConfigurableAttributes`, not the runtime `Attributes` snapshot —
// the runtime view often returns a stub whose `.value` is null even
// when the persisted value is set. Re-look-up from
// ConfigurableAttributes by name when needed; fall back to whatever
// `attr` was passed in if the configurable lookup fails.
object rawMxValue = TryReadAttributeValue(attr);
if (rawMxValue == null)
{
var name = SafeAttributeName(attr);
if (!string.IsNullOrEmpty(name))
{
var cfgAttr = TryFindAttribute(obj.ConfigurableAttributes, name);
if (cfgAttr != null && !ReferenceEquals(cfgAttr, attr))
rawMxValue = TryReadAttributeValue(cfgAttr);
}
}
var direct = MxValueDetails(rawMxValue);
var package = new PackageSnapshot();
var packageValue = (PackageAttributeValue)null;
@@ -1384,45 +1416,65 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
if (value == null)
return new { Supported = false, Value = (object)null, Unavailable = "Attribute value is not exposed by this GRAccess attribute." };
var dataType = ReadString(value, "DataType");
var isArray = ReadString(value, "IsArray");
if (string.Equals(isArray, "True", StringComparison.OrdinalIgnoreCase))
// The MxValue COM interface is vtable-only — its `GetString` /
// `GetInteger` / etc. accessors are NOT reachable via IDispatch
// late-binding. We must cast to the typed `IMxValue` interface.
// This was the root cause of the earlier "writeback gap" mirage:
// every read returned "not exposed" because `Invoke(value, "GetString")`
// threw, never because the underlying value was actually missing.
var mx = value as IMxValue;
if (mx == null)
return new { Supported = false, Value = (object)null, Unavailable = $"Value object is not an IMxValue (got {value.GetType().FullName})." };
string dataType;
try { dataType = mx.GetDataType().ToString(); }
catch { dataType = string.Empty; }
// Array detection via typed call. `GetDimensionSize` returns 0 for
// scalars and >0 for arrays (with Dim1 reflecting element count).
int dimSize = 0;
try { mx.GetDimensionSize(out dimSize); } catch { }
var isArray = dimSize > 0;
if (isArray)
return new { Supported = false, DataType = dataType, IsArray = true, Value = (object)null, Unavailable = "Array and complex MxValue readback is not yet implemented." };
// Dispatch on the typed DataType enum. Names mirror the
// `MxDataType` enum in `ArchestrA.GRAccess.dll`.
try
{
var elapsed = (VB_LARGE_INTEGER)Invoke(value, "GetElapsedTime");
return new
switch (dataType)
{
Supported = true,
DataType = dataType,
IsArray = false,
Value = ElapsedTimeToInt64(elapsed),
Unit = "100ns"
};
case "MxBoolean":
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetBoolean() };
case "MxInteger":
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetInteger() };
case "MxFloat":
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetFloat() };
case "MxDouble":
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetDouble() };
case "MxString":
case "MxInternationalizedString":
case "MxBigString":
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetString() };
case "MxTime":
// MxTime returns a VBFILETIME via out-param; field
// layout differs across interop builds. Skip the
// typed read for now; consumers that need timestamps
// can fall through to package-fallback reads.
return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = "MxTime readback is not yet implemented for this dispatcher path." };
case "MxElapsedTime":
{
var e = mx.GetElapsedTime();
return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)ElapsedTimeToInt64(e), Unit = "100ns" };
}
}
}
catch
catch (Exception ex)
{
// Try the next scalar accessor; GRAccess throws when the accessor does not match.
return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = $"Typed accessor for {dataType} threw: {ex.GetType().Name}: {ex.Message}" };
}
foreach (var read in new[] { "GetBoolean", "GetInteger", "GetFloat", "GetDouble", "GetString" })
{
try
{
return new
{
Supported = true,
DataType = dataType,
IsArray = false,
Value = Invoke(value, read)
};
}
catch
{
// Try the next scalar accessor; GRAccess throws when the accessor does not match.
}
}
return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = $"No typed accessor for MxDataType {dataType}." };
return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = "No supported scalar accessor succeeded for this MxValue." };
}
@@ -1929,11 +1981,18 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
return $"{operation}: {results.CompletelySuccessful}";
}
// COM IDispatch is case-insensitive at runtime, but .NET's `InvokeMember`
// is not unless `BindingFlags.IgnoreCase` is set. Some IAttribute proxies
// expose properties under a lowercase name (e.g. `value` on `IAttribute`)
// while siblings expose CamelCase (e.g. `DataType` on `IMxValue`). Without
// IgnoreCase a `ReadProperty(attr, "Value")` lookup silently returns null
// for one and works for the other — the gap that masked all script-body
// writes as "no-ops" in earlier versions of `MxValueDetails`.
private static object Invoke(object target, string method, params object[] parameters)
{
return target.GetType().InvokeMember(
method,
BindingFlags.InvokeMethod,
BindingFlags.InvokeMethod | BindingFlags.IgnoreCase,
null,
target,
parameters);
@@ -1944,7 +2003,12 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess
if (target == null || string.IsNullOrWhiteSpace(property)) return null;
try
{
return target.GetType().InvokeMember(property, BindingFlags.GetProperty, null, target, null);
return target.GetType().InvokeMember(
property,
BindingFlags.GetProperty | BindingFlags.IgnoreCase,
null,
target,
null);
}
catch
{