Files
histsdk/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
T
Joseph Doherty 3af8a13059 D2 (revision-write): probe SysTimeSec — same gate, narrower scope
Extended the harness with --write-revision-target-tag <name> (overrides
the value's TagKey via SQL lookup) and --write-revision-skip-validate
(passes false to AddNonStreamedValue's `validate` boolean). Added
--write-revision-commit gate so the harness validates without actually
calling SendValues by default — important when targeting system tags.

Probed SysTimeSec (wwTagKey=12, server-cache-resident system tag):
- AddNonStreamedValue: ErrorCode=TagNotFoundInCache (129) — same failure
- With validate=false: same failure (the cache check is intrinsic, not
  gated by the boolean)

Conclusion: the gate is per-(client-session, tag), not per-server-cache.
Even tags the SERVER cache knows about are rejected because the LIBRARY
maintains a separate per-connection tag list that AddNonStreamedValue
checks. That list isn't populated by knowing the wwTagKey alone — it
needs whatever mechanism (RegisterTags2 / read flow side effect / IO
server registration) that we haven't reverse-engineered.

The revision-write path remains architecturally blocked for managed
clients. Plan doc updated with the SysTimeSec finding.

177/177 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:27:58 -04:00

1386 lines
63 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Web.Script.Serialization;
namespace AVEVA.Historian.NativeTraceHarness;
internal static class Program
{
private static int Main(string[] args)
{
string repoRoot = FindRepoRoot();
Directory.SetCurrentDirectory(repoRoot);
string tagName = GetArg(args, "--tag") ?? "OtOpcUaParityTest_001.Counter";
string serverName = GetArg(args, "--server-name") ?? "localhost";
int tcpPort = int.TryParse(GetArg(args, "--tcp-port"), out int parsedTcpPort) ? parsedTcpPort : 32568;
int lookbackMinutes = int.TryParse(GetArg(args, "--lookback-minutes"), out int parsedLookback) ? parsedLookback : 1440;
int maxRows = int.TryParse(GetArg(args, "--max-rows"), out int parsedMaxRows) ? parsedMaxRows : 1;
int waitSeconds = int.TryParse(GetArg(args, "--connection-wait-seconds"), out int parsedWait) ? parsedWait : 15;
int preLoadSleepSeconds = int.TryParse(GetArg(args, "--pre-load-sleep-seconds"), out int parsedPreLoadSleep) ? parsedPreLoadSleep : 0;
int preOpenSleepSeconds = int.TryParse(GetArg(args, "--pre-open-sleep-seconds"), out int parsedPreOpenSleep) ? parsedPreOpenSleep : 0;
int preStartSleepSeconds = int.TryParse(GetArg(args, "--pre-start-sleep-seconds"), out int parsedPreStartSleep) ? parsedPreStartSleep : 0;
string scenario = GetArg(args, "--scenario") ?? "history";
string retrievalModeName = GetArg(args, "--retrieval-mode") ?? "Full";
bool directConnection = HasFlag(args, "--direct-connection");
bool integratedSecurity = !HasFlag(args, "--no-integrated-security");
string? proxyServer = GetArg(args, "--proxy-server");
string? runtimeMethodPointerOutput = GetArg(args, "--runtime-method-pointer-output");
string runtimeMethodPointerFilters = GetArg(args, "--runtime-method-pointer-filters")
?? "StartDataQuery;StartQuery;GetNextRow;StartEventQuery";
ulong resolutionTicks = ulong.TryParse(GetArg(args, "--resolution-ticks"), out ulong parsedResolutionTicks) ? parsedResolutionTicks : 0;
DateTime endUtc = TryParseUtc(GetArg(args, "--end-utc")) ?? DateTime.UtcNow;
DateTime startUtc = TryParseUtc(GetArg(args, "--start-utc")) ?? endUtc.AddMinutes(-lookbackMinutes);
string current = Path.GetFullPath(GetArg(args, "--current-dir") ?? Path.Combine(repoRoot, "current"));
string managedDll = Path.GetFullPath(GetArg(args, "--managed-dll-path") ?? Path.Combine(current, "aahClientManaged.dll"));
if (!File.Exists(managedDll))
{
throw new FileNotFoundException("Missing aahClientManaged.dll.", managedDll);
}
if (!Directory.Exists(current))
{
throw new DirectoryNotFoundException($"Missing dependency folder: {current}");
}
AppDomain.CurrentDomain.AssemblyResolve += (_, eventArgs) =>
{
AssemblyName name = new(eventArgs.Name);
string candidate = Path.Combine(current, name.Name + ".dll");
return File.Exists(candidate) ? Assembly.LoadFrom(candidate) : null!;
};
Directory.CreateDirectory(Path.Combine(repoRoot, "docs", "reverse-engineering"));
TryDelete(Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog"));
TraceSource diagnosticProbe = new("System.ServiceModel");
diagnosticProbe.TraceInformation("NativeTraceHarness diagnostics probe");
diagnosticProbe.Flush();
Directory.SetCurrentDirectory(current);
if (preLoadSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preLoadSleepSeconds));
}
Assembly assembly = Assembly.LoadFrom(managedDll);
string? methodPointerFilter = GetArg(args, "--dump-method-pointers");
if (methodPointerFilter is not null)
{
Console.WriteLine(Serialize(DumpRuntimeMethodPointers(assembly, methodPointerFilter)));
return 0;
}
Type accessType = GetType(assembly, "ArchestrA.HistorianAccess");
Type connectionArgsType = GetType(assembly, "ArchestrA.HistorianConnectionArgs");
Type connectionStatusType = GetType(assembly, "ArchestrA.HistorianConnectionStatus");
Type connectionType = GetType(assembly, "ArchestrA.HistorianConnectionType");
Type historyQueryArgsType = GetType(assembly, "ArchestrA.HistoryQueryArgs");
Type eventQueryArgsType = GetType(assembly, "ArchestrA.EventQueryArgs");
Type tagQueryArgsType = GetType(assembly, "ArchestrA.TagQueryArgs");
Type eventQueryTypeType = GetType(assembly, "ArchestrA.HistorianEventQueryType");
Type eventOrderType = GetType(assembly, "ArchestrA.HistorianEventOrder");
Type errorType = GetType(assembly, "ArchestrA.HistorianAccessError");
Type retrievalModeType = GetType(assembly, "ArchestrA.HistorianRetrievalMode");
object access = Activator.CreateInstance(accessType)!;
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process"));
if (directConnection)
{
SetProperty(connectionArgs, "DirectConnection", true);
SetField(connectionArgs, "directConnection", true);
}
if (!string.IsNullOrWhiteSpace(proxyServer))
{
SetProperty(connectionArgs, "ProxyServer", proxyServer!);
}
Dictionary<string, object?> snapshots = [];
if (preOpenSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preOpenSleepSeconds));
}
object openError = Activator.CreateInstance(errorType)!;
MethodInfo openMethod = accessType.GetMethod("OpenConnection", new[] { connectionArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.OpenConnection");
object?[] openArgs = [connectionArgs, openError];
bool openSuccess = (bool)openMethod.Invoke(access, openArgs)!;
openError = openArgs[1]!;
snapshots["ConnectionArgs"] = SnapshotObject(connectionArgs);
snapshots["AccessAfterOpen"] = SnapshotObject(access);
ConnectionStatusSnapshot status = WaitForConnection(access, accessType, connectionStatusType, waitSeconds);
bool startSuccess = false;
object? startError = null;
string? startQueryException = null;
string? moveTerminalDescription = null;
List<object> rows = [];
if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario))
{
object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["EventQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(eventQueryArgsType)!;
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "EventCount", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "QueryType", Enum.Parse(eventQueryTypeType, "Events"));
SetProperty(queryArgs, "EventOrder", Enum.Parse(eventOrderType, "Ascending"));
snapshots["EventQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { eventQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-event-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["EventQueryAfterStart"] = SnapshotObject(query);
snapshots["EventQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["EventQueryAfterFirstMove"] = SnapshotObject(query);
snapshots["EventResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
EventTime = FormatDateProperty(result, "EventTime"),
ReceivedTime = FormatDateProperty(result, "ReceivedTime"),
EventType = TryGetPropertyValue(result, "EventType"),
Type = TryGetPropertyValue(result, "Type"),
DisplayText = TryGetPropertyValue(result, "DisplayText"),
Area = TryGetPropertyValue(result, "Area"),
Source = TryGetPropertyValue(result, "Source"),
System = TryGetPropertyValue(result, "System"),
Severity = TryGetPropertyValue(result, "Severity"),
Priority = TryGetPropertyValue(result, "Priority"),
IsAlarm = TryGetPropertyValue(result, "IsAlarm")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer && IsWriteScenario(scenario))
{
// Per docs/plans/write-commands-reverse-engineering.md safety §1, refuse to run
// unless the sandbox tag name is whitelisted.
string sandboxTag = GetArg(args, "--write-sandbox-tag") ?? "RetestSdkWriteSandbox";
if (!sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Write scenario refuses to run against tags whose name doesn't start with 'RetestSdkWrite'. Pass --write-sandbox-tag RetestSdkWriteSandbox.");
}
string writeDataTypeName = GetArg(args, "--write-data-type") ?? "Float";
double writeValue = double.TryParse(GetArg(args, "--write-value"), out double parsedValue) ? parsedValue : 42.5;
double writeMinEu = double.TryParse(GetArg(args, "--write-min-eu"), out double parsedMinEu) ? parsedMinEu : 0.0;
double writeMaxEu = double.TryParse(GetArg(args, "--write-max-eu"), out double parsedMaxEu) ? parsedMaxEu : 100.0;
double writeMinRaw = double.TryParse(GetArg(args, "--write-min-raw"), out double parsedMinRaw) ? parsedMinRaw : 0.0;
double writeMaxRaw = double.TryParse(GetArg(args, "--write-max-raw"), out double parsedMaxRaw) ? parsedMaxRaw : 100.0;
// --write-skip-add-tag lets the value-only second pass run without re-creating
// the sandbox. The connection's tag cache is bound at OpenConnection time, so the
// server-cache refresh after a fresh AddTag requires a NEW process / connection.
bool skipAddTag = HasFlag(args, "--write-skip-add-tag");
bool skipAddValue = HasFlag(args, "--write-skip-add-value");
bool writeApplyScaling = HasFlag(args, "--write-apply-scaling");
string writeStorageTypeName = GetArg(args, "--write-storage-type") ?? "Cyclic";
// Decoded via dnlib — actual enum field types on HistorianTag:
// set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType
// set_TagStorageType stfld ArchestrA.HistorianStorageType HistorianTag::tagStorageType
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
Type dataValueType = GetType(assembly, "ArchestrA.HistorianDataValue");
Type dataValueDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type connectionIndexEnum = GetType(assembly, "ArchestrA.ConnectionIndex");
// Build HistorianTag for the sandbox.
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", sandboxTag);
SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw);
SetProperty(tag, "MaxRaw", writeMaxRaw);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", writeApplyScaling);
uint tagKey = 0;
if (!skipAddTag)
{
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.AddTag");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tag");
object?[] addTagArgs = [tag, tagKey, addError];
bool addTagSuccess = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tagKey = (uint)addTagArgs[1]!;
addError = addTagArgs[2]!;
snapshots["TagAfterAddTag"] = SnapshotObject(tag);
snapshots["AddTagError"] = SnapshotObject(addError);
rows.Add(new
{
Kind = "AddTag",
Success = addTagSuccess,
TagKey = tagKey,
ErrorDescription = GetPropertyText(addError, "ErrorDescription"),
});
}
// ALWAYS look up the real wwTagKey from SQL — AddTag returns a synthetic
// placeholder key (~10000000) when the tag is freshly created, but the server
// session cache only recognizes the real Runtime.dbo.Tag.wwTagKey value
// (small int). Using the synthetic key in AddStreamedValue causes server-side
// error 168 "Tag not added to server".
using (System.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;"))
{
sql.Open();
using System.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? result = cmd.ExecuteScalar();
if (result is int existingKey)
{
uint realKey = (uint)existingKey;
if (realKey != tagKey)
{
rows.Add(new { Kind = "TagKeyOverride", Synthetic = tagKey, RealFromSql = realKey });
tagKey = realKey;
}
}
using System.Data.SqlClient.SqlCommand analogCmd = sql.CreateCommand();
analogCmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
analogCmd.Parameters.AddWithValue("@t", sandboxTag);
using System.Data.SqlClient.SqlDataReader analogReader = analogCmd.ExecuteReader();
if (analogReader.Read())
{
rows.Add(new
{
Kind = "AnalogTagPersisted",
TagName = sandboxTag,
MinEU = analogReader.IsDBNull(0) ? (object)"<null>" : analogReader.GetDouble(0),
MaxEU = analogReader.IsDBNull(1) ? (object)"<null>" : analogReader.GetDouble(1),
MinRaw = analogReader.IsDBNull(2) ? (object)"<null>" : analogReader.GetDouble(2),
MaxRaw = analogReader.IsDBNull(3) ? (object)"<null>" : analogReader.GetDouble(3),
Scaling = analogReader.IsDBNull(4) ? (object)"<null>" : analogReader.GetValue(4),
InputApplyScaling = writeApplyScaling,
});
}
}
// Server cache may not pick up new tags immediately. Allow a wait between AddTag
// and AddStreamedValue so the server side has time to add the new tag to its
// in-memory cache. Configurable via --write-resync-wait-seconds (default 8).
int resyncWait = int.TryParse(GetArg(args, "--write-resync-wait-seconds"), out int w) ? w : 8;
if (resyncWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(resyncWait));
}
// Build HistorianDataValue + push it (drives AddS2 on the wire).
if (tagKey != 0 && !skipAddValue)
{
object value = Activator.CreateInstance(dataValueType)!;
SetProperty(value, "TagKey", tagKey);
SetProperty(value, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(value, "OpcQuality", (ushort)192); // Good
SetProperty(value, "Value", writeValue);
SetProperty(value, "StartDateTime", DateTime.UtcNow);
SetProperty(value, "EndDateTime", DateTime.UtcNow);
SetProperty(value, "ApplyScaling", false);
// The public AddStreamedValue overloads (per dnlib) are:
// 0x0600618C — public instance, locals(HistorianDataValue, DateTime)
// 0x0600618D — public instance, no locals (simplest dispatcher)
// The 0x0600618E impl is private and not reachable by reflection.
// Pick the public instance overload whose parameters are
// (HistorianDataValue, [bool/DateTime], HistorianAccessError&) — the
// 0x0600618D dispatcher matches 3 params.
MethodInfo addValueMethod = accessType.GetMethods()
.Where(m => m.Name == "AddStreamedValue")
.OrderBy(m => m.GetParameters().Length)
.First(m =>
{
ParameterInfo[] ps = m.GetParameters();
return ps.Length >= 2 && ps[0].ParameterType == dataValueType
&& ps[ps.Length - 1].ParameterType.IsByRef
&& ps[ps.Length - 1].ParameterType.GetElementType() == errorType;
});
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-value");
object addValueError = Activator.CreateInstance(errorType)!;
ParameterInfo[] addValueParams = addValueMethod.GetParameters();
object?[] addValueArgs = new object?[addValueParams.Length];
addValueArgs[0] = value;
for (int i = 1; i < addValueParams.Length - 1; i++)
{
Type pt = addValueParams[i].ParameterType;
addValueArgs[i] = pt == typeof(bool) ? false
: pt == typeof(DateTime) ? DateTime.UtcNow
: pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addValueArgs[addValueParams.Length - 1] = addValueError;
bool addValueSuccess = (bool)addValueMethod.Invoke(access, addValueArgs)!;
addValueError = addValueArgs[addValueArgs.Length - 1]!;
snapshots["ValueAfterAddStreamedValue"] = SnapshotObject(value);
snapshots["AddValueError"] = SnapshotObject(addValueError);
rows.Add(new
{
Kind = "AddStreamedValue",
Success = addValueSuccess,
Value = writeValue,
ErrorDescription = GetPropertyText(addValueError, "ErrorDescription"),
});
}
// --write-revision-values triggers the revision (non-streamed) write path.
// Calls SendValues with a HistorianDataValueList populated via
// NonStreamedValuesBegin / AddNonStreamedValue / AddNonStreamedValuesEnd.
// Captures whatever happens — success, server-side rejection, or client
// gate — for protocol decoding purposes.
if (HasFlag(args, "--write-revision-values"))
{
try
{
Type dataValueListType = GetType(assembly, "ArchestrA.HistorianDataValueList");
Type dataCategoryEnum = GetType(assembly, "ArchestrA.HistorianDataCategory");
// Use HistorianAccess.CreateHistorianDataValueList — the public factory that
// binds the list to the live HistorianClient* via GetClient(ConnectionIndex).
MethodInfo createListMethod = accessType.GetMethods()
.Where(m => m.Name == "CreateHistorianDataValueList")
.OrderBy(m => m.GetParameters().Length)
.First();
rows.Add(new
{
Kind = "CreateHistorianDataValueListSig",
Params = createListMethod.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}").ToArray(),
});
rows.Add(new
{
Kind = "EnumValues",
DataCategory = Enum.GetValues(dataCategoryEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
});
// Pick non-zero values where appropriate. Common AVEVA convention:
// HistorianDataCategory.Real or Process is typically the first non-zero entry
// ConnectionIndex.Process is the first non-zero entry
object?[] createListArgs = createListMethod.GetParameters().Select(p =>
{
if (p.ParameterType == dataCategoryEnum)
{
// Prefer first declared (likely Process or Real)
return Enum.GetValues(dataCategoryEnum).Cast<object>().FirstOrDefault();
}
if (p.ParameterType == connectionIndexEnum)
{
return Enum.Parse(connectionIndexEnum, "Process", ignoreCase: true);
}
return p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null;
}).ToArray();
object listInstance = createListMethod.Invoke(access, createListArgs)!;
snapshots["DataValueListBeforeBegin"] = SnapshotObject(listInstance);
System.Reflection.BindingFlags allInstance =
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance;
MethodInfo beginMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "NonStreamedValuesBegin");
object?[] beginArgs = beginMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object beginResult = beginMethod.Invoke(listInstance, beginArgs)!;
rows.Add(new
{
Kind = "NonStreamedValuesBegin",
Result = beginResult?.ToString(),
ParameterCount = beginArgs.Length,
});
// Optional override: target a different tag (e.g. SysTimeSec) by name.
// Used to investigate whether the cache gate is per-tag (i.e. tags
// already in the runtime cache pass validation while client-created
// sandbox tags don't).
string? targetTagOverride = GetArg(args, "--write-revision-target-tag");
uint revTagKey = tagKey;
if (!string.IsNullOrWhiteSpace(targetTagOverride))
{
using System.Data.SqlClient.SqlConnection sqlT = new("Server=.;Database=Runtime;Integrated Security=SSPI;");
sqlT.Open();
using System.Data.SqlClient.SqlCommand cmdT = sqlT.CreateCommand();
cmdT.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmdT.Parameters.AddWithValue("@t", targetTagOverride);
object? overrideKey = cmdT.ExecuteScalar();
if (overrideKey is int k)
{
revTagKey = (uint)k;
rows.Add(new { Kind = "RevisionTargetTagOverride", Tag = targetTagOverride, TagKey = revTagKey });
}
else
{
rows.Add(new { Kind = "RevisionTargetTagOverrideNotFound", Tag = targetTagOverride });
}
}
// Build a single HistorianDataValue for the revision sample.
object revValue = Activator.CreateInstance(dataValueType)!;
SetProperty(revValue, "TagKey", revTagKey);
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(revValue, "OpcQuality", (ushort)192);
SetProperty(revValue, "Value", writeValue);
SetProperty(revValue, "StartDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "EndDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "ApplyScaling", false);
MethodInfo[] addMethodCandidates = dataValueListType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "AddNonStreamedValueCandidates",
Count = addMethodCandidates.Length,
Sigs = addMethodCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo addMethod = addMethodCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
// --write-revision-skip-validate flips the bool to false to see if the
// cache gate is enforced inside this function or elsewhere downstream.
bool validateFlag = !HasFlag(args, "--write-revision-skip-validate");
object addError0 = Activator.CreateInstance(errorType)!;
object?[] addArgs = new object?[addMethod.GetParameters().Length];
addArgs[0] = revValue;
for (int i = 1; i < addArgs.Length - 1; i++)
{
Type pt = addMethod.GetParameters()[i].ParameterType;
addArgs[i] = pt == typeof(bool) ? validateFlag : pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addArgs[addArgs.Length - 1] = addError0;
object addResult = addMethod.Invoke(listInstance, addArgs)!;
object addErrorAfter = addArgs[addArgs.Length - 1]!;
rows.Add(new
{
Kind = "AddNonStreamedValue",
Result = addResult?.ToString(),
ErrorDescription = GetPropertyText(addErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(addErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(addErrorAfter, "ErrorType"),
});
MethodInfo endListMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "AddNonStreamedValuesEnd");
object?[] endListArgs = endListMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object endListResult = endListMethod.Invoke(listInstance, endListArgs)!;
rows.Add(new { Kind = "AddNonStreamedValuesEnd", Result = endListResult?.ToString() });
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
// Safety: require explicit --write-revision-commit to actually fire
// SendValues. Without it, the harness validates the path (cache gate,
// value validation) but does NOT push anything to the wire. Important
// when targeting system tags via --write-revision-target-tag.
bool commitRevision = HasFlag(args, "--write-revision-commit");
if (!commitRevision)
{
rows.Add(new { Kind = "RevisionSendValuesSkipped", Reason = "Pass --write-revision-commit to actually call SendValues." });
goto skipSendValues;
}
// SendValues drives the on-the-wire revision flow:
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
// → SendNonStreamedValues (the actual WCF push).
object sendError = Activator.CreateInstance(errorType)!;
MethodInfo[] sendValuesCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "SendValues")
.ToArray();
rows.Add(new
{
Kind = "SendValuesCandidates",
Count = sendValuesCandidates.Length,
Sigs = sendValuesCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo sendValuesMethod = sendValuesCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValueList list, HistorianAccessError& error)
object?[] sendArgs = new object?[sendValuesMethod.GetParameters().Length];
sendArgs[0] = listInstance;
for (int i = 1; i < sendArgs.Length - 1; i++)
{
Type pt = sendValuesMethod.GetParameters()[i].ParameterType;
sendArgs[i] = pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
sendArgs[sendArgs.Length - 1] = sendError;
bool sendSuccess = (bool)sendValuesMethod.Invoke(access, sendArgs)!;
sendError = sendArgs[sendArgs.Length - 1]!;
rows.Add(new
{
Kind = "SendValues",
Success = sendSuccess,
SignatureParamCount = sendArgs.Length,
ErrorDescription = GetPropertyText(sendError, "ErrorDescription"),
ErrorCode = GetPropertyText(sendError, "ErrorCode"),
ErrorType = GetPropertyText(sendError, "ErrorType"),
});
snapshots["SendValuesError"] = SnapshotObject(sendError);
skipSendValues:;
}
catch (Exception ex)
{
rows.Add(new { Kind = "RevisionFlowException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// DeleteTags runs independently of AddStreamedValue success (write-RE
// sandbox cleanup); guarded by --write-delete-after to keep the default
// run non-destructive.
if (HasFlag(args, "--write-delete-after"))
{
Type tagStatusType = GetType(assembly, "ArchestrA.HistorianTagStatus");
Type tagStatusListType = GetType(assembly, "ArchestrA.HistorianTagStatusList");
object tagsToDelete = Activator.CreateInstance(tagStatusListType)!;
object tagStatus = Activator.CreateInstance(tagStatusType)!;
SetProperty(tagStatus, "TagName", sandboxTag);
MethodInfo addItem = tagStatusListType.GetMethod("Add", new[] { tagStatusType })
?? throw new MissingMethodException("HistorianTagStatusList.Add");
addItem.Invoke(tagsToDelete, [tagStatus]);
object deleteError = Activator.CreateInstance(errorType)!;
MethodInfo deleteMethod = accessType.GetMethods().First(m =>
m.Name == "DeleteTags" && m.GetParameters().Length == 2);
object?[] deleteArgs = [tagsToDelete, deleteError];
bool deleteSuccess = (bool)deleteMethod.Invoke(access, deleteArgs)!;
deleteError = deleteArgs[1]!;
rows.Add(new
{
Kind = "DeleteTags",
Success = deleteSuccess,
ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
});
}
}
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))
{
object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["TagQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(tagQueryArgsType)!;
SetProperty(queryArgs, "TagFilter", tagName);
SetProperty(queryArgs, "CacheTagInfo", true);
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", false);
snapshots["TagQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethods().First(method =>
method.Name == "StartQuery" && method.GetParameters().Length == 3);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-tag-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, 0u, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
uint tagCount = startArgs[1] is uint count ? count : 0;
startError = startArgs[2];
snapshots["TagQueryAfterStart"] = SnapshotObject(query);
snapshots["TagQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
uint requestedRows = checked((uint)Math.Max(maxRows, 1));
MethodInfo? getTagNamesMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagNames" && method.GetParameters().Length == 4);
if (getTagNamesMethod is not null)
{
object tagNamesError = Activator.CreateInstance(errorType)!;
object?[] tagNameArgs = [0u, requestedRows, null, tagNamesError];
bool namesSuccess = (bool)getTagNamesMethod.Invoke(query, tagNameArgs)!;
tagNamesError = tagNameArgs[3]!;
rows.Add(new
{
Kind = "TagNames",
Success = namesSuccess,
ErrorDescription = GetPropertyText(tagNamesError, "ErrorDescription"),
Names = ToSerializableValue(tagNameArgs[2])
});
}
MethodInfo? getTagInfoMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagInfo" && method.GetParameters().Length == 4);
if (getTagInfoMethod is not null)
{
object tagInfoError = Activator.CreateInstance(errorType)!;
object?[] tagInfoArgs = [0u, requestedRows, null, tagInfoError];
bool infoSuccess = (bool)getTagInfoMethod.Invoke(query, tagInfoArgs)!;
tagInfoError = tagInfoArgs[3]!;
rows.Add(new
{
Kind = "TagInfo",
Success = infoSuccess,
ErrorDescription = GetPropertyText(tagInfoError, "ErrorDescription"),
Tags = SummarizeTagList(tagInfoArgs[2])
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer)
{
object query = accessType.GetMethod("CreateHistoryQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["QueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(historyQueryArgsType)!;
StringCollection tags = [];
tags.Add(tagName);
SetProperty(queryArgs, "TagNames", tags);
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "BatchSize", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "RetrievalMode", Enum.Parse(retrievalModeType, retrievalModeName, ignoreCase: true));
if (resolutionTicks > 0)
{
SetProperty(queryArgs, "Resolution", resolutionTicks);
}
snapshots["QueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { historyQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-history-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["QueryAfterStart"] = SnapshotObject(query);
snapshots["QueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["QueryAfterFirstMove"] = SnapshotObject(query);
snapshots["QueryResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
StartDateTime = ((DateTime)GetPropertyValue(result, "StartDateTime")!).ToString("O"),
EndDateTime = ((DateTime)GetPropertyValue(result, "EndDateTime")!).ToString("O"),
Quality = GetPropertyValue(result, "Quality"),
OpcQuality = GetPropertyValue(result, "OpcQuality"),
QualityDetail = GetPropertyValue(result, "QualityDetail"),
Value = GetPropertyValue(result, "Value"),
PercentGood = GetPropertyValue(result, "PercentGood")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
if (openSuccess)
{
object closeError = Activator.CreateInstance(errorType)!;
MethodInfo? closeMethod = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() });
if (closeMethod is not null)
{
object?[] closeArgs = [closeError];
_ = closeMethod.Invoke(access, closeArgs);
}
}
if (access is IDisposable disposableAccess)
{
disposableAccess.Dispose();
}
Trace.Flush();
string tracePath = Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog");
var output = new
{
Operation = "NativeTraceHarness.IntegratedRead",
Scenario = scenario,
ServerName = serverName,
DirectConnection = directConnection,
ProxyServer = proxyServer,
TagName = tagName,
LookbackMinutes = lookbackMinutes,
RetrievalMode = retrievalModeName,
ResolutionTicks = resolutionTicks,
StartUtc = startUtc.ToString("O"),
EndUtc = endUtc.ToString("O"),
OpenSuccess = openSuccess,
OpenErrorType = GetPropertyText(openError, "ErrorType"),
OpenErrorCode = GetPropertyText(openError, "ErrorCode"),
OpenErrorDescription = GetPropertyText(openError, "ErrorDescription"),
status.ConnectedToServer,
status.Pending,
status.ErrorOccurred,
StartQuerySuccess = startSuccess,
StartQueryErrorType = GetPropertyText(startError, "ErrorType"),
StartQueryErrorCode = GetPropertyText(startError, "ErrorCode"),
StartQueryErrorDescription = GetPropertyText(startError, "ErrorDescription"),
StartQueryException = startQueryException,
MoveTerminalDescription = moveTerminalDescription,
RowCount = rows.Count,
Rows = rows,
Snapshots = snapshots,
TracePath = tracePath,
TraceExists = File.Exists(tracePath),
TraceBytes = File.Exists(tracePath) ? new FileInfo(tracePath).Length : 0
};
Console.WriteLine(Serialize(output));
return openSuccess && startSuccess ? 0 : 1;
}
private static ConnectionStatusSnapshot WaitForConnection(object access, Type accessType, Type statusType, int waitSeconds)
{
MethodInfo method = accessType.GetMethod("GetConnectionStatus", new[] { statusType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.GetConnectionStatus");
DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(waitSeconds, 1));
ConnectionStatusSnapshot snapshot;
do
{
object status = Activator.CreateInstance(statusType)!;
object?[] args = [status];
_ = method.Invoke(access, args);
status = args[0]!;
snapshot = new ConnectionStatusSnapshot(
(bool)GetPropertyValue(status, "ConnectedToServer")!,
(bool)GetPropertyValue(status, "Pending")!,
(bool)GetPropertyValue(status, "ErrorOccurred")!);
if ((snapshot.ConnectedToServer && !snapshot.Pending) || snapshot.ErrorOccurred || (!snapshot.ConnectedToServer && !snapshot.Pending))
{
return snapshot;
}
Thread.Sleep(250);
} while (DateTime.UtcNow < deadline);
return snapshot;
}
private static IReadOnlyList<object> DumpRuntimeMethodPointers(Assembly assembly, string filter)
{
List<object> results = [];
BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
LoadedModuleInfo? moduleInfo = FindLoadedModule(Path.GetFileName(assembly.Location));
foreach (MethodInfo method in assembly.ManifestModule.GetMethods(flags))
{
AddRuntimeMethodPointer(results, "<Module>", method, filter, moduleInfo);
}
foreach (Type type in assembly.GetTypes())
{
foreach (MethodInfo method in type.GetMethods(flags))
{
AddRuntimeMethodPointer(results, type.FullName ?? type.Name, method, filter, moduleInfo);
}
}
return results;
}
private static void WriteRuntimeMethodPointerSnapshot(
Assembly assembly,
string? outputPath,
string filtersText,
string repoRoot,
string scenario,
string phase)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return;
}
string resolvedPath = Path.IsPathRooted(outputPath!)
? outputPath!
: Path.Combine(repoRoot, outputPath!);
string? directory = Path.GetDirectoryName(resolvedPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
List<object> methodPointers = [];
foreach (string rawFilter in filtersText.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
{
string filter = rawFilter.Trim();
if (filter.Length == 0)
{
continue;
}
methodPointers.Add(new
{
Filter = filter,
Methods = DumpRuntimeMethodPointers(assembly, filter)
});
}
var snapshot = new
{
Operation = "NativeTraceHarness.RuntimeMethodPointerSnapshot",
ProcessId = Process.GetCurrentProcess().Id,
Scenario = scenario,
Phase = phase,
TimestampUtc = DateTime.UtcNow.ToString("O"),
AssemblyPath = assembly.Location,
MethodPointers = methodPointers
};
File.WriteAllText(resolvedPath, Serialize(snapshot));
}
private static void AddRuntimeMethodPointer(List<object> results, string declaringType, MethodInfo method, string filter, LoadedModuleInfo? moduleInfo)
{
string fullName = declaringType + "." + method.Name;
if (fullName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
string? pointer = null;
string? prepareError = null;
try
{
RuntimeHelpers.PrepareMethod(method.MethodHandle);
long pointerValue = method.MethodHandle.GetFunctionPointer().ToInt64();
pointer = "0x" + pointerValue.ToString("X");
bool pointerInModule = moduleInfo is not null && pointerValue >= moduleInfo.BaseAddress && pointerValue < moduleInfo.EndAddress;
long? pointerRva = pointerInModule ? pointerValue - moduleInfo!.BaseAddress : null;
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = pointerInModule,
FunctionPointerRva = pointerRva.HasValue ? "0x" + pointerRva.Value.ToString("X") : null,
PrepareError = prepareError
});
return;
}
catch (Exception ex)
{
prepareError = FormatException(ex);
}
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = false,
FunctionPointerRva = (string?)null,
PrepareError = prepareError
});
}
private static LoadedModuleInfo? FindLoadedModule(string moduleName)
{
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (string.Equals(module.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase))
{
return new LoadedModuleInfo(module.BaseAddress.ToInt64(), module.ModuleMemorySize);
}
}
return null;
}
private sealed class LoadedModuleInfo
{
public LoadedModuleInfo(long baseAddress, int size)
{
BaseAddress = baseAddress;
Size = size;
}
public long BaseAddress { get; }
public int Size { get; }
public long EndAddress => BaseAddress + Size;
}
private static string Serialize(object value)
{
return new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(value);
}
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(directory))
{
if (File.Exists(Path.Combine(directory, "Histsdk.slnx")))
{
return directory!;
}
directory = Directory.GetParent(directory)?.FullName;
}
return Directory.GetCurrentDirectory();
}
private static string? GetArg(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static bool HasFlag(string[] args, string name)
{
foreach (string arg in args)
{
if (arg.Equals(name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static DateTime? TryParseUtc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!DateTime.TryParse(
value,
null,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out DateTime parsed))
{
throw new ArgumentException("Invalid UTC timestamp: " + value);
}
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
private static Type GetType(Assembly assembly, string name)
{
return assembly.GetType(name, throwOnError: true)!;
}
private static void SetProperty(object target, string name, object value)
{
PropertyInfo? property = target.GetType().GetProperty(name);
if (property is not null && property.CanWrite)
{
MethodInfo? setter = property.GetSetMethod(nonPublic: true);
if (setter is not null)
{
setter.Invoke(target, [value]);
}
}
}
private static void SetField(object target, string name, object value)
{
FieldInfo? field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field is not null)
{
field.SetValue(target, value);
}
}
private static object? GetPropertyValue(object target, string name)
{
PropertyInfo? property = target.GetType().GetProperty(name);
return property is null || !property.CanRead ? null : property.GetValue(target);
}
private static object? TryGetPropertyValue(object target, string name)
{
return TryRead(() => GetPropertyValue(target, name));
}
private static string? FormatDateProperty(object target, string name)
{
object? value = TryGetPropertyValue(target, name);
return value is DateTime dateTime ? dateTime.ToString("O") : value?.ToString();
}
private static string? GetPropertyText(object? target, string name)
{
if (target is null)
{
return null;
}
return GetPropertyValue(target, name)?.ToString();
}
private static string FormatException(Exception ex)
{
return ex.GetType().Name + ": " + ex.Message;
}
private static bool IsEventScenario(string scenario)
{
return scenario.Equals("event", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("events", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTagScenario(string scenario)
{
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tags", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-query", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWriteScenario(string scenario)
{
return scenario.Equals("write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("writes", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, object?> SnapshotObject(object target)
{
Dictionary<string, object?> snapshot = new(StringComparer.OrdinalIgnoreCase);
BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (FieldInfo field in target.GetType().GetFields(flags))
{
if (ShouldSkipMember(field.Name))
{
continue;
}
snapshot["field:" + field.Name] = TryRead(() => ToSerializableValue(field.GetValue(target)));
}
foreach (PropertyInfo property in target.GetType().GetProperties(flags))
{
if (!property.CanRead || property.GetIndexParameters().Length != 0 || ShouldSkipMember(property.Name))
{
continue;
}
snapshot["property:" + property.Name] = TryRead(() => ToSerializableValue(property.GetValue(target)));
}
return snapshot;
}
private static bool ShouldSkipMember(string name)
{
return name.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("user", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("security", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static object? TryRead(Func<object?> read)
{
try
{
return read();
}
catch (Exception ex)
{
return "<unreadable:" + ex.GetType().Name + ">";
}
}
private static object? ToSerializableValue(object? value)
{
if (value is null)
{
return null;
}
Type type = value.GetType();
if (type.IsEnum)
{
return value.ToString();
}
if (value is DateTime dateTime)
{
return dateTime.ToString("O");
}
if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal)
{
return value;
}
if (value is StringCollection strings)
{
List<string> result = [];
foreach (string? item in strings)
{
if (item is not null)
{
result.Add(item);
}
}
return result;
}
if (value is Array array)
{
List<object?> result = [];
int count = Math.Min(array.Length, 8);
for (int i = 0; i < count; i++)
{
result.Add(ToSerializableValue(array.GetValue(i)));
}
if (array.Length > count)
{
result.Add("<truncated:" + array.Length + ">");
}
return result;
}
return "<" + type.FullName + ">";
}
private static List<object?> SummarizeTagList(object? tagList)
{
List<object?> result = [];
if (tagList is null)
{
return result;
}
object? lengthValue = TryGetPropertyValue(tagList, "Length") ?? TryGetPropertyValue(tagList, "Count");
int length = Convert.ToInt32(lengthValue);
int count = Math.Min(length, 8);
MethodInfo? itemMethod = tagList.GetType().GetMethods().FirstOrDefault(method =>
method.Name == "Item" && method.GetParameters().Length == 1);
for (int i = 0; i < count; i++)
{
object? tag = null;
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)i)]);
}
catch
{
tag = null;
}
if (tag is null)
{
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)(i + 1))]);
}
catch
{
tag = null;
}
}
result.Add(tag is null ? null : new
{
TagName = TryGetPropertyValue(tag, "TagName"),
TagDescription = TryGetPropertyValue(tag, "TagDescription"),
EngineeringUnit = TryGetPropertyValue(tag, "EngineeringUnit"),
TagKey = TryGetPropertyValue(tag, "TagKey"),
TagDataType = TryGetPropertyValue(tag, "TagDataType"),
TagStorageType = TryGetPropertyValue(tag, "TagStorageType"),
SourceTag = TryGetPropertyValue(tag, "SourceTag"),
SourceServer = TryGetPropertyValue(tag, "SourceServer"),
IsInActive = TryGetPropertyValue(tag, "IsInActive")
});
}
if (length > count)
{
result.Add("<truncated:" + length + ">");
}
return result;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best-effort cleanup; WCF may still hold the previous trace file.
}
}
private sealed class ConnectionStatusSnapshot
{
public ConnectionStatusSnapshot(bool connectedToServer, bool pending, bool errorOccurred)
{
ConnectedToServer = connectedToServer;
Pending = pending;
ErrorOccurred = errorOccurred;
}
public bool ConnectedToServer { get; }
public bool Pending { get; }
public bool ErrorOccurred { get; }
}
}