R1.10 RenameTagsAsync: async tag rename via History StartJob (StJb)

Tag rename has no dedicated WCF op — the (old,new) name batch rides the
generic History StartJob (StJb) job buffer; the server returns a job id and
applies renames asynchronously. Handle is the uppercase storage-session GUID,
Open2 in write mode; reuses the write orchestrator's open+priming chain.

jobBuffer layout (decoded + server-validated): byte[7] zero prefix + uint32
pairCount + per pair (uint32 oldCharCount + UTF-16 oldName + uint32
newCharCount + UTF-16 newName), order (old,new). The raw instrument capture
mangles the final byte with MDAS chunk markers (the R1.1 lesson), so the golden
fixture pins the CLEAN byte[] the SDK handed the channel (dumped via
AVEVA_HISTORIAN_RENAME_DUMP) — the exact buffer the live server accepted and
renamed with.

Gated server-side by the AllowRenameTags system parameter (default 0): when
disabled the native client rejects pre-wire (err 132); the managed SDK surfaces
it as StartJob=false -> Accepted=false. Enabling needs a Historian config
reload, not just a storage-engine restart.

Shipped: HistorianClient.RenameTagAsync/RenameTagsAsync -> HistorianTagRenameResult;
HistorianTagRenameProtocol; orchestrator RenameTags/SendStartJobRename; golden
WcfTagRenameProtocolTests (4, pins server-accepted buffer); gated live test
RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag (passed end-to-end).
Native-harness `rename` scenario + Capture-RenameTags.ps1 + decode-rename-capture.py.
Doc: docs/reverse-engineering/wcf-rename-tags.md. 213 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-21 01:18:41 -04:00
parent 362fcb0ef4
commit bc353df8c4
12 changed files with 794 additions and 3 deletions
+133
View File
@@ -0,0 +1,133 @@
<#
.SYNOPSIS
Captures the native AVEVA client's RenameTags wire traffic (HCAL roadmap R1.10) so the
StJb (StartJob) rename jobBuffer + GtJb (GetJobStatus) response can be decoded instead of guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's `rename` scenario against the live Historian
with an IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage AND
ReadMessage are instrumented to log every MDAS body. The harness opens a WRITE-enabled
connection, creates a sandbox source tag (RetestSdkWrite...), calls
HistorianAccess.RenameTags([(from,to)], ref status, out err), and polls GetTagRenameStatus.
Rename maps to the generic job framework: StJb(handle, jobBuffer) -> jobId, then
GtJb(handle, jobId) -> jobStatus. Decode with scripts/decode-rename-capture.py: find the
WCF.WriteMessage.Body whose op is StJb -> its jobBuffer carries the (old,new) name pairs; the
paired ReadMessage carries the jobId; the GtJb request/response carry the status.
SAFETY: sandbox-guarded — both names MUST start with 'RetestSdkWrite'. The default run renames
RetestSdkWriteRenameSrc -> RetestSdkWriteRenameDst and (unless -SkipCleanup) deletes the
destination tag afterward via a second harness pass.
.NOTES
Artifacts are diagnostic and gitignored. Sanitize before copying anything into docs/ --
never commit raw capture NDJSON, credentials, hostnames, or customer tag names.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$RenameFrom = "RetestSdkWriteRenameSrc",
[string]$RenameTo = "RetestSdkWriteRenameDst",
[switch]$SkipCleanup,
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
if (-not $RenameFrom.StartsWith("RetestSdkWrite") -or -not $RenameTo.StartsWith("RetestSdkWrite")) {
throw "Both -RenameFrom and -RenameTo must start with 'RetestSdkWrite' (sandbox guard)."
}
$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-rename"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "rename-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
$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 rename ($RenameFrom -> $RenameTo) ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "rename",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--rename-from", $RenameFrom,
"--rename-to", $RenameTo,
"--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 " (rename 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 (RenameTagsReturned / Rows):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 30
# Best-effort cleanup: delete the destination sandbox tag so reruns start clean.
if (-not $SkipCleanup) {
Write-Host "`n== Cleanup: deleting $RenameTo ==" -ForegroundColor Cyan
$cleanupArgs = @(
"--scenario", "write",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--write-sandbox-tag", $RenameTo,
"--write-skip-add-tag",
"--write-skip-add-value",
"--write-delete-after",
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
try {
$ErrorActionPreference = "Continue"
& dotnet run --no-build -c $Configuration --project $harnessProj -- @cleanupArgs 2>&1 | Select-Object -Last 4
} catch {
Write-Host " (cleanup raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = "Stop"
}
}
Write-Host "`nDecode with: python scripts\decode-rename-capture.py" -ForegroundColor Cyan
+129
View File
@@ -0,0 +1,129 @@
"""Decode the RenameTags WCF request/response (HCAL R1.10).
Reads the chained WriteMessage+ReadMessage capture produced by scripts/Capture-RenameTags.ps1
and locates the rename exchange. Rename maps to the generic job framework:
StJb (StartJob): WriteMessage carries op "StJb" + a string handle + the rename jobBuffer
(the (old,new) name pairs). ReadMessage carries the returned jobId string.
GtJb (GetJobStatus): WriteMessage carries op "GtJb" + handle + jobId. ReadMessage carries
the job-status buffer.
We flag bodies by the StJb/GtJb op and by the sandbox names, then dump the buffers so the
jobBuffer layout (batch count + old/new UTF-16 framing) can be read off directly.
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-rename"
CAP = CAPDIR / "rename-capture-latest.ndjson"
# Sandbox names used by the default capture run (not secret).
FROM = "RetestSdkWriteRenameSrc"
TO = "RetestSdkWriteRenameDst"
OP_STJB = b"StJb"
OP_GTJB = b"GtJb"
def hexdump(label, buf, base=0):
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" {base + off:04X} {hp:<48} |{ap}|")
print()
def ascii_strings(buf, minlen=3):
out, cur, start = [], [], 0
for i, x in enumerate(buf):
if 32 <= x < 127:
if not cur:
start = i
cur.append(chr(x))
else:
if len(cur) >= minlen:
out.append((start, "".join(cur)))
cur = []
if len(cur) >= minlen:
out.append((start, "".join(cur)))
return out
def u16_strings(buf, minlen=3):
out, i = [], 0
while i < len(buf) - 1:
j, chars = i, []
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
chars.append(chr(buf[j]))
j += 2
if len(chars) >= minlen:
out.append((i, "".join(chars)))
i = j
else:
i += 1
return out
def main() -> int:
if not CAP.exists():
print(f"Missing capture: {CAP}\nRun scripts/Capture-RenameTags.ps1 first.")
return 1
records = []
for line in CAP.open(encoding="utf-8-sig"):
if line.strip():
records.append(json.loads(line))
from_u16, to_u16 = FROM.encode("utf-16-le"), TO.encode("utf-16-le")
from_a, to_a = FROM.encode("ascii"), TO.encode("ascii")
print(f"== {len(records)} MDAS bodies captured ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
flags = []
if OP_STJB in body:
flags.append("StJb")
if OP_GTJB in body:
flags.append("GtJb")
if from_u16 in body or from_a in body:
flags.append("FROM")
if to_u16 in body or to_a in body:
flags.append("TO")
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
def find(predicate):
hits = []
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if predicate(rec, body):
hits.append((idx, rec, body))
return hits
print("\n== StJb request(s): WriteMessage bodies tagged StJb ==")
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body" and OP_STJB in b):
hexdump(f"[{idx}] StJb WriteMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
print(" ASCII strings:")
for off, s in ascii_strings(body):
print(f" 0x{off:04X} {s!r}")
print()
print("\n== StJb / GtJb response(s) + GtJb request(s) ==")
for idx, rec, body in find(lambda r, b: (OP_STJB in b or OP_GTJB in b) and r.get("Phase") == "WCF.ReadMessage.Body"):
hexdump(f"[{idx}] {rec.get('Phase')}", body)
print(" strings:", [s for _, s in ascii_strings(body)][:16])
print()
return 0
if __name__ == "__main__":
sys.exit(main())