3af8a13059
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>
1386 lines
63 KiB
C#
1386 lines
63 KiB
C#
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; }
|
||
}
|
||
}
|