R1.5 GetTagExtendedPropertiesAsync (GetTepByNm) + R1.6 closed (no op)

Ship tag extended-property reads over the 2020 WCF aa/Retr/GetTepByNm op:
HistorianClient.GetTagExtendedPropertiesAsync(tag) -> name/value pairs.

String-handle op reached with the Open2 storage-session GUID formatted
uppercase (same format that unlocked GETRP/GETHI/ExeC). Routed via the
name-based native path (GetTagExtendedPropertiesByName, server-fetch flag),
not the index-based TagQuery path.

Evidence-backed findings from the capture:
- GetTepByNm (and GetTgByNm) succeed with the uppercase handle -- further
  validates the resolved string-handle wall.
- QTB (StartTagQuery) does NOT punch through: captured uppercase, it still
  fails server-side (CMdServer::StartActiveTagnamesQuery over the
  aahMetadataServer pipe) -- a metadata-server blocker, not handle format.
- R1.6 (localized properties) has NO distinct op (only error-message/UI-text
  localization in the managed client); collapses into R1.5. Closed, not throwing.

Wire format (golden-pinned, synthetic bytes -- no dev tag names committed):
- request tagNames = uint count + per-name(uint charCount + UTF-16)
- response = uint tagCount + per-tag(marker + compact-ASCII name +
  uint propCount + per-prop(marker + compact-ASCII name + 0x43 VT_BSTR value)
  + trailer); sequence-paged.

Adds: HistorianTagExtendedProperty model, HistorianTagExtendedPropertyProtocol
(codec), HistorianWcfTagExtendedPropertyClient (orchestration), dialect +
public API; golden WcfTagExtendedPropertyProtocolTests (4) + gated live test
(HISTORIAN_TEP_TAG). Tooling: Capture-TagExtendedProperties.ps1,
decode-tag-properties-capture.py, harness tag-extended-properties scenario.
Docs: wcf-tag-extended-properties.md; roadmap R1.5 DONE / R1.6 collapsed;
wall doc + memory updated with the QTB-server-side nuance. 228 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-20 22:52:07 -04:00
parent 4da5287d01
commit 108220c36b
13 changed files with 897 additions and 8 deletions
+9 -5
View File
@@ -98,17 +98,21 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
> (and R1.1) take a **`string` GUID handle**; the earlier "code 1/51 blocked" verdict came from
> passing the Open2 storage GUID in .NET's default **lowercase**. Sent **uppercase**
> (`storageSessionId.ToString("D").ToUpperInvariant()`) the same handle works: **GETRP** (R1.2,
> shipped), **GETHI** (R1.4) and **ExeC** (R1.1) are all live-verified reachable. R1.5/R1.6
> (GetTepByNm family) + QTB/QTG are very likely reachable the same way (not yet individually
> re-probed). Full analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner).
> shipped), **GETHI** (R1.4) and **ExeC** (R1.1) are all live-verified reachable, and **R1.5
> `GetTepByNm`** is now **shipped + live-verified** (`GetTagExtendedPropertiesAsync`). **R1.6 has no
> distinct op** (collapses into R1.5). Note: `QTB` (StartTagQuery) does **not** punch through — it
> fails *server-side* (`CMdServer::StartActiveTagnamesQuery` over the `aahMetadataServer` pipe),
> independent of handle format, so the index-based property/query paths stay blocked here. Full
> analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner) and
> `docs/reverse-engineering/wcf-tag-extended-properties.md`.
> R1.8/R1.9 (StartQuery summary/state modes) are `uint`-handle and were already reachable.
### 1b. Bounded (decode one `bytes` payload; SM each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ✅ **REACHABLE (2026-06-20, live-probed)** via the uppercase storage GUID — `GETHI` returns data (`StringHandleProbeDiagnosticTests`). The version-keyed request returns `uint charCount + UTF-16`; the full info struct (incl. `EventStorageMode`@514) needs its own request capture. **Public API not yet shipped.** | uppercase string handle |
| R1.5 | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | 🟡 **Likely reachable** via uppercase string handle (GetTepByNm family) — not yet individually re-probed. TEP result buffer. | uppercase string handle |
| R1.6 | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | 🟡 **Likely reachable** (same family) — not yet re-probed. | uppercase string handle |
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle |
| ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — |
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
| R1.8 | Analog-summary query | `Retrieval.StartQuery` (summary mode) | summary row layout — **`uint`-handle, reachable. Scoped + decode targets located** (`CAnalogSummaryValue.UnpackFromValueBuffer`, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
| R1.9 | State-summary query | `Retrieval.StartQuery` (state mode) | state-summary row layout — **`uint`-handle, reachable. Scoped** (`CStateSummaryStruct`: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
@@ -17,8 +17,18 @@
> `scripts/Capture-RuntimeParam.ps1`, `scripts/Capture-ExecSql.ps1`. The handle for ExeC/GetR is the
> **same** Open2 storage-session GUID (confirmed = `outBuff[5..21]`). The original analysis below is
> retained for history; treat its "blocked" conclusions as **superseded** — the only missing piece
> was the uppercase format. R1.5/R1.6 (GetTepByNm family) and QTB/QTG are very likely reachable the
> same way but have not yet been individually re-probed.
> was the uppercase format.
>
> **Update 2026-06-20 — R1.5 `GetTepByNm` shipped; QTB nuance.** `GetTagExtendedPropertiesFromName`
> (`GetTepByNm`) is now **shipped + live-verified** with the uppercase handle
> (`GetTagExtendedPropertiesAsync`; see `wcf-tag-extended-properties.md`). It confirms the
> string-handle Retrieval family is reachable (and `GetTgByNm`/GetTagInfosFromName was observed
> succeeding alongside it). **But not every string-handle op is just a format fix:** `QTB`
> (`StartTagQuery`) was captured being sent with a correctly-**uppercase** handle and still failed
> with `error 1` *server-side* (`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over
> `\\.\pipe\aahMetadataServer\console`). So QTB/QTG (the active-tagnames query family) are blocked by
> the metadata server, not the handle format — distinct from the handle-format wall. **R1.6
> (localized properties) has no distinct op** and collapses into R1.5.
---
@@ -0,0 +1,104 @@
# Tag extended properties over 2020 WCF — GetTepByNm (HCAL R1.5)
**Status: ✅ DONE + live-verified (2026-06-20).** `HistorianClient.GetTagExtendedPropertiesAsync(tag)`
reads a tag's extended (user-defined) properties over the 2020 WCF op
`aa/Retr/GetTagExtendedPropertiesFromName` (`GetTepByNm`). Live-verified end-to-end from the
pure-managed .NET 10 client against the local 2020 Historian.
## The op
`GetTepByNm` is on the Retrieval service (`IRetrievalServiceContract4`):
```
bool GetTagExtendedPropertiesFromName(
string handle, // Open2 storage-session GUID, UPPERCASE dash-no-braces
byte[] tagNames, // [MessageParameter pRequestBuff-style]
ref uint sequence, // paging cursor (0 on first call)
out byte[] tagExtendedProperties, // result buffer
out byte[] errorBuffer)
```
It is a **string-handle** op — reachable from the managed client because the handle is the Open2
storage-session GUID formatted `storageSessionId.ToString("D").ToUpperInvariant()` (the same handle
format that unlocked GETRP/GETHI/ExeC; see `wcf-string-handle-wall.md`). The Retrieval service
version handshake (`Retr.GetV`) is primed first, as the native client does.
## Why the name-based path (not the TagQuery path)
There are two managed entry points:
- **Index-based** `TagQuery.GetTagExtendedPropertyInfo(start, count, …)` — requires a prior
`StartTagQuery` (`QTB`). On this 2020 box **QTB fails server-side** (`error 1` from
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console`),
so this path is dead here regardless of handle format.
- **Name-based** `HistorianAccess.GetTagExtendedPropertiesByName(tagName, fetchFromServer, …)`
issues `GetTepByNm` directly with the tag name in `tagNames`, no QTB needed. Its second arg
forces a **server fetch** when true; when false the C++ client reads a local cache and returns
`error 41 (Requested item not found)` without any WCF round-trip. The SDK reproduces the
name-based path.
## Wire format (captured)
`scripts/Capture-TagExtendedProperties.ps1` (NativeTraceHarness `tag-extended-properties` scenario +
instrument-wcf-{write,read}message) → decode with `scripts/decode-tag-properties-capture.py`.
Golden-pinned in `WcfTagExtendedPropertyProtocolTests`.
### Request — `tagNames` buffer
```
uint32 count
per name: uint32 charCount + UTF-16LE chars
```
(For one tag: `01 00 00 00` + `LL 00 00 00` + UTF-16 name.)
### Response — `tagExtendedProperties` buffer
```
uint32 tagCount
per tag:
byte groupMarker (observed 0x01)
0x09 + uint16 byteLen + ASCII tagName (compact-ASCII string)
uint32 propertyCount
per property:
byte propMarker (observed 0x02 — likely the value type)
0x09 + uint16 byteLen + ASCII propertyName
0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value (VT_BSTR CRetVariant)
byte trailingMarker (observed 0x01)
```
`payloadLen` counts the `charCount` field (2 bytes) + the UTF-16 value bytes. Only the string value
variant (`0x43`) is evidence-backed; other variant types throw `ProtocolEvidenceMissingException`
(same discipline as GETRP). The single-byte `0x01`/`0x02`/`0x01` markers are pinned as observed
constants from a single capture; their full semantics are not independently disambiguated.
Example (sanitized — real capture used a dev tag/value):
```
tag "Reactor.Temp1" → property "Location" = "Plant/AreaA"
```
### Paging
`GetTepByNm` is sequence-paged like `GetNextQueryResultBuffer`: call with `sequence = 0`, parse the
buffer, then re-call with the returned `sequence`. A small result returns everything on the first
call; the next call returns an empty/`nil` buffer (with a benign `CClientUtil::FillBufferFromVector`
terminator) — that is the stop signal. The SDK loops until the buffer carries no rows.
## R1.6 (localized properties) — no distinct op on 2020
There is **no** `GetTagLocalizedPropertiesFromName` / `GetTlpByNm` op or
`GetTagLocalizedPropertiesByName` method in `current/aahClientManaged.dll` — the only "localized"
surfaces are `ClientApp.GetLocalizedText` and `SMessageTextMap.GetLocalizedMessage` (error-message /
UI-text localization), not tag properties. So R1.6 **collapses into R1.5**: extended properties
(`GetTepByNm`) are the user-defined tag-property read surface on 2020. R1.6 is closed as
"no separate op," not left throwing.
## Shipped surface
- `HistorianClient.GetTagExtendedPropertiesAsync(tag)``IReadOnlyList<HistorianTagExtendedProperty>`
(`Name`/`Value` pairs; empty when the tag has none).
- `HistorianTagExtendedPropertyProtocol` (serializer/parser), `HistorianWcfTagExtendedPropertyClient`
(orchestration), golden `WcfTagExtendedPropertyProtocolTests`, gated live
`GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties` (set `HISTORIAN_TEP_TAG`
to a tag with extended properties).
+104
View File
@@ -0,0 +1,104 @@
<#
.SYNOPSIS
Captures the native AVEVA client's GetTagExtendedPropertiesFromName (GetTepByNm) wire traffic
(HCAL roadmap R1.5) so the WCF op name, the string-handle format, the tagNames request buffer,
and the extended-property response buffer can be decoded instead of guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's tag scenario with --retrieve-extended-properties,
which flips TagQueryArgs.RetrieveTagExtendedPropertyInfo and then calls
TagQuery.GetTagExtendedPropertyInfo(start, count, out TagExtendedPropertyGroupList, out err) —
the managed method that issues the GetTepByNm op. An IL-rewritten copy of aahClientManaged.dll
logs every MDAS body (ClientMessageEncoder.WriteMessage + ReadMessage), the same pipeline that
produced every other proven request/response shape.
Decode with scripts/decode-tag-properties-capture.py: locate the WCF.WriteMessage.Body whose op
is aa/Retr/GetTepByNm -> that is the request (string handle + tagNames buffer + sequence). The
paired WCF.ReadMessage.Body is the extended-property response buffer.
.NOTES
Read-only metadata call; no data is written. Artifacts are diagnostic and gitignored.
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON, credentials,
hostnames, or customer tag names. SysTimeSec is a built-in system tag (safe to name).
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
# A tag (or wildcard) to query extended properties for. SysTimeSec is a built-in system tag
# present on every Historian; override with a real tag that carries extended properties for a
# richer response decode.
[string]$Tag = "SysTimeSec",
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-tag-extended-properties"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "tag-extended-properties-capture-latest.ndjson"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
# Chain via a distinct intermediate file (reading+writing the same path drops the second hook on
# the mixed-mode native image). Final dll carries both hooks: WCF.WriteMessage.Body + WCF.ReadMessage.Body.
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing tag-extended-properties ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "tag-extended-properties",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--tag", $Tag,
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
$harnessJson = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} catch {
Write-Host " (tag-extended-properties raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $prevEap
}
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
Write-Host " -> $recCount records -> $capturePath"
Write-Host "Harness output (TagExtendedProperties Success / Groups):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 24
Write-Host "`nDecode with: python scripts\decode-tag-properties-capture.py" -ForegroundColor Cyan
+75
View File
@@ -0,0 +1,75 @@
"""Decode the GetTagExtendedPropertiesFromName (GetTepByNm) WCF request/response (HCAL R1.5).
Reads the chained WriteMessage+ReadMessage capture produced by
scripts/Capture-TagExtendedProperties.ps1, locates the aa/Retr/GetTepByNm exchange, and
dumps the tagNames request buffer + tagExtendedProperties response buffer so the op name,
the uppercase string handle, the tagNames layout, and the extended-property response layout
can be read off.
Request tagNames buffer:
uint32 count + per name: uint32 charCount + UTF-16LE chars.
Response tagExtendedProperties buffer:
uint32 tagCount
per tag: byte marker(0x01) + compact-ASCII tagName(0x09 + uint16 len + ascii)
uint32 propCount
per prop: byte marker(0x02) + compact-ASCII propName(0x09 + uint16 len + ascii)
value: 0x43 (VT_BSTR) + uint16 payloadLen + uint16 charCount + UTF-16LE
trailing byte(0x01)
Output is diagnostic. Sanitize before copying into docs/ (tag names / values are dev data).
"""
import base64
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-tag-extended-properties"
DEFAULT_CAP = CAPDIR / "tep-localized-capture.ndjson"
ACTION = re.compile(rb"aa/[A-Za-z0-9]+/[A-Za-z0-9_]+")
def hexdump(label, buf):
print(f"=== {label}: {len(buf)} bytes ===")
for off in range(0, len(buf), 16):
c = buf[off:off + 16]
hp = " ".join(f"{x:02X}" for x in c)
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
print(f" {off:04X} {hp:<48} |{ap}|")
print()
def main() -> int:
cap = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CAP
if not cap.exists():
print(f"Missing capture: {cap}\nRun scripts/Capture-TagExtendedProperties.ps1 -Localized first.")
return 1
records = [json.loads(l) for l in cap.open(encoding="utf-8-sig") if l.strip()]
print(f"== {len(records)} MDAS bodies captured ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
acts = sorted({m.decode() for m in ACTION.findall(body)})
flag = " <== GetTepByNm" if any("Tep" in a for a in acts) else ""
print(f" [{idx:02d}] {rec.get('Phase'):24s} len={len(body):5d} {acts}{flag}")
print("\n== GetTepByNm request(s) [WriteMessage] ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if rec.get("Phase") == "WCF.WriteMessage.Body" and b"GetTepByNm" in body:
hexdump(f"[{idx}] request", body)
print("\n== GetTepByNm response(s) [ReadMessage] ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if rec.get("Phase") == "WCF.ReadMessage.Body" and b"GetTepByNmResponse" in body:
hexdump(f"[{idx}] response", body)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -166,6 +166,19 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.GetRuntimeParameterAsync(name, cancellationToken);
}
/// <summary>
/// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF
/// <c>GetTepByNm</c> op. Returns the property name/value pairs (empty when the tag has none).
/// String-valued properties only (the evidence-backed surface); other value variants throw
/// <see cref="ProtocolEvidenceMissingException"/>. See
/// <c>HistorianTagExtendedPropertyProtocol</c>.
/// </summary>
public Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken);
}
/// <summary>
/// Creates or updates the named tag in the Historian Runtime database via
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
@@ -0,0 +1,11 @@
namespace AVEVA.Historian.Client.Models;
/// <summary>
/// A single extended (user-defined) property attached to a Historian tag — a name/value pair
/// returned by <c>GetTagExtendedPropertiesAsync</c>. Extended properties are stored separately
/// from the core tag metadata (server table <c>_TagExtendedProperty</c>) and are exposed over the
/// 2020 WCF <c>aa/Retr/GetTepByNm</c> op.
/// </summary>
/// <param name="Name">The property name (e.g., <c>Location</c>).</param>
/// <param name="Value">The property value as a string (the wire format is a VT_BSTR variant).</param>
public sealed record HistorianTagExtendedProperty(string Name, string Value);
@@ -74,6 +74,13 @@ internal sealed class Historian2020ProtocolDialect
return Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken);
}
public Task<IReadOnlyList<Models.HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken);
}
private static async IAsyncEnumerable<T> Missing<T>(
string operation,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -0,0 +1,174 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Protocol;
namespace AVEVA.Historian.Client.Wcf;
/// <summary>
/// Serializes the <c>GetTepByNm</c> (GetTagExtendedPropertiesFromName) request buffer and parses
/// its response buffer for AVEVA Historian 2020 over WCF/MDAS.
/// </summary>
/// <remarks>
/// Wire format captured from the native client (<c>scripts/Capture-TagExtendedProperties.ps1</c>
/// + instrument-wcf-{write,read}message; golden-pinned in
/// <c>WcfTagExtendedPropertyProtocolTests</c>). The op is
/// <c>aa/Retr/GetTepByNm(string handle, byte[] tagNames, ref uint sequence) -&gt; (bool,
/// byte[] tagExtendedProperties, byte[] errorBuffer)</c> — a string-handle op reachable from the
/// pure-managed client using the Open2 storage-session GUID as an <b>uppercase</b> handle (the
/// same format that unlocked GETRP/GETHI/ExeC; see <c>wcf-string-handle-wall.md</c>).
///
/// <para><b>tagNames</b> request buffer: <c>uint32 count</c> then per name <c>uint32 charCount</c>
/// + UTF-16LE chars.</para>
///
/// <para><b>tagExtendedProperties</b> response buffer: <c>uint32 tagCount</c> then per tag a
/// 1-byte group marker (observed <c>0x01</c>) + compact-ASCII tag name (<c>0x09</c> + uint16 byte
/// length + ASCII), <c>uint32 propertyCount</c>, then per property a 1-byte marker (observed
/// <c>0x02</c>) + compact-ASCII property name + a CRetVariant value (<c>0x43</c> VT_BSTR + uint16
/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed
/// <c>0x01</c>). Only the string value variant (<c>0x43</c>) is evidence-backed; other variant
/// types throw <see cref="ProtocolEvidenceMissingException"/>.</para>
///
/// <para>The op is sequence-paged: call with <c>sequence = 0</c>, parse the buffer, then re-call
/// with the returned sequence until the response carries no rows. Only string-valued properties
/// from a single capture are exercised; the single-byte group/property markers are pinned as
/// observed constants and their full semantics are not independently disambiguated.</para>
/// </remarks>
internal static class HistorianTagExtendedPropertyProtocol
{
private const byte CompactStringMarker = 0x09;
private const byte VariantTypeBStr = 0x43;
/// <summary>Serializes the <c>tagNames</c> request buffer for a single tag.</summary>
public static byte[] SerializeRequest(string tagName)
{
ArgumentException.ThrowIfNullOrEmpty(tagName);
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(1u); // name count = 1
writer.Write((uint)tagName.Length); // char count
writer.Write(Encoding.Unicode.GetBytes(tagName));
writer.Flush();
return stream.ToArray();
}
/// <summary>
/// Parses the <c>tagExtendedProperties</c> response buffer into a flat list of
/// (tagName, propertyName, value) rows. Returns an empty list when the buffer carries no rows
/// (the terminal page of the sequence loop), which is how callers know to stop paging.
/// </summary>
public static IReadOnlyList<HistorianTagExtendedPropertyRow> ParseResponse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 4)
{
return [];
}
int cursor = 0;
uint tagCount = ReadUInt32(buffer, ref cursor);
if (tagCount == 0)
{
return [];
}
List<HistorianTagExtendedPropertyRow> rows = [];
for (uint t = 0; t < tagCount; t++)
{
// 1-byte group marker (observed 0x01).
SkipByte(buffer, ref cursor);
string tagName = ReadCompactAsciiString(buffer, ref cursor);
uint propertyCount = ReadUInt32(buffer, ref cursor);
for (uint p = 0; p < propertyCount; p++)
{
// 1-byte property marker (observed 0x02 — likely the property value type).
SkipByte(buffer, ref cursor);
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);
}
}
return rows;
}
private static string ReadCompactAsciiString(ReadOnlySpan<byte> buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 3);
byte marker = buffer[cursor++];
if (marker != CompactStringMarker)
{
throw new ProtocolEvidenceMissingException(
$"GetTepByNm response expected compact string marker 0x09, found 0x{marker:X2}.");
}
ushort byteLength = ReadUInt16(buffer, ref cursor);
EnsureAvailable(buffer, cursor, byteLength);
string value = Encoding.ASCII.GetString(buffer.Slice(cursor, byteLength));
cursor += byteLength;
return value;
}
private static string ReadVariantStringValue(ReadOnlySpan<byte> buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 5);
byte variantType = buffer[cursor++];
if (variantType != VariantTypeBStr)
{
throw new ProtocolEvidenceMissingException(
$"GetTepByNm response value variant type 0x{variantType:X2} is not the evidence-backed string variant (0x43).");
}
// uint16 payload length (bytes that follow the charCount field), then uint16 charCount.
_ = ReadUInt16(buffer, ref cursor);
ushort charCount = ReadUInt16(buffer, ref cursor);
int byteCount = charCount * 2;
EnsureAvailable(buffer, cursor, byteCount);
string value = Encoding.Unicode.GetString(buffer.Slice(cursor, byteCount));
cursor += byteCount;
return value;
}
private static void SkipByte(ReadOnlySpan<byte> buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 1);
cursor++;
}
private static ushort ReadUInt16(ReadOnlySpan<byte> buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 2);
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor, 2));
cursor += 2;
return value;
}
private static uint ReadUInt32(ReadOnlySpan<byte> buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 4);
uint value = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
cursor += 4;
return value;
}
private static void EnsureAvailable(ReadOnlySpan<byte> buffer, int cursor, int byteCount)
{
if (cursor < 0 || byteCount < 0 || cursor > buffer.Length - byteCount)
{
throw new InvalidDataException("GetTepByNm response ended unexpectedly.");
}
}
}
/// <summary>One (tag, propertyName, value) row parsed from a GetTepByNm response buffer.</summary>
internal sealed record HistorianTagExtendedPropertyRow(string TagName, string PropertyName, string Value);
@@ -0,0 +1,107 @@
using System.ServiceModel;
using System.ServiceModel.Channels;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
/// <summary>
/// Reads tag extended properties (HCAL R1.5) over the 2020 WCF <c>aa/Retr/GetTepByNm</c> op. This
/// is a string-handle op reached with the Open2 storage-session GUID formatted UPPERCASE — the same
/// handle format that unlocked GETRP/GETHI/ExeC (see <c>wcf-string-handle-wall.md</c>).
/// </summary>
internal static class HistorianWcfTagExtendedPropertyClient
{
// GetTepByNm is sequence-paged. A single tag with a handful of properties returns everything in
// the first buffer and an empty buffer on the next call; the cap is a runaway guard.
private const int MaxPages = 64;
/// <summary>Diagnostic: the GetTepByNm return code / error from the last call (set only on
/// server rejection).</summary>
public static string? LastError { get; private set; }
public static Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(
HistorianClientOptions options,
string tag,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return Task.Run(() => GetTagExtendedProperties(options, tag), cancellationToken);
}
private static IReadOnlyList<HistorianTagExtendedProperty> GetTagExtendedProperties(
HistorianClientOptions options,
string tag)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(options);
LastError = null;
List<HistorianTagExtendedProperty> properties = [];
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
additionalSetup: (_, context) =>
QueryExtendedProperties(retrBinding, retrEndpoint, options, context.StorageSessionId, tag, properties));
return properties;
}
private static void QueryExtendedProperties(
Binding retrBinding,
EndpointAddress retrEndpoint,
HistorianClientOptions options,
Guid storageSessionId,
string tag,
List<HistorianTagExtendedProperty> sink)
{
// GetTepByNm takes the storage-session GUID as a string handle, formatted exactly as the
// native client sends it: uppercase, dash-separated, no braces.
string handle = storageSessionId.ToString("D").ToUpperInvariant();
byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
ChannelFactory<IRetrievalServiceContract4> factory = new(retrBinding, retrEndpoint);
HistorianWcfClientCredentialsHelper.Configure(factory, options);
IRetrievalServiceContract4 channel = factory.CreateChannel();
ICommunicationObject co = (ICommunicationObject)channel;
try
{
// Prime the Retrieval service version handshake (Retr.GetV), as the native client does
// before any string-handle Retrieval op.
channel.GetInterfaceVersion(out _);
uint sequence = 0;
for (int page = 0; page < MaxPages; page++)
{
bool ok = channel.GetTagExtendedPropertiesFromName(
handle, tagNames, ref sequence, out byte[] responseBuffer, out byte[] errorBuffer);
if (!ok)
{
// A non-success on the first page is a real failure; on a later page it is the
// benign end-of-paging terminator (server returns nil + a FillBufferFromVector
// marker once the rows are exhausted).
if (page == 0)
{
LastError = $"GetTepByNm returned false (responseLen={responseBuffer?.Length ?? 0}, errorLen={errorBuffer?.Length ?? 0}).";
}
break;
}
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
HistorianTagExtendedPropertyProtocol.ParseResponse(responseBuffer ?? []);
if (rows.Count == 0)
{
break;
}
foreach (HistorianTagExtendedPropertyRow row in rows)
{
sink.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value));
}
}
}
finally
{
try { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } catch { try { co.Abort(); } catch { } }
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { try { factory.Abort(); } catch { } }
}
}
}
@@ -296,6 +296,41 @@ public sealed class HistorianClientIntegrationTests
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetTagExtendedPropertiesAsync_AgainstLocalHistorian_ReturnsProperties()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
// A tag that carries at least one extended property. Gated on its own env var so the test
// skips cleanly when no such tag is configured (no tag name is hardcoded).
string? tepTag = Environment.GetEnvironmentVariable("HISTORIAN_TEP_TAG");
if (string.IsNullOrWhiteSpace(host)
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|| !OperatingSystem.IsWindows()
|| string.IsNullOrWhiteSpace(tepTag))
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// GetTepByNm rides the storage-session GUID as an uppercase string handle. The configured
// tag has at least one string-valued extended property (e.g. Location).
IReadOnlyList<AVEVA.Historian.Client.Models.HistorianTagExtendedProperty> properties =
await client.GetTagExtendedPropertiesAsync(tepTag, CancellationToken.None);
Assert.NotEmpty(properties);
Assert.All(properties, p =>
{
Assert.False(string.IsNullOrWhiteSpace(p.Name));
Assert.NotNull(p.Value);
});
}
[Fact]
public async Task GetConnectionStatusAsync_AgainstLocalHistorian_ReportsConnectedToServer()
{
@@ -0,0 +1,120 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Protocol;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Golden-byte tests for the GetTepByNm (GetTagExtendedPropertiesFromName) codec. The byte layout
/// is pinned to the live capture documented on <see cref="HistorianTagExtendedPropertyProtocol"/>
/// (scripts/Capture-TagExtendedProperties.ps1); synthetic tag/property values are used here so no
/// dev tag names land in the repo, but the framing is byte-for-byte the captured structure.
/// </summary>
public sealed class WcfTagExtendedPropertyProtocolTests
{
[Fact]
public void SerializeRequest_SingleTag_MatchesCapturedLayout()
{
// tagNames = uint32 count(1) + uint32 charCount + UTF-16LE chars.
const string tag = "Reactor.Temp1";
byte[] actual = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
using MemoryStream expected = new();
using (BinaryWriter w = new(expected, Encoding.Unicode, leaveOpen: true))
{
w.Write(1u);
w.Write((uint)tag.Length);
w.Write(Encoding.Unicode.GetBytes(tag));
}
Assert.Equal(expected.ToArray(), actual);
}
[Fact]
public void ParseResponse_SingleStringProperty_ExtractsNameAndValue()
{
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
IReadOnlyList<HistorianTagExtendedPropertyRow> rows =
HistorianTagExtendedPropertyProtocol.ParseResponse(buffer);
HistorianTagExtendedPropertyRow row = Assert.Single(rows);
Assert.Equal("Reactor.Temp1", row.TagName);
Assert.Equal("Location", row.PropertyName);
Assert.Equal("Plant/AreaA", row.Value);
}
[Fact]
public void ParseResponse_ZeroTagCount_ReturnsEmpty_ForPagingTermination()
{
// The terminal page of the sequence loop carries tagCount = 0.
byte[] terminal = [0x00, 0x00, 0x00, 0x00];
Assert.Empty(HistorianTagExtendedPropertyProtocol.ParseResponse(terminal));
}
[Fact]
public void ParseResponse_NonStringVariant_Throws()
{
// Replace the 0x43 VT_BSTR marker with an unmapped variant type (0x03).
byte[] buffer = BuildResponse("Reactor.Temp1", "Location", "Plant/AreaA");
int variantOffset = Array.IndexOf(buffer, (byte)0x43);
Assert.True(variantOffset > 0);
buffer[variantOffset] = 0x03;
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer));
}
/// <summary>
/// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount,
/// then per tag [marker 0x01][compact-ASCII name][uint32 propCount][per prop marker 0x02 +
/// compact-ASCII name + 0x43 VT_BSTR value][trailing 0x01].
/// </summary>
private static byte[] BuildResponse(string tag, string propName, string propValue)
{
using MemoryStream ms = new();
using BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true);
WriteUInt32(w, 1u); // tagCount
w.Write((byte)0x01); // group marker
WriteCompactAscii(w, tag);
WriteUInt32(w, 1u); // propertyCount
w.Write((byte)0x02); // property marker
WriteCompactAscii(w, propName);
WriteVariantString(w, propValue);
w.Write((byte)0x01); // trailing marker
w.Flush();
return ms.ToArray();
}
private static void WriteUInt32(BinaryWriter w, uint value)
{
Span<byte> b = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(b, value);
w.Write(b);
}
private static void WriteUInt16(BinaryWriter w, ushort value)
{
Span<byte> b = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(b, value);
w.Write(b);
}
private static void WriteCompactAscii(BinaryWriter w, string value)
{
w.Write((byte)0x09);
WriteUInt16(w, (ushort)value.Length);
w.Write(Encoding.ASCII.GetBytes(value));
}
private static void WriteVariantString(BinaryWriter w, string value)
{
w.Write((byte)0x43); // VT_BSTR
WriteUInt16(w, (ushort)(2 + value.Length * 2)); // payload length = charCount field + UTF-16 bytes
WriteUInt16(w, (ushort)value.Length); // char count
w.Write(Encoding.Unicode.GetBytes(value));
}
}
@@ -43,6 +43,12 @@ internal static class Program
string? aggregationTypeName = GetArg(args, "--aggregation-type");
uint maxStates = uint.TryParse(GetArg(args, "--max-states"), out uint parsedMaxStates) ? parsedMaxStates : 0;
string? historyFilter = GetArg(args, "--filter");
// R1.5 capture: flip the TagQueryArgs flag that makes the native client retrieve extended
// properties in the index-based TagQuery path. (The dedicated tag-extended-properties
// scenario, which drives the name-based GetTagExtendedPropertiesByName, is the reliable
// GetTepByNm capture path; the TagQuery path is gated behind QTB, which fails server-side
// here.) Off by default so the normal tag-query scenario is unchanged.
bool retrieveExtendedProperties = HasFlag(args, "--retrieve-extended-properties");
DateTime endUtc = TryParseUtc(GetArg(args, "--end-utc")) ?? DateTime.UtcNow;
DateTime startUtc = TryParseUtc(GetArg(args, "--start-utc")) ?? endUtc.AddMinutes(-lookbackMinutes);
@@ -246,6 +252,77 @@ internal static class Program
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsTagExtendedPropertiesScenario(scenario))
{
// R1.5 capture: drive HistorianAccess.GetTagExtendedPropertiesByName(string tagName,
// bool fetchFromServer, out TagExtendedPropertyGroup, out error) directly. This is the
// NAME-based entry point that issues the GetTepByNm WCF op WITHOUT a prior
// StartTagQuery (QTB) — the index-based TagQuery.GetTagExtendedPropertyInfo path is
// blocked here because QTB fails server-side (CMdServer StartActiveTagnamesQuery).
// The second GetTagExtendedPropertiesByName arg forces a server fetch (issues GetTepByNm)
// when true; when false the C++ client reads its local cache and returns err 41 if the
// tag's properties were never fetched. Default true so the scenario captures GetTepByNm;
// pass --tep-cache-only to exercise the cache-read (no WCF op) path.
bool fetchFromServer = !HasFlag(args, "--tep-cache-only");
// Prime the tag identity table first (ProcessTagNameIdentity inside
// GetTagExtendedPropertiesByName fails with err 41 if the tag was never resolved on
// this connection). GetTagInfoByName(tagName, cache, out HistorianTag, out err) is the
// proven uint-handle metadata path that registers the tag.
string? primeResult = null;
MethodInfo? getTagInfoByName = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (getTagInfoByName is not null)
{
ParameterInfo[] tibParams = getTagInfoByName.GetParameters();
Type tagOutType = tibParams[2].ParameterType.GetElementType()!;
object tibError = Activator.CreateInstance(errorType)!;
object?[] tibArgs = new object?[] { tagName, true, null, tibError };
try
{
bool tibOk = (bool)getTagInfoByName.Invoke(access, tibArgs)!;
primeResult = $"GetTagInfoByName={tibOk} err={GetPropertyText(tibArgs[3], "ErrorDescription")}";
}
catch (TargetInvocationException ex)
{
primeResult = "GetTagInfoByName threw: " + FormatException(ex.InnerException ?? ex);
}
}
MethodInfo getTepByName = accessType.GetMethods()
.First(m => m.Name == "GetTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
ParameterInfo[] tepParams = getTepByName.GetParameters();
Type groupType = tepParams[2].ParameterType.GetElementType()!; // TagExtendedPropertyGroup& -> TagExtendedPropertyGroup
object tepError = Activator.CreateInstance(errorType)!;
object?[] tepArgs = new object?[] { tagName, fetchFromServer, null, tepError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-tag-extended-properties");
bool tepOk = false;
string? tepException = null;
try
{
tepOk = (bool)getTepByName.Invoke(access, tepArgs)!;
}
catch (TargetInvocationException ex)
{
tepException = FormatException(ex.InnerException ?? ex);
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
TagName = tagName,
FetchFromServer = fetchFromServer,
Prime = primeResult,
GroupType = groupType.FullName,
GetTagExtendedPropertiesByNameReturned = tepOk,
Exception = tepException,
Group = ToSerializableValue(tepArgs[2]),
GroupSnapshot = tepArgs[2] is null ? null : SnapshotObject(tepArgs[2]!),
Error = SnapshotObject(tepArgs[3]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
@@ -916,7 +993,7 @@ internal static class Program
object queryArgs = Activator.CreateInstance(tagQueryArgsType)!;
SetProperty(queryArgs, "TagFilter", tagName);
SetProperty(queryArgs, "CacheTagInfo", true);
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", false);
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", retrieveExtendedProperties);
snapshots["TagQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
@@ -980,6 +1057,42 @@ internal static class Program
Tags = SummarizeTagList(tagInfoArgs[2])
});
}
// R1.5 capture: explicitly pull extended properties so the native client issues
// the GetTepByNm WCF op (only fires when --retrieve-extended-properties is set,
// which flips RetrieveTagExtendedPropertyInfo on the query args above).
if (retrieveExtendedProperties)
{
MethodInfo? getTepMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagExtendedPropertyInfo" && method.GetParameters().Length == 4);
if (getTepMethod is not null)
{
object tepError = Activator.CreateInstance(errorType)!;
object?[] tepArgs = [0u, requestedRows, null, tepError];
bool tepSuccess = false;
try
{
tepSuccess = (bool)getTepMethod.Invoke(query, tepArgs)!;
}
catch (TargetInvocationException ex)
{
rows.Add(new { Kind = "TagExtendedPropertyException", Detail = FormatException(ex.InnerException ?? ex) });
}
tepError = tepArgs[3]!;
rows.Add(new
{
Kind = "TagExtendedProperties",
Success = tepSuccess,
ErrorDescription = GetPropertyText(tepError, "ErrorDescription"),
ErrorCode = GetPropertyText(tepError, "ErrorCode"),
Groups = ToSerializableValue(tepArgs[2])
});
if (tepArgs[2] is not null)
{
snapshots["TagExtendedPropertyGroups"] = SnapshotObject(tepArgs[2]!);
}
}
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
@@ -1513,6 +1626,18 @@ internal static class Program
|| scenario.Equals("sql", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Tag extended-properties scenario (R1.5 capture): opens a normal authenticated process
/// connection and calls the NAME-based <c>GetTagExtendedPropertiesByName</c> so the GetTepByNm
/// WCF op + tagNames request / extended-property response buffers can be captured. This
/// bypasses the QTB (StartTagQuery) path, which fails server-side here.
/// </summary>
private static bool IsTagExtendedPropertiesScenario(string scenario)
{
return scenario.Equals("tag-extended-properties", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase);
}
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);