From 3525653c2b95577fcdddc5d741200270dc8f8ac9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 06:03:38 -0400 Subject: [PATCH] fix(grpc): extended-property read parser + GetConnectionStatus over gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistorianTagExtendedPropertyProtocol.ParseResponse: fix the multi-property/ multi-group response shape captured live from the 2023 R2 server. The server returns one group per property (the tag name repeats), each propertyCount=1, and a uint16 searchability-flags trailer per property (0x0003 built-in, 0x0001 user- added) — NOT the single-byte group trailer the old model assumed, which drifted one byte per group and threw "expected 0x09 found 0x01" on any buffer with more than one property. Now reads the per-property uint16 trailer (tolerates a legacy 1-byte tail). Fixes read-back on both WCF and gRPC. Adds GetTagExtendedPropertiesRaw for future captures. - HistorianGrpcStatusClient.GetConnectionStatusAsync (plan #5): synthesize connection status from a measured gRPC handshake (OpenConnection yielding a storage-session GUID => connected), mirroring the WCF synthesize-from-probe approach. Routed in Historian2020ProtocolDialect on UseGrpc (the WCF path used the MDAS binding, which can't reach the gRPC port). - HistorianGrpcSqlClient: record the negative plan-#4 result — a HistoryService. RegisterTags prime does NOT clear the server-side CSrvDbConnection fault (tried live on both 0x402/0x401); the op stays bounded behind ProtocolEvidenceMissingException. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../Grpc/HistorianGrpcSqlClient.cs | 12 +++-- .../Grpc/HistorianGrpcStatusClient.cs | 51 +++++++++++++++++++ .../Grpc/HistorianGrpcTagClient.cs | 25 +++++++++ .../Protocol/Historian2020ProtocolDialect.cs | 7 ++- .../HistorianTagExtendedPropertyProtocol.cs | 40 +++++++++++---- 5 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs index b5bb36d..d48992a 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs @@ -21,10 +21,14 @@ namespace AVEVA.Historian.Client.Grpc; /// System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand. /// This is a server-side CSrvDbConnection (SQL DB-connection) precondition that the pure /// managed gRPC session does not establish — the same class of wall as -/// StorageService.OpenStorageConnection (whose real precondition is the front-door -/// HistoryService.RegisterTags family). Priming Retr.GetV does not clear it. The request -/// framing here is the captured/expected shape; the op stays bounded behind -/// until the DB-connection registration is reproduced. +/// StorageService.OpenStorageConnection. Priming Retr.GetV does not clear it, and +/// a HistoryService.RegisterTags prime does NOT clear it either (tried live 2026-06-22 on +/// both read-only 0x402 and write-enabled 0x401 sessions: RegisterTags itself +/// returned false and ExecuteSqlCommand faulted with the identical native-38 IndexOutOfRange) — +/// so unlike the OpenStorageConnection wall, the SQL DB-connection context is not established by the +/// RegisterTags family. The request framing here is the captured/expected shape; the op stays bounded +/// behind until the DB-connection registration is +/// reproduced. Use the WCF transport for SQL. /// /// internal static class HistorianGrpcSqlClient diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs index 3e67538..ec290b3 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -111,6 +111,57 @@ internal static class HistorianGrpcStatusClient } } + /// + /// Returns a measured connection status over the 2023 R2 gRPC transport (plan #5). Mirrors + /// 's synthesize-from-handshake approach: it opens an + /// authenticated session and reports / + /// from whether the handshake + /// (GetInterfaceVersion → ValidateClientCredential token loop → OpenConnection, which yields the + /// storage-session GUID) succeeds. There is no dedicated connection-status RPC on either transport. + /// Store-forward connectivity is not observable here (D2-gated) and stays false. + /// + public static Task GetConnectionStatusAsync( + HistorianClientOptions options, + CancellationToken cancellationToken) + => Task.Run(() => GetConnectionStatus(options, cancellationToken), cancellationToken); + + private static HistorianConnectionStatus GetConnectionStatus(HistorianClientOptions options, CancellationToken cancellationToken) + { + bool connected; + string? error = null; + try + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + // A successful OpenConnection yields a non-empty storage-session GUID — proof the server and + // its storage session are reachable, the gRPC analog of the WCF handshake probe. + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken); + connected = session.StorageSessionId != Guid.Empty; + if (!connected) + { + error = "OpenConnection returned an empty storage-session handle."; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + connected = false; + error = $"{ex.GetType().Name}: {ex.Message}"; + } + + return new HistorianConnectionStatus( + ServerName: options.Host, + Pending: false, + ErrorOccurred: !connected, + Error: error, + ConnectedToServer: connected, + ConnectedToServerStorage: connected, + ConnectedToStoreForward: false, + ConnectionKind: HistorianConnectionKind.Process); + } + /// /// Reads the Historian server's system time-zone name (roadmap item R1.3, /// StatusService.GetSystemTimeZoneName). Unlike the 2020 WCF surface — where the native diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index e8476b0..79cd32e 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -112,6 +112,31 @@ internal static class HistorianGrpcTagClient return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken); } + /// + /// Issues a single page-0 GetTagExtendedPropertiesFromName call and returns the raw native + /// btTeps response buffer (empty when the server reports no rows / non-success). Internal so + /// reverse-engineering probes can capture the framing. + /// + internal static byte[] GetTagExtendedPropertiesRaw(HistorianClientOptions options, string tag, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken); + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag); + GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName( + new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest + { + StrHandle = session.StringHandle, + BtTagNames = ByteString.CopyFrom(tagNames), + UiSequence = 0 + }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + return (response.Status?.BSuccess ?? false) ? response.BtTeps?.ToByteArray() ?? [] : []; + } + private static IReadOnlyList GetTagExtendedProperties( HistorianClientOptions options, string tag, CancellationToken cancellationToken) { diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index dcc8942..613d820 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -52,7 +52,12 @@ internal sealed class Historian2020ProtocolDialect public Task GetConnectionStatusAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken); + + // Over gRPC (2023 R2) the status is measured from the gRPC handshake (the WCF synthesize-from- + // probe path uses the MDAS binding, which can't reach the gRPC port). Non-gRPC stays on WCF. + return UseGrpc + ? HistorianGrpcStatusClient.GetConnectionStatusAsync(_options, cancellationToken) + : Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken); } public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs index 87296c8..17faf75 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs @@ -25,9 +25,17 @@ namespace AVEVA.Historian.Client.Wcf; /// 1-byte group marker (observed 0x01) + compact-ASCII tag name (0x09 + uint16 byte /// length + ASCII), uint32 propertyCount, then per property a 1-byte marker (observed /// 0x02) + compact-ASCII property name + a CRetVariant value (0x43 VT_BSTR + uint16 -/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed -/// 0x01). Only the string value variant (0x43) is evidence-backed; other variant -/// types throw . +/// payload length + uint16 charCount + UTF-16LE) + a uint16 searchability-flags trailer +/// (Facetable|Searchable|SubstringSearchable; e.g. 0x0003 for a built-in property, +/// 0x0001 for a user-added one — captured live 2026-06-22). Only the string value variant +/// (0x43) is evidence-backed; other variant types throw +/// . +/// +/// Per-property, not per-group: the server returns one group per property (the tag name +/// repeats), each with propertyCount = 1, and the uint16 flags trailer belongs to the +/// property — there is no separate group trailer. An earlier revision modelled the trailer as a +/// single byte after the property block; that happened to parse a single-property buffer (the second +/// flags byte was the buffer's end) but drifted one byte per group on multi-property responses. /// /// The op is sequence-paged: call with sequence = 0, parse the buffer, then re-call /// with the returned sequence until the response carries no rows. Only string-valued properties @@ -219,13 +227,11 @@ internal static class HistorianTagExtendedPropertyProtocol string propertyName = ReadCompactAsciiString(buffer, ref cursor); string value = ReadVariantStringValue(buffer, ref cursor); rows.Add(new HistorianTagExtendedPropertyRow(tagName, propertyName, value)); - } - // 1-byte trailing marker (observed 0x01) after the property block. Read it only if a - // byte remains so a tightly-packed terminal buffer doesn't over-read. - if (cursor < buffer.Length) - { - SkipByte(buffer, ref cursor); + // uint16 searchability-flags trailer for this property (captured live). Tolerate a + // legacy single-byte tail or a tightly-packed terminal buffer so older single-property + // captures still parse. + SkipPropertyTrailer(buffer, ref cursor); } } @@ -275,6 +281,22 @@ internal static class HistorianTagExtendedPropertyProtocol cursor++; } + /// + /// Consumes the per-property uint16 searchability-flags trailer. Consumes 2 bytes when available; + /// tolerates a legacy single-byte tail (consumes 1) so older single-property captures still parse. + /// + private static void SkipPropertyTrailer(ReadOnlySpan buffer, ref int cursor) + { + if (cursor <= buffer.Length - 2) + { + cursor += 2; + } + else if (cursor < buffer.Length) + { + cursor += 1; + } + } + private static ushort ReadUInt16(ReadOnlySpan buffer, ref int cursor) { EnsureAvailable(buffer, cursor, 2);