Compare commits

...

16 Commits

Author SHA1 Message Date
Joseph Doherty 31b1b8db76 docs(#36): design doc §8/§10 — live-gate results and the String-node retarget
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:54:11 -04:00
Joseph Doherty 29ca6b15ab docs(#36): rig live gate PASS (two runs, reset echo held 751 ms on run 2); seed retargeted to String nodes; register row 120 — OPC UA scalar writes go out as String variants
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:53:51 -04:00
Joseph Doherty 3595f00dad test(site-runtime): HandshakeResetEchoTests — label the legacy pin as permanent behaviour (#36 review)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:36:28 -04:00
Joseph Doherty 17d5d3e639 fix(scripts): reset-aware handshake refuses a null reset value; document additive timeouts (#36 review)
A null resetValue encoded to null, which wrote an empty string and then handed the
waiter a null target (= any change), fast-pathing on the stale cache and reinstating
the race the overload exists to close. The context parameter is now non-nullable and
the accessor throws ArgumentException before any write. Also: additive-timeout note on
all surfaces, batch-failure message no longer names the bool overload, and the
error-message/batch-failure branches are pinned.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:35:50 -04:00
Joseph Doherty 637da6c020 docker(seed-handshake-demo): System.-qualify TimeSpan/StringComparison to match the sibling seed (#36 review)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:30:44 -04:00
Joseph Doherty 465a9d711a docker: seed-handshake-demo.sh — live gate for the reset-aware handshake over real OPC UA flags (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:30:40 -04:00
Joseph Doherty 4e335891b4 test(site-runtime): traced reset-aware handshake emits four ordered step rows (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:30:40 -04:00
Joseph Doherty b623fdb05e feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:30:40 -04:00
Joseph Doherty 03c8a01dd1 test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:30:40 -04:00
Joseph Doherty 4db2f3e76e feat(central-ui): Test Run sandbox mirrors the reset-aware WriteBatchAndWaitAsync (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:23:09 -04:00
Joseph Doherty ae23c40a74 docs: reset-aware handshake — spec note, component doc, Delmia/Ipsen notes, register row 119, CLAUDE.md (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:23:09 -04:00
Joseph Doherty 13f9f73033 feat(flow-debugger): predict the reset write + echo wait of the reset-aware handshake (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:23:09 -04:00
Joseph Doherty f7504a9499 feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:22:29 -04:00
Joseph Doherty e5c536c6b7 feat(commons): HandshakePhase + HandshakeResult for the reset-aware handshake (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:18:10 -04:00
Joseph Doherty 9bb373c4a1 docs(scripts): implementation plan for #36 — reset-aware WriteBatchAndWaitAsync handshake
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:15:27 -04:00
Joseph Doherty 0e6cef05e3 docs(scripts): design for #36 — reset-aware WriteBatchAndWaitAsync handshake
The MES/Delmia handshake fast-paths on the previous run's cached CompleteFlag
because the reset write has not echoed back before the wait registers. An
edge-triggered wait is rejected (a subscription can swallow the reset when the
device flips inside one sampling interval); the fix encodes the reset leg inside
a new WriteBatchAndWaitAsync overload that observes the reset echo before writing
the trigger and returns a phase-aware HandshakeResult.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 15:06:56 -04:00
24 changed files with 2492 additions and 7 deletions
+1
View File
@@ -72,6 +72,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- Staggered Instance Actor startup on failover to prevent reconnection storms.
- **Instance Actor tag-subscribe retry is guarded + backed off (Lever 1, 2026-08-28).** One outstanding `SubscribeTagsRequest` per connection until `TagSubscribeInFlightCeilingMs` (45 s); the interval doubles ±20 % jitter from `TagSubscribeRetryIntervalMs` (5 s) to `TagSubscribeRetryMaxIntervalMs` (60 s), resetting on Success or `ConnectionQualityChanged(Good)`. Pre-Lever-1 the actor re-sent a full N-tag subscribe every fixed 5 s with no in-flight check — under a slow MxGateway that stacked ~6 concurrent 244-tag subscribes per 30 s and wedged the session (QA 2026-08-26). Consequence to know: a *lost* reply is now re-sent after the ceiling (45 s), not the 5 s interval — a *failed* reply still retries at the backed-off interval. Levers 2 (bulk supervisory advise, cross-repo) and 3 (session `command_timeout`) are still design-only: `docs/plans/2026-08-28-mxgateway-subscribe-storm-prevention-design.md`.
- Explicit supervision strategies: Resume for coordinator actors, Stop for short-lived execution actors.
- **Level-triggered flag handshakes must observe the reset echo before raising the trigger (Gitea #36, 2026-09-10).** `Attributes.WriteBatchAndWaitAsync` has a reset-aware overload — `(values, flagKey, flagValue, responseKey, responseValue, timeout, resetKey, resetValue, resetTimeout?)` → `HandshakeResult` (`Matched`/`TimedOut`/`Phase`/`ResetFailed`) — that writes the reset, waits event-driven for the device to echo it, and only then writes the batch + trigger and waits for the response. The six-argument bool overload is unchanged but **unsafe for a repeated handshake**: the waiter's current-value fast-path answers from the previous run's cached response value while the script's reset write is still in flight (QA measured 0 ms matches and stale `BatchID`s). An edge-triggered wait was rejected — a subscription reports changes from the last *reported* value, so a device flipping reset→done inside one sampling interval never reports the reset and such a wait never resolves. Composition only: `ScriptRuntimeContext.WriteBatchResetAndWait` chains the existing `SetAttribute` → waiter → batch → waiter legs, so no actor/message/proto change, and a traced run shows all four steps. Mirrored on `ScriptCompileSurface`, `SandboxScriptHost` and `FlowShapePredictor`. The QA/prod MES script port is register row 119. Design: `docs/plans/2026-09-10-handshake-reset-echo-design.md`.
### Data & Communication
- Data Connection Layer is a clean data pipe — publishes to Instance Actors only.
+4 -1
View File
@@ -9,7 +9,10 @@ set -euo pipefail
# (docs/api-specs/Delmia-Recipe-Download-API.md §5), ReadyFlag defaulting to true.
# - Template SimMachine — composes DelmiaReceiverSim as `DelmiaReceiver` and carries
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes).
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes). The #36
# reset-echo race cannot occur here: a STATIC write updates the cache synchronously,
# so the reset is "echoed" before the trigger is written. The data-sourced proof of
# the reset-aware overload is docker/seed-handshake-demo.sh.
# * DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag) —
# plays the Galaxy object: accepts unless DownloadPath ends in ".bad" or the work
# order is blank, writes RecipeProcessResult/Text, clears the trigger flag, then
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -euo pipefail
# Seed a DATA-SOURCED flag handshake on site-a to prove the reset-aware
# Attributes.WriteBatchAndWaitAsync overload (Gitea #36) against a real OPC UA
# subscription — the reset echo must round-trip through the device before the
# trigger is written, and a SECOND run must not fast-path on the first run's
# cached Complete=true.
#
# What it creates (site-a / site id 1, connection id 1 "OPC PLC Simulator"):
# - Template HandshakeDemo
# * Trigger (String) <- ns=3;s=TestChildObject.TestString writable, NOT simulated (holds what is written)
# * Complete (String) <- ns=3;s=JoeAppEngine.BTCS writable, NOT simulated
# * Handshake (Call) — the reset-aware overload; returns the HandshakeResult fields
# * Device (ValueChange on Trigger) — plays the PLC: on Trigger=="True" sets Complete="True"
# and clears Trigger (so the next run's trigger is a fresh edge)
#
# WHY STRING NODES, NOT THE Boolean/Double ones (Motor.Running, Pump.Running, Motor.Speed):
# a data-sourced SCALAR write leaves the InstanceActor as its codec-encoded STRING ("True",
# "42.5") and RealOpcUaClient wraps it in a plain Variant, so opc-plc answers BadTypeMismatch
# (0x80740000) for every non-String node — observed live 2026-09-10 (deferred-work register
# row 120). Only List values are decoded before the write. The two String nodes above are
# the rig's only writable String scalars, so the flags are the strings "True"/"False";
# the reset-echo ordering this gate proves is identical either way.
# - Instance handshake-demo-1, bound + deployed
#
# Not idempotent (names must be unique). To re-seed:
# scadabridge instance delete --id <id> && scadabridge template delete --id <id>
# Prerequisites: infra (scadabridge-opcua) up, docker/deploy.sh, docker/seed-sites.sh.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
SITE_ID="${SITE_ID:-1}"
CONNECTION_ID="${CONNECTION_ID:-1}"
INSTANCE_NAME="${INSTANCE_NAME:-handshake-demo-1}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
echo "1/5 Creating template HandshakeDemo..."
TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name HandshakeDemo \
--description "Gitea #36 live gate: reset-aware WriteBatchAndWaitAsync over real OPC UA flags" | json_id)
echo " id=$TEMPLATE_ID"
echo "2/5 Adding data-sourced attributes..."
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Trigger --data-type String --data-source "ns=3;s=TestChildObject.TestString" --description "Handshake trigger flag as a string (writable String node, not simulated; see header)" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Complete --data-type String --data-source "ns=3;s=JoeAppEngine.BTCS" --description "Handshake completion flag as a string (writable String node, not simulated; see header)" > /dev/null
echo "3/5 Adding the Handshake (Call) script — reset-aware overload..."
read -r -d '' HANDSHAKE_CODE <<'CS' || true
// Gitea #36: reset Complete="False", WAIT for the device to echo it, then batch+trigger, then wait
// for Complete=="True". Run this twice: the second run is the one the plain overload got wrong.
// String flags because the rig's OPC UA sim rejects non-String scalar writes (register row 120);
// the batch carries no payload values — the trigger alone rides the batch leg.
var values = new Dictionary<string, object?>();
var hs = await Attributes.WriteBatchAndWaitAsync(
values, "Trigger", "True", "Complete", "True", System.TimeSpan.FromSeconds(20),
"Complete", "False", System.TimeSpan.FromSeconds(10));
Log.Info($"handshake matched={hs.Matched} phase={hs.Phase} timedOut={hs.TimedOut} resetFailed={hs.ResetFailed}");
return new { Matched = hs.Matched, Phase = hs.Phase.ToString(), TimedOut = hs.TimedOut, ResetFailed = hs.ResetFailed, Error = hs.ErrorMessage ?? "" };
CS
"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Handshake --trigger-type Call \
--return-def '{"type":"object","properties":{"Matched":{"type":"boolean"},"Phase":{"type":"string"},"TimedOut":{"type":"boolean"},"ResetFailed":{"type":"boolean"},"Error":{"type":"string"}},"required":["Matched","Phase"]}' \
--code "$HANDSHAKE_CODE" > /dev/null
echo "4/5 Adding the Device (ValueChange on Trigger) script — plays the PLC..."
read -r -d '' DEVICE_CODE <<'CS' || true
// Plays the device: on the rising edge of Trigger, complete the request and clear the trigger
// so the NEXT run's Trigger=true is a fresh change on the subscription. Its own Trigger=false
// write re-triggers this script, which returns at the guard.
var t = await Attributes.GetAsync("Trigger");
var ts = t?.ToString();
if (!((t is bool b && b) || ts == "1" || string.Equals(ts, "true", System.StringComparison.OrdinalIgnoreCase))) return null;
await Attributes.SetAsync("Complete", "True");
await Attributes.SetAsync("Trigger", "False");
return null;
CS
"$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Device --trigger-type ValueChange \
--trigger-config '{"attributeName":"Trigger"}' --code "$DEVICE_CODE" > /dev/null
echo "5/5 Creating, binding and deploying instance $INSTANCE_NAME..."
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id)
"$CLI" instance set-bindings "${A[@]}" --id "$INSTANCE_ID" \
--bindings "[[\"Trigger\",$CONNECTION_ID],[\"Complete\",$CONNECTION_ID]]" > /dev/null
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null
echo " instance id=$INSTANCE_ID"
echo ""
echo "=== Done. Live gate (run it TWICE — the second run is the #36 case): ==="
echo " $CLI flow-debug run-script --instance-id $INSTANCE_ID --script Handshake --show-logs ${A[*]}"
echo "Expect on BOTH runs: Matched=true, Phase=Response; the trace shows Complete write ->"
echo "Complete wait (reset echo, >0 ms on the 2nd run) -> batch+Trigger write -> Complete wait."
@@ -149,6 +149,13 @@ try {
```
</details>
> **Known race (Gitea #36, 2026-09-10).** Step 2's `RecipeProcessedFlag = false` is a device
> write whose echo has not arrived when step 3's wait registers, so the wait can be satisfied by
> the PREVIOUS download's cached `true`. The runtime fix is the reset-aware overload —
> `WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, 25 s,
> "RecipeProcessedFlag", false, 10 s)` returning a `HandshakeResult` — which replaces steps 2–3.
> The deployed prod/QA script bodies are NOT yet ported (deferred-work register row 119).
## 5. Galaxy-side contract — the `DelmiaReceiver` template (id 12; composed clone id 26)
All ten attributes are **data-sourced over the site `MxGateway` connection**, bound per instance to the
@@ -69,6 +69,11 @@ receiver is ready to accept a MoveIn.
Written: `MoveInMesContainerNum`, `MoveInBatchID`, `MoveInJobSequenceNumber`,
`MoveInNumberWorkOrders`, `MoveInWorkOrderNumbers[]`, `MoveInPartNumbers[]`,
`MoveInOperatorName`, `MoveInFlag`.
4a. **Handshake ordering (Gitea #36, 2026-09-10):** the reset of `<X>CompleteFlag` must be
ECHOED by the Galaxy object before `<X>Flag` is raised, otherwise the completion wait
fast-paths on the previous request's cached `true`. Port the receiver scripts onto the
reset-aware `WriteBatchAndWaitAsync` overload (`docs/plans/2026-09-10-handshake-reset-echo-design.md`
§4) and drop the `Task.Delay(1000)`.
5. **Return BatchID (Q2):** echo the input `MoveInBatchID` (no read-back).
## 4. Components
@@ -222,6 +222,15 @@ type/members don't collide with `ForbiddenIdentifiers` (`dynamic`, `Activator`)
actor to evict the orphan waiter promptly (otherwise it self-evicts at its own timeout).
- **Concurrency / re-entrancy.** Multiple waiters per instance are fine (keyed by `CorrelationId`).
Consider a per-instance cap as a guard against a script leaking waiters in a loop.
- **Handshake reset ordering (Gitea #36, design `2026-09-10-handshake-reset-echo-design.md`).**
The fast-path above is correct ONLY once the caller's reset has been observed. A script that
writes `CompleteFlag=false` and immediately calls the batch helper races the device echo: the
cache still holds the previous run's `true`, the wait fast-paths in 0 ms, and the script reads
stale results. An edge-triggered wait is NOT the fix (a subscription can swallow the reset when
the device flips inside one sampling interval); the reset must be echoed BEFORE the trigger is
written. Use the reset-aware `Attributes.WriteBatchAndWaitAsync(…, resetKey, resetValue,
resetTimeout)` overload, which returns a `HandshakeResult` (`ResetFailed` ⇒ nothing else was
written).
---
@@ -112,6 +112,8 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 117 | **Flow Debugger site trace pull: `FlowDebugRunner.WriteAndPollAsync` fetches ONE bounded page with a fixed `since`** *(Low)*. The write-and-poll loop asks the site for `FlowDebugRunner.PullTraceEventsMax` rows per poll, always `since` the run start, and the site serves `ORDER BY OccurredAtUtc ASC LIMIT` — so the page is the OLDEST rows in the window, never a continuation of the previous one. Step logs made that reachable: one traced run can now emit up to 201 `ScriptLog` rows, so a handful of chatty runs inside the window can fill the page and push a later spawned run's `ScriptRun` row past the end of it, at which point the loop never sees the awaited run and reports a false `NO_TRIGGER_WITHIN_WINDOW` for a run that did happen. Final review I1 raised the cap 500 -> 2 000 (the site's own ceiling, `SiteStreamGrpcServer.MaxTraceEventsPerPull`), which makes it ~10x harder but does not remove it. The real fix is to PAGE — advance `since` to the last-seen `OccurredAtUtc` between polls (keeping the already-known ids so nothing is double-counted) — or to exclude `ScriptLog` from the awaited-run poll entirely and pull the log rows once, after the run has settled. | Final review of `docs/plans/2026-09-09-flow-debugger-step-logs.md` (I1); `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs` `WriteAndPollAsync` | 2 000 is the site's hard ceiling, so the headroom cannot be bought again with a bigger number; paging needs the poll loop to track a cursor and the awaited-run predicate to survive a partial view, which is more than the raise was worth at review time. | The first `NO_TRIGGER_WITHIN_WINDOW` reported for a run whose Replay of the same root DOES show the spawned `ScriptRun`. |
| 118 | **Pre-existing bUnit flake: `CentralUI.Tests.Pages.NotificationReportDetailModalTests.CloseButton_DismissesModal` (~10 %)** *(Low)*. Fails intermittently with `UnknownEventHandlerIdException` — the test resolves the close button once and clicks it after a re-render has already retired that element's handler id. Not caused by the step-logs work: the file is untouched since `40944a0d`. The fix is a one-liner — re-`Find` the button immediately before the click (the pattern the other modal tests already use) rather than reusing an element captured earlier in the test. | Observed during the step-logs full-suite runs, 2026-09-09 | It is a test-only race with a known cause and a known fix; it was out of scope for a remediation pass whose rule was to touch only the files under review. | Next time `CentralUI.Tests` is worked on, or the first time the flake blocks a gate run. |
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
| 119 | **Port the QA/prod MES receiver scripts onto the reset-aware `WriteBatchAndWaitAsync` overload (Gitea #36)** *(Medium)*. Template scripts CvdReactor MesMoveIn (13) / MesMoveOut (14) / MesSampleData (36) and base MESReceiver MoveIn/MoveOut (15, 16, 25, 26, 37, 38) still reset `<X>CompleteFlag` with a plain write and rely on `Task.Delay(1000)`; QA carries a config-only `WaitAsync(false, 10 s)` patch on 13/14 (deployment record 110). Rewrite: `var hs = await rx.Attributes.WriteBatchAndWaitAsync(values, "<X>Flag", true, "<X>CompleteFlag", true, 25 s, "<X>CompleteFlag", false, 10 s); if (!hs.Matched) return new { WasSuccessful=false, ErrorText = hs.ResetFailed ? "Timeout waiting for <X>CompleteFlag to reset" : "Timeout waiting for <X> to complete", BatchID=0 };` and delete the delay. Requires the #36 build on the target central + sites first (the overload does not exist on older sites → deploy-gate compile failure, by design). | Gitea #36, design `2026-09-10-handshake-reset-echo-design.md` §8 | Repo-only scope was the user's decision for the #36 session; the script bodies live only in the QA/prod databases. | Next QA deployment of a build ≥ this change (write a deployment record; then prod). |
| 120 | **Data-sourced SCALAR writes reach the OPC UA server as String Variants — every non-String node rejects them with BadTypeMismatch (0x80740000)** *(Medium)*. `InstanceActor.HandleSetDataAttribute` / `HandleWriteAttributeBatch` decode only List values before the DCL write ("Scalars are unchanged"), so the codec-encoded string (`"True"`, `"42.5"`) reaches `RealOpcUaClient.WriteValuesAsync`, which wraps it in `new Variant(value)` — a String variant sent to a Boolean/Double node. Found live 2026-09-10 running `docker/seed-handshake-demo.sh` against opc-plc `Motor.Running`/`Pump.Running`/`Motor.Speed` (`SetAttribute('Complete') failed: OPC UA write failed with status: 0x80740000`); the seed was retargeted onto the rig's only two writable String nodes. The MxGateway path is unaffected in practice: prod's Delmia/MES handshakes write the same strings through the gateway today. Fix candidates: (a) decode scalars by declared `DataType` in the InstanceActor before the DCL write — also changes what MxGateway receives, so it needs a gateway-side compatibility check; (b) adapter-local: `RealOpcUaClient` coerces to the node's DataType (it already resolves `DataTypeIds` in `EnrichVariableTypeInfoAsync`), leaving the MxGateway wire untouched — preferred. Pin with a `RealOpcUaClientArrayWriteTests`-style test for a Boolean scalar. | Found live 2026-09-10 during the #36 rig gate | Out of scope for #36 (handshake ordering); touches the production write path and needs the MxGateway compatibility question answered first. | Before any OPC UA-bound template relies on script writes to non-String nodes (none does today — until this gate the rig's script writes to OPC UA were static-only). |
## Resolved (verified against the code 2026-07-10)
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
@@ -0,0 +1,214 @@
# Design — reset-aware `WriteBatchAndWaitAsync` handshake (Gitea #36)
**Date:** 2026-09-10
**Issue:** Gitea #36 — *Scripts: WriteBatchAndWaitAsync handshake fast-paths on the previous run's
cached CompleteFlag (MesMoveOut returns stale BatchID/result)*
**Status:** approved 2026-09-10; scope for this change is **repo only** (runtime, mirrors, tests,
docs, rig). The QA/prod MES template-script rewrite is a deployment record, not part of this change.
## 1. The defect
The MES/Delmia receiver handshake is a level-triggered flag protocol on data-sourced attributes:
```
CompleteFlag = false // reset (device write; the site cache is NOT updated optimistically)
WriteBatchAndWaitAsync(values, "MoveOutFlag", true, "MoveOutCompleteFlag", true, 25 s)
read Successful / BatchID / ErrorText
```
`WriteBatchAndWaitAsync` writes the batch and trigger and then registers an event-driven wait on
`CompleteFlag == true`. The waiter's **current-value fast-path** (spec §5, by design) answers
immediately if the cache already holds the target. After the first run, the cache holds the
previous run's `true` until the device echoes the reset back through the subscription — and that
echo has not arrived by the time the wait registers (16 ms after the reset write on QA). The wait
therefore matches in 0 ms, and the script reads result attributes the device has not yet written.
The fixed `Task.Delay(1000)` in the scripts masks the race only when the device completes inside a
second; QA measured 0.4–1.2 s, so the outcome is a coin flip (issue evidence table).
## 2. Why an edge-triggered wait is not the fix
The obvious alternative — a waiter that ignores the cached value and matches only on a change
event arriving after registration — trades one race for a worse one. A subscription reports a
**change from the last reported value**. If the reset (`false`) and the trigger are written back to
back and the device flips `false → true` inside one sampling interval, the `false` is never
reported: the last reported value stays `true`, the device's new `true` is not a change, and the
waiter never resolves. Nothing site-side can recover that; the protocol itself requires the reset
to be **observed** before the trigger is written, so the device cannot start until the flag is
provably low. That is exactly what the interim QA patch does (`SetAsync(false)` then
`WaitAsync(false, 10 s)` before the batch call), and it observed a real edge on every run since.
Once the reset echo has been observed, the existing level-triggered fast-path is correct again:
the only `true` the cache can hold afterwards is one the device wrote after seeing the trigger.
## 3. Decision
**Encode the reset leg inside `WriteBatchAndWaitAsync`** as a new overload, so a handshake cannot
be written with the legs in the wrong order, and return a **phase-aware result** so a script can
report *which* leg timed out. The runtime primitive lives in `ScriptRuntimeContext`; the
`InstanceActor` waiter is unchanged.
Alternatives considered:
| Option | Verdict |
|---|---|
| Edge-triggered flag on `WaitAsync` / the batch call | Rejected — §2, non-deterministic under sampling. |
| Standalone `Attributes.SetAndWaitAsync(key, value, timeout)` → `bool` | Smaller and reusable, but nothing stops an author from omitting it; two calls per handshake. Not chosen. |
| Keep `bool`, throw on reset timeout | Simplest surface, but an inbound method would surface `SCRIPT_ERROR` instead of a structured `WasSuccessful=false`. Not chosen. |
| **Reset leg inside the batch call + `HandshakeResult`** | **Chosen.** |
## 4. Script-facing API
```csharp
// Existing (unchanged): level-triggered, caller owns the reset.
Task<bool> Attributes.WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout);
// New: reset-aware. Reset arguments LAST so the six leading positions keep their meaning.
Task<HandshakeResult> Attributes.WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout,
string resetKey, object? resetValue, TimeSpan? resetTimeout = null);
```
```csharp
// Commons/Types, beside WaitResult.
public enum HandshakePhase { Reset, Response }
public readonly record struct HandshakeResult(
bool Matched, object? Value, string? Quality, bool TimedOut,
HandshakePhase Phase, string? ErrorMessage);
```
`ResetFailed` (`!Matched && Phase == Reset`) is a computed convenience: script bodies import only
`System`, `System.Collections.Generic`, `System.Linq` and `System.Threading.Tasks`, so a script
branching on the enum would have to spell `ZB.MOM.WW.ScadaBridge.Commons.Types.HandshakePhase`.
Semantics of the new overload, in execution order:
1. **Reset write** — `resetKey = resetValue` through the existing `SetAttribute` path (static or
data-sourced; a device-write failure throws `InvalidOperationException`, exactly as the batch
leg does today).
2. **Reset wait** — the existing event-driven waiter on `resetKey == resetValue`, bounded by
`resetTimeout ?? timeout`, quality-agnostic. Not matched ⇒ return
`HandshakeResult { Matched=false, TimedOut, Phase=Reset, ErrorMessage }` and **never write the
batch or the trigger**. On a static attribute the cache updates synchronously, so this wait is
an immediate fast-path — the overload is safe to use uniformly.
3. **Batch + trigger** — unchanged existing leg (all values data-sourced, one connection, trigger
written after the batch; failure throws).
4. **Response wait** — unchanged existing waiter on `responseKey == responseValue`, bounded by
`timeout`; returns `Phase=Response` with the matched value/quality, or `TimedOut`.
Typical use (the MES scripts after the port):
```csharp
var hs = await rx.Attributes.WriteBatchAndWaitAsync(
values, "MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
resetKey: "MoveOutCompleteFlag", resetValue: false, resetTimeout: TimeSpan.FromSeconds(10));
if (!hs.Matched)
return new { WasSuccessful = false, BatchID = 0,
ErrorText = hs.Phase == HandshakePhase.Reset
? "Timeout waiting for MoveOutCompleteFlag to reset"
: "Timeout waiting for MoveOut to complete" };
// result attributes are now the device's answer to THIS request — no Task.Delay needed
```
## 5. Runtime shape
- `ScriptRuntimeContext.WriteBatchResetAndWait(...)` composes the existing building blocks:
`SetAttribute` → `WaitInternal` → the existing batch Ask → `WaitInternal`. No new actor messages,
no proto change, no InstanceActor change. Each leg is already a Flow Debugger step
(`AttributeWrite` + `AttributeWait` for the reset, the batch `AttributeWrite`, the response
`AttributeWait`), so a traced run shows all four legs with no `FlowGraphBuilder` work.
- The script execution-timeout token bounds every Ask as it does today; a deadline hit mid-wait
propagates as cancellation (spec §4.3), unchanged.
- **Residual, documented not fixed:** a *previous* run that timed out and whose late `true` echo
arrives after this run's reset echo can still satisfy the response wait. A level-triggered flag
protocol cannot distinguish that case; it needs a sequence/handle attribute on the device side.
**The two waits are additive.** A call can run for `resetTimeout` + `timeout` plus Ask slack, so a
script using the overload must set `ExecutionTimeoutSeconds` above that sum. A script deadline hit
part-way through either leg propagates as cancellation (§4.3) — the script sees the cancellation,
not a `HandshakeResult` naming the phase it died in.
## 6. Mirrors and analysis surfaces
- `ScriptCompileSurface` (ScriptAnalysis) — compile-only mirror of the new overload
(`NotSupportedException(CompileOnly)`), plus `HandshakeResult`/`HandshakePhase` visible to scripts.
- `SandboxScriptHost` (CentralUI Test Run) — mirror that throws the labelled `NotInSandbox`
exception, the same as the existing batch overload (no device batch transport in the sandbox).
- `FlowShapePredictor` — the nine-argument form predicts **four** nodes: reset `AttributeWrite`
(Tags), reset `AttributeWait` (Site), batch `AttributeWrite` (Tags), response `AttributeWait`
(Site). Argument indices 1 and 3 keep their existing meaning; the reset key is index 6.
- `CompileSurfaceParityTests` enforces the mirrors by reflection; no manual list to update.
- The routed `Route.To(code).WaitForAttribute` and the sandbox `WaitAsync` are untouched — the
overload is a site-local composition, not a new wire verb.
## 7. Tests
1. **`HandshakeResetEchoTests`** (SiteRuntime.Tests/Scripts) over the **real `InstanceActor`** with
a DCL test probe and data-sourced Boolean attributes, cache seeded to `CompleteFlag=true`:
- *Reset gates the trigger:* the probe receives `WriteTagRequest(reset=false)` and answers
success but does **not** echo. Assert **no** `WriteTagBatchRequest` arrives and the task is
still pending. Echo `false` via `TagValueUpdate`; assert the batch request arrives. Echo
`true`; assert `Matched`, `Phase=Response`, `Value=true`.
- *Reset timeout:* no echo ever; assert `Matched=false, TimedOut=true, Phase=Reset` and that no
batch request was ever sent.
- *Static reset attribute:* reset on a static attribute fast-paths; the batch is written at once.
2. **Trace emission** (`ScriptRuntimeContextTraceEmissionTests`): a traced reset-aware call emits
the reset `AttributeWrite` + `AttributeWait` rows before the batch row.
3. **`FlowShapePredictorTests`**: the nine-argument form yields the four nodes in order.
4. Parity: `CompileSurfaceParityTests` (existing) covers the mirrors.
## 8. Docs and scripts (repo)
- `docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md` §5 — add a *handshake reset
ordering* correctness note pointing here.
- `docs/requirements/Component-ScriptAnalysis.md` — list the overload among the mirrored members.
- `docs/api-specs/Delmia-Recipe-Download-API.md` §4 / `docs/plans/2026-06-16-ipsen-mes-movein-design.md`
— note the race and the intended rewrite (the deployed prod/QA bodies are unchanged by this repo
change).
- `docker/seed-delmia-sim.sh` — unchanged behaviour (static attributes; cache updates
synchronously so the race cannot occur); add a comment saying so.
- **Rig live gate** — `docker/seed-handshake-demo.sh`: a template with two data-sourced **String**
attributes on the rig's two writable OPC UA String nodes (not simulated, so they hold what is
written; String rather than Boolean because of deferred-work register row 120, found by this
very gate), a `Call` script using the new overload, and a
ValueChange "device" script that sets the complete flag when the trigger rises. Proves the reset
echo round-trips through a real subscription and that `Phase` reports correctly.
- Deferred-work register — one row: *port the QA/prod MES receiver scripts (13/14/15/16/25/26/36/
37/38) onto the overload and drop the `Task.Delay(1000)`*, with the §4 shape; requires the build
on the target central first.
- CLAUDE.md — key-decision bullet under *Architecture & Runtime*.
## 9. Compatibility
Additive only: a new overload, two new Commons types, no message/proto/schema change. Existing
scripts compile and behave identically. An older site receiving a script that uses the overload
fails the deploy gate's compile (the member does not exist there), which is the intended
mixed-version outcome — upgrade sites before porting scripts.
## 10. Live gate 2026-09-10 (docker rig, main after the #36 merge)
`docker/seed-handshake-demo.sh` → instance `handshake-demo-1` on site-a, flags on the rig's two
writable OPC UA **String** nodes (`TestChildObject.TestString` = Trigger, `JoeAppEngine.BTCS` =
Complete). The Boolean/Double nodes rejected the scalar writes with `BadTypeMismatch`
(`0x80740000`) — a pre-existing gap in the data-sourced scalar write path (only List values are
decoded before the DCL write; the OPC UA client sends the encoded string as a String variant),
recorded as deferred-work register row 120. The ordering this gate proves is identical on String
flags. `flow-debug run-script --instance-id <id> --script Handshake --show-logs`, two consecutive
runs, traced steps as reported by the CLI:
| Leg | Run 1 | Run 2 (cache holds run 1's `True`) |
|---|---|---|
| reset write `Complete = "False"` | 1 ms | 1 ms |
| reset-echo wait (`Complete == "False"`) | 217 ms, satisfied | **751 ms, satisfied** |
| batch + trigger write `Trigger = "True"` | 7 ms | 1 ms |
| response wait (`Complete == "True"`) | 2009 ms, satisfied | 2029 ms, satisfied |
| script result | `Matched=true, Phase=Response` | `Matched=true, Phase=Response` |
Run 2 is the #36 case: the cache still held `True` from run 1, the reset wait did **not**
fast-path, and the trigger was not written until the device echoed the reset 751 ms later. The
response wait is dominated by the Device script's own two data writes plus the subscription
round-trip. The optional negative control (planting a stale `True` with the OPC UA tool) was
skipped: `infra/tools/opcua_tool.py` needs the `asyncua` module, which is not installed here.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-09-10-handshake-reset-echo.md",
"tasks": [
{"id": 1, "subject": "Task 1: Commons HandshakePhase + HandshakeResult", "status": "pending"},
{"id": 2, "subject": "Task 2: ScriptRuntimeContext.WriteBatchResetAndWait (probe-level tests)", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: End-to-end regression test over the real InstanceActor", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: AttributeAccessor overload + ScriptCompileSurface mirror", "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 5: SandboxScriptHost mirror + Central UI compile-clean test", "status": "pending", "blockedBy": [1]},
{"id": 6, "subject": "Task 6: FlowShapePredictor nine-argument form", "status": "pending", "blockedBy": [1]},
{"id": 7, "subject": "Task 7: Trace emission test for the four ordered step rows", "status": "pending", "blockedBy": [2]},
{"id": 8, "subject": "Task 8: Docs, register row 119, rig comment, CLAUDE.md", "status": "pending"},
{"id": 9, "subject": "Task 9: Rig live gate seed docker/seed-handshake-demo.sh", "status": "pending", "blockedBy": [4]},
{"id": 10, "subject": "Task 10: Integration — build, suites, merge, rig deploy + live gate, Gitea comment", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8,9]}
],
"lastUpdated": "2026-09-10T00:00:00Z"
}
@@ -179,7 +179,7 @@ active use across an eviction while keeping the same ceiling.
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
There is a **third** hand-maintained mirror of the runtime globals: the Central UI Test-Run host `SandboxScriptHost` (see REQ-SA-5 / Interactions). Because Central UI deliberately does not reference Site Runtime, it cannot share the reflection-based parity test above; instead it is guarded by representative-script "diagnose clean" tests in `CentralUI.Tests` (one per non-trivial surface — e.g. the `Attributes.WriteBatchAndWaitAsync` / `WaitAsync` / `WaitForAsync` batch-write-and-wait helpers, the inbound `Database`/`WaitForAttribute` helpers, and the `Notify` outbox shape). A member that drifts out of `SandboxScriptHost` does **not** fail the deploy gate (which compiles against `ScriptCompileSurface`) — it surfaces only as an in-editor `CS1061` false error against otherwise-valid scripts, so these compile-clean tests are the safety net for that surface.
There is a **third** hand-maintained mirror of the runtime globals: the Central UI Test-Run host `SandboxScriptHost` (see REQ-SA-5 / Interactions). Because Central UI deliberately does not reference Site Runtime, it cannot share the reflection-based parity test above; instead it is guarded by representative-script "diagnose clean" tests in `CentralUI.Tests` (one per non-trivial surface — e.g. the `Attributes.WriteBatchAndWaitAsync` (including the reset-aware nine-argument overload) / `WaitAsync` / `WaitForAsync` batch-write-and-wait helpers, the inbound `Database`/`WaitForAttribute` helpers, and the `Notify` outbox shape). A member that drifts out of `SandboxScriptHost` does **not** fail the deploy gate (which compiles against `ScriptCompileSurface`) — it surfaces only as an in-editor `CS1061` false error against otherwise-valid scripts, so these compile-clean tests are the safety net for that surface.
---
@@ -228,7 +228,7 @@ The decorator changes **no resolution result**, only object identity: it returns
`FlowShapePredictor` (Flow Debugger design `docs/plans/2026-09-05-flow-debugger-design.md` §8) predicts, from script **syntax** alone, the swimlane shape a run would produce before anything executes: `PredictInboundMethod(methodName, methodScript, resolveRouteScript, maxDepth = 4)` and `PredictSiteScript(scriptName, scriptBody, maxDepth = 4)` return a `PredictedFlow` of `PredictedNode(Id, Kind, Lane, Title, InputDependent, Note)` + `PredictedEdge(From, To, CrossesBoundary, Async = false)`.
- **Syntax walk, not semantic.** A `CSharpSyntaxWalker` over `CSharpSyntaxTree.ParseText(code, SourceCodeKind.Script)` matches the script-API member-access chains by whitespace-stripped text, exactly as `ScriptTrustValidator`'s hardening walker does: `Route.To(x).Call(y)` / `.SetAttributes` / `.WaitForAttribute`, `Scripts.Call(y)`, `…Attributes.WriteBatchAndWaitAsync` / `WaitAsync` / `WaitForAsync` / `SetAsync`, `…Attributes[x] = v`, `ExternalSystem.Call/CachedCall`, `Database.Execute/Query/QuerySingle/CachedWrite`, `Notify.Send(list)` / `Notify.To(list).Send(...)` (`Async`-suffixed spellings map identically). Reads (`GetAttributes`, indexer reads) produce no node because the runtime records no trace row for them; nor does `Database.Connection(...)` — the number of commands on a raw handle is not statically knowable.
- **Syntax walk, not semantic.** A `CSharpSyntaxWalker` over `CSharpSyntaxTree.ParseText(code, SourceCodeKind.Script)` matches the script-API member-access chains by whitespace-stripped text, exactly as `ScriptTrustValidator`'s hardening walker does: `Route.To(x).Call(y)` / `.SetAttributes` / `.WaitForAttribute`, `Scripts.Call(y)`, `…Attributes.WriteBatchAndWaitAsync` (the reset-aware overload additionally yields a reset `AttributeWrite` + `AttributeWait` pair ahead of the batch) / `WaitAsync` / `WaitForAsync` / `SetAsync`, `…Attributes[x] = v`, `ExternalSystem.Call/CachedCall`, `Database.Execute/Query/QuerySingle/CachedWrite`, `Notify.Send(list)` / `Notify.To(list).Send(...)` (`Async`-suffixed spellings map identically). Reads (`GetAttributes`, indexer reads) produce no node because the runtime records no trace row for them; nor does `Database.Connection(...)` — the number of commands on a raw handle is not statically knowable.
- **Outbound calls land on the External lane (2026-09-06, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md` D8).** `ExternalSystem.*` → `ApiCall` titled `{system}.{method}`; `Database.*` → `DbCall` titled `{connection} · {member}`; `Notify` → `Notify` titled `Notify {list}`. A `CachedCall`, `CachedWrite` or `Notify` node carries `Note = "cached"` where applicable and its incoming edge is `Async`; an async edge does **not** advance the chain — the next node links from the previous non-async node — mirroring `FlowGraphBuilder.IsAsync` so a predicted async branch renders like a real one.
- **Literals resolve; everything else is `InputDependent`** and titled with the expression's source text (`Route.To(code)`). Branching is not modelled: every recognised call is emitted once in source order, and an `if (…) return/throw` guard becomes a `Note = "early return: <condition>"` on the node that **follows** it (the condition is visited first, so a guard whose condition *is* the traced call — Delmia's `if (!await …WriteBatchAndWaitAsync(…)) return …` — attributes correctly).
- **DB-free by contract.** Routed and nested script bodies come from the caller-supplied `resolveRouteScript(instanceCode, scriptName)` (Central UI resolves instance → template parent chain → script; called with the literal code when constant, else with the expression text so a resolver may still answer); recursion stops at `maxDepth` with a `depth limit` note.
@@ -464,6 +464,31 @@ public class SandboxAttributeAccessor
string responseKey, object? responseValue, TimeSpan timeout)
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
/// <summary>
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
/// overload (Gitea #36): present for editor/compile parity, throws
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
/// </summary>
/// <param name="values">The attribute values to write as a batch.</param>
/// <param name="flagKey">The flag attribute key to set to signal completion.</param>
/// <param name="flagValue">The value to set on the flag attribute.</param>
/// <param name="responseKey">The attribute key to observe for the device's response.</param>
/// <param name="responseValue">The response value to wait for.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <param name="resetKey">The attribute written first and waited on before the batch.</param>
/// <param name="resetValue">
/// The reset value the device must echo before the batch is written. MUST NOT be null — the
/// real accessor throws <see cref="System.ArgumentException"/>, because a null would write an
/// empty string and then wait on "any change", reinstating the race the overload closes.
/// </param>
/// <param name="resetTimeout">The maximum time to wait for the reset echo; <c>null</c> means <paramref name="timeout"/>.</param>
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout,
string resetKey, object? resetValue, TimeSpan? resetTimeout = null)
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
/// <summary>
/// Value-equality attribute wait — routed to the bound deployed instance cross-site
/// (the same real event-driven waiter a site uses). Needs a bound instance; throws
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary>
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4).
/// </summary>
public enum HandshakePhase
{
/// <summary>The reset write + reset-echo wait that precedes the batch and trigger.</summary>
Reset,
/// <summary>The batch + trigger write and the response wait that follow an observed reset echo.</summary>
Response,
}
/// <summary>
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36):
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a
/// script can report "timed out waiting for the reset echo" separately from "timed out
/// waiting for the device to complete". A reset that is never echoed means the batch and
/// trigger were NEVER written.
/// </summary>
/// <param name="Matched">
/// <see langword="true"/> when the response attribute reached its target within the timeout
/// (which implies the reset echo was observed first).
/// </param>
/// <param name="Value">The matched value of the phase's attribute; <see langword="null"/> on timeout / error.</param>
/// <param name="Quality">The attribute quality at match time; <see langword="null"/> on the non-match paths.</param>
/// <param name="TimedOut"><see langword="true"/> when the phase's wait timed out before a match.</param>
/// <param name="Phase">The leg this result describes — <see cref="HandshakePhase.Reset"/> only when that leg failed.</param>
/// <param name="ErrorMessage">Why the phase did not match, when known; <see langword="null"/> on success.</param>
public readonly record struct HandshakeResult(
bool Matched,
object? Value,
string? Quality,
bool TimedOut,
HandshakePhase Phase,
string? ErrorMessage)
{
/// <summary>
/// <see langword="true"/> when the reset leg did not match (timeout or error), i.e. the
/// device never echoed the reset value and nothing else was written. Provided so a
/// script can branch without spelling the <see cref="HandshakePhase"/> type name, which
/// is not in the script imports.
/// </summary>
public bool ResetFailed => !Matched && Phase == HandshakePhase.Reset;
}
@@ -45,6 +45,8 @@ public sealed record PredictedFlow(IReadOnlyList<PredictedNode> Nodes, IReadOnly
/// script API exposes (<c>Route.To(x).Call(y)</c>, <c>Scripts.Call(y)</c>,
/// <c>….Attributes.WriteBatchAndWaitAsync(…)</c>, <c>….Attributes[x] = v</c>, …) by text with
/// whitespace stripped, exactly as <see cref="ScriptTrustValidator"/>'s hardening walker does.
/// The nine-argument reset-aware <c>WriteBatchAndWaitAsync</c> overload (Gitea #36) additionally
/// predicts a reset attribute write + echo wait ahead of the batch write + wait pair.
/// String-literal arguments resolve to titles; anything else is flagged
/// <see cref="PredictedNode.InputDependent"/>. Branching is not modelled: every recognised call
/// is emitted once in source order, and an <c>if (…) return …;</c> guard that precedes the next
@@ -369,9 +371,18 @@ public static class FlowShapePredictor
{
case "WriteBatchAndWaitAsync":
{
// (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout)
// (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout
// [, resetAttr, resetValue, resetTimeout]) — Gitea #36: the reset-aware
// overload writes + waits on the reset attribute BEFORE the batch.
var (trigger, triggerDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression);
var (wait, waitDependent) = LiteralOrExpr(args.ElementAtOrDefault(3)?.Expression);
var resetArg = ArgAt(args, 6, "resetKey");
if (resetArg is not null)
{
var (reset, resetDependent) = LiteralOrExpr(resetArg.Expression);
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(reset), resetDependent, "reset");
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(reset), resetDependent, "reset echo");
}
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify($"batch + {trigger}"), triggerDependent, null);
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(wait), waitDependent, null);
return true;
@@ -463,6 +474,16 @@ public static class FlowShapePredictor
private static string TrimAsyncSuffix(string member)
=> member.Length > 5 && member.EndsWith("Async", StringComparison.Ordinal) ? member[..^5] : member;
/// <summary>An argument by name (<c>name: expr</c>) or, failing that, by position among the unnamed arguments.</summary>
private static ArgumentSyntax? ArgAt(SeparatedSyntaxList<ArgumentSyntax> args, int index, string name)
{
var named = args.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == name);
if (named is not null)
return named;
var positional = args.ElementAtOrDefault(index);
return positional is { NameColon: null } ? positional : null;
}
/// <summary>A string literal resolves to its value; anything else is input-dependent and shown as its source text.</summary>
private static (string Text, bool Dependent) LiteralOrExpr(ExpressionSyntax? expr)
{
@@ -332,6 +332,23 @@ public sealed class ScriptCompileSurface
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <returns><c>true</c> if the response value was observed before the timeout elapsed; otherwise <c>false</c>.</returns>
public Task<bool> WriteBatchAndWaitAsync(IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c> overload (Gitea #36).</summary>
/// <param name="values">The set of attribute values to write as a batch.</param>
/// <param name="flagKey">The name of the flag attribute signaling the write.</param>
/// <param name="flagValue">The value written to the flag attribute.</param>
/// <param name="responseKey">The name of the attribute to observe for a response.</param>
/// <param name="responseValue">The value to wait for on the response attribute.</param>
/// <param name="timeout">The maximum time to wait for the response.</param>
/// <param name="resetKey">The attribute written first and waited on before the batch.</param>
/// <param name="resetValue">
/// The reset value the device must echo before the batch is written. MUST NOT be null — the
/// real accessor throws <see cref="System.ArgumentException"/>, because a null would write an
/// empty string and then wait on "any change", reinstating the race the overload closes.
/// </param>
/// <param name="resetTimeout">The maximum time to wait for the reset echo; <c>null</c> means <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout, string resetKey, object? resetValue, TimeSpan? resetTimeout = null) => throw new NotSupportedException(CompileOnly);
}
/// <summary>Compile-only mirror of <c>ChildrenAccessor</c>.</summary>
@@ -111,6 +111,59 @@ public class AttributeAccessor
timeout);
}
/// <summary>
/// Reset-aware handshake (Gitea #36): writes <paramref name="resetKey"/>=<paramref name="resetValue"/>,
/// waits (event-driven, bounded by <paramref name="resetTimeout"/>, default = <paramref name="timeout"/>)
/// for the device to ECHO that reset, and only then performs the ordinary
/// <see cref="WriteBatchAndWaitAsync(IReadOnlyDictionary{string, object?}, string, object?, string, object?, TimeSpan)"/>
/// legs (batch → trigger → response wait). Returns a <see cref="HandshakeResult"/> so a
/// script can tell a reset-echo timeout (<see cref="HandshakeResult.ResetFailed"/>; nothing
/// else was written) from a completion timeout. Use this for every level-triggered
/// flag handshake: without an observed reset the plain overload can be satisfied
/// instantly by the previous run's cached response value. Throws
/// <see cref="System.InvalidOperationException"/> if the reset write or the batch/trigger
/// write itself fails; never throws on either timeout. The two waits are ADDITIVE — the call
/// can run for <paramref name="resetTimeout"/> + <paramref name="timeout"/> plus Ask slack, so
/// size the script's <c>ExecutionTimeoutSeconds</c> above that sum: a deadline hit mid-leg
/// propagates as cancellation (spec §4.3), not as a <see cref="HandshakeResult"/>.
/// </summary>
/// <param name="values">Attribute key → value to batch-write (data-sourced, one connection).</param>
/// <param name="flagKey">Trigger attribute key written AFTER the batch.</param>
/// <param name="flagValue">Value to write to the trigger.</param>
/// <param name="responseKey">Attribute key to wait on after the trigger.</param>
/// <param name="responseValue">Target value to wait for (<c>null</c> ⇒ any change).</param>
/// <param name="timeout">How long to wait for the response before giving up.</param>
/// <param name="resetKey">Attribute written FIRST (usually <paramref name="responseKey"/>); static or data-sourced.</param>
/// <param name="resetValue">
/// Value the device must echo back before the batch is written. MUST NOT be null: a null encodes
/// to null, which would write an empty string and then wait on "any change", fast-pathing on the
/// stale cached value and reinstating the race this overload exists to close —
/// <see cref="System.ArgumentException"/> is thrown before anything is written.
/// </param>
/// <param name="resetTimeout">How long to wait for the reset echo; <c>null</c> ⇒ <paramref name="timeout"/>.</param>
/// <returns>The phase-aware handshake outcome.</returns>
public Task<HandshakeResult> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
string responseKey, object? responseValue, TimeSpan timeout,
string resetKey, object? resetValue, TimeSpan? resetTimeout = null)
{
var encoded = new Dictionary<string, string?>(values.Count);
foreach (var kv in values)
encoded[Resolve(kv.Key)] = AttributeValueCodec.Encode(kv.Value);
return _ctx.WriteBatchResetAndWait(
encoded,
Resolve(flagKey), AttributeValueCodec.Encode(flagValue),
Resolve(responseKey), AttributeValueCodec.Encode(responseValue),
timeout,
Resolve(resetKey),
AttributeValueCodec.Encode(resetValue)
?? throw new ArgumentException(
"A reset-aware handshake needs a definite reset value: null would write an " +
"empty string and then match on any change.", nameof(resetValue)),
resetTimeout ?? timeout);
}
/// <summary>
/// WaitForAttribute (spec §3-§5): waits event-driven until the attribute equals
/// <paramref name="targetValue"/> (value-equality, codec-normalized), bounded by
@@ -964,6 +964,100 @@ public class ScriptRuntimeContext
public async Task<bool> WriteBatchAndWait(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout)
=> (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
.ConfigureAwait(false)).Matched;
/// <summary>
/// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument
/// <c>Attributes.WriteBatchAndWaitAsync</c> overload. Legs, in a FIXED order:
/// (1) write <paramref name="resetAttr"/>=<paramref name="resetEncoded"/> through
/// <see cref="SetAttribute"/> (throws on a device-write failure, like the batch leg);
/// (2) wait event-driven for the device to ECHO the reset, bounded by
/// <paramref name="resetTimeout"/> — not matched ⇒ return a <see cref="HandshakePhase.Reset"/>
/// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing
/// response wait bounded by <paramref name="timeout"/>.
/// </summary>
/// <remarks>
/// Why the reset must be OBSERVED before the trigger is written: a subscription reports a
/// change from the last reported value, so a device that flips reset→done inside one
/// sampling interval never reports the reset — the cached previous-run "done" value would
/// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait
/// would never resolve at all. Holding the trigger until the echo lands makes the existing
/// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger
/// step, so a traced run shows all four.
/// <para>
/// The two waits are ADDITIVE: the call can run for <paramref name="resetTimeout"/> +
/// <paramref name="timeout"/> plus Ask slack, so size the script's
/// <c>ExecutionTimeoutSeconds</c> above that sum — a script deadline hit mid-leg propagates
/// as cancellation (spec §4.3), not as a <see cref="HandshakeResult"/>.
/// </para>
/// </remarks>
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
/// <param name="triggerAttr">Scope-resolved trigger attribute written AFTER the batch.</param>
/// <param name="triggerEncoded">Codec-encoded trigger value.</param>
/// <param name="responseAttr">Scope-resolved attribute to wait on after the trigger.</param>
/// <param name="responseEncoded">Codec-encoded response target (null ⇒ any change).</param>
/// <param name="timeout">Bound for the response wait.</param>
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
/// <param name="resetEncoded">
/// Codec-encoded reset value the device must echo before anything else is written. NON-NULL by
/// contract: a null would write an empty string on leg 1 and then give the leg-2 waiter a null
/// target — "any change" — which fast-paths on the stale cached value and reinstates the very
/// race this overload exists to close. The accessor rejects a null reset value before it gets here.
/// </param>
/// <param name="resetTimeout">Bound for the reset-echo wait.</param>
/// <returns>A phase-aware result; never throws on either timeout.</returns>
public async Task<HandshakeResult> WriteBatchResetAndWait(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout,
string resetAttr, string resetEncoded, TimeSpan resetTimeout)
{
// Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then
// fast-paths); a data-sourced one goes to the device and the cache waits for the echo.
await SetAttribute(resetAttr, resetEncoded).ConfigureAwait(false);
// Leg 2 — reset echo. Quality-agnostic, like the response wait.
var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false)
.ConfigureAwait(false);
if (!reset.Matched)
{
return new HandshakeResult(
Matched: false, reset.Value, reset.Quality, reset.TimedOut, HandshakePhase.Reset,
reset.ErrorMessage
?? (reset.TimedOut
? $"Timed out after {resetTimeout.TotalSeconds}s waiting for '{resetAttr}' to echo the reset value"
: $"Reset wait for '{resetAttr}' did not match"));
}
// Legs 3 + 4 — unchanged batch/trigger write and response wait.
var response = await WriteBatchAndWaitCore(
encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false);
return new HandshakeResult(
response.Matched, response.Value, response.Quality, response.TimedOut, HandshakePhase.Response,
response.Matched
? null
: response.ErrorMessage
?? (response.TimedOut
? $"Timed out after {timeout.TotalSeconds}s waiting for '{responseAttr}'"
: $"Response wait for '{responseAttr}' did not match"));
}
/// <summary>
/// Shared body of <see cref="WriteBatchAndWait"/> / <see cref="WriteBatchResetAndWait"/>:
/// the batch + trigger Ask (throws on failure) followed by the response wait, returning
/// the full <see cref="WaitForAttributeResponse"/> so the reset-aware caller can surface
/// value/quality/timeout.
/// </summary>
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
/// <param name="triggerAttr">Scope-resolved trigger attribute name written AFTER the batch.</param>
/// <param name="triggerEncoded">Codec-encoded value for the trigger.</param>
/// <param name="responseAttr">Scope-resolved attribute to wait on.</param>
/// <param name="responseEncoded">Codec-encoded target value (null ⇒ any change).</param>
/// <param name="timeout">How long to wait for the response before returning a timed-out result.</param>
/// <returns>The full response wait result — never throws on a wait timeout.</returns>
private async Task<WaitForAttributeResponse> WriteBatchAndWaitCore(
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
string responseAttr, string? responseEncoded, TimeSpan timeout)
{
var cid = Guid.NewGuid().ToString();
var batchReq = new WriteAttributeBatchRequest(
@@ -1001,9 +1095,10 @@ public class ScriptRuntimeContext
if (!batchResp.Success)
throw new InvalidOperationException(
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
$"Batch write failed: {batchResp.ErrorMessage}");
return await WaitAttribute(responseAttr, responseEncoded, null, timeout).ConfigureAwait(false);
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
.ConfigureAwait(false);
}
/// <summary>
@@ -756,13 +756,18 @@ public class ScriptAnalysisServiceTests
// WaitAsync / WaitForAsync, so the editor false-flagged valid instance scripts
// with CS1061 even though `template validate` and the deploy gate accept them.
// The sandbox surface must mirror the runtime AttributeAccessor member-for-member.
// Gitea #36: also covers the reset-aware WriteBatchAndWaitAsync overload
// (writes+waits a reset key/value before the batch) with and without the
// optional resetTimeout, returning a HandshakeResult.
var code =
"var data = new System.Collections.Generic.Dictionary<string, object?> { { \"Cmd\", 1 } };\n" +
"var done = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"var hit = await Attributes.WaitAsync(\"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"var pred = await Attributes.WaitAsync(\"Done\", v => v != null, System.TimeSpan.FromSeconds(5));\n" +
"var res = await Attributes.WaitForAsync(\"Done\", true, System.TimeSpan.FromSeconds(5));\n" +
"return done && hit && pred && res.Matched;";
"var hs = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false, System.TimeSpan.FromSeconds(2));\n" +
"var hs2 = await Attributes.WriteBatchAndWaitAsync(data, \"Flag\", true, \"Done\", true, System.TimeSpan.FromSeconds(5), \"Done\", false);\n" +
"return done && hit && pred && res.Matched && hs.Matched && !hs2.ResetFailed;";
var resp = _svc.Diagnose(new DiagnoseRequest(code));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
@@ -0,0 +1,23 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Gitea #36: <see cref="HandshakeResult"/> lets a script tell WHICH leg of a reset-aware
/// handshake failed without naming the <see cref="HandshakePhase"/> enum (script bodies
/// import only System/System.Collections.Generic/System.Linq/System.Threading.Tasks).
/// </summary>
public class HandshakeResultTests
{
[Fact]
public void ResetFailed_IsTrue_OnlyForAnUnmatchedResetPhase()
{
var resetTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Reset, "reset timed out");
var responseTimeout = new HandshakeResult(false, null, null, true, HandshakePhase.Response, "response timed out");
var matched = new HandshakeResult(true, true, "Good", false, HandshakePhase.Response, null);
Assert.True(resetTimeout.ResetFailed);
Assert.False(responseTimeout.ResetFailed);
Assert.False(matched.ResetFailed);
}
}
@@ -313,4 +313,46 @@ public class FlowShapePredictorTests
Assert.True(write.InputDependent);
Assert.Equal("name", write.Title);
}
/// <summary>Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary>
[Fact]
public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch()
{
const string body = """
var data = new System.Collections.Generic.Dictionary<string, object?> { { "MoveOutBatchID", 1 } };
var hs = await Children["RightMESReceiver"].Attributes.WriteBatchAndWaitAsync(
data, "MoveOutFlag", true, "MoveOutCompleteFlag", true, System.TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false, System.TimeSpan.FromSeconds(10));
if (hs.ResetFailed) return new { WasSuccessful = false };
""";
var flow = FlowShapePredictor.PredictSiteScript("MesMoveOut", body);
Assert.Equal(
[
(FlowStepKind.ScriptRun, FlowLane.Site, "MesMoveOut"),
(FlowStepKind.AttributeWrite, FlowLane.Tags, "MoveOutCompleteFlag"),
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
(FlowStepKind.AttributeWrite, FlowLane.Tags, "batch + MoveOutFlag"),
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList());
Assert.Equal("reset", flow.Nodes[1].Note);
Assert.Equal("reset echo", flow.Nodes[2].Note);
}
/// <summary>Named reset arguments resolve by name, not position.</summary>
[Fact]
public void ResetAwareWriteBatchAndWait_NamedArguments_ResolveTheResetKey()
{
const string body = """
var hs = await Attributes.WriteBatchAndWaitAsync(data, "Trig", true, "Done", true, t,
resetKey: "Done", resetValue: false);
""";
var flow = FlowShapePredictor.PredictSiteScript("S", body);
Assert.Equal("Done", flow.Nodes[1].Title);
Assert.Equal(FlowStepKind.AttributeWrite, flow.Nodes[1].Kind);
Assert.Equal(5, flow.Nodes.Count);
}
}
@@ -0,0 +1,173 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset
/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
/// the reset echo lands, then matches only on the device's fresh <c>True</c>.
/// </summary>
public class HandshakeResetEchoTests : TestKit, IDisposable
{
private const string Conn = "PLC";
private const string TriggerTag = "ns=3;s=MoveOutFlag";
private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag";
private const string BatchTag = "ns=3;s=MoveOutBatchID";
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
public HandshakeResetEchoTests()
{
_localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
// then dispose the database before deleting — the master connection anchors the WAL.
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private static FlattenedConfiguration Config() => new()
{
InstanceUniqueName = "Z28061",
Attributes =
[
new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn },
new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn },
new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn },
]
};
private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot()
{
var dcl = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary,
null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance, dcl.Ref)));
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
// The previous run's completion is still in the cache: codec "True", exactly what the
// response wait targets.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
var ctx = new ScriptRuntimeContext(
actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance);
return (actor, dcl, ctx);
}
private static IReadOnlyDictionary<string, string?> Values(int batchId)
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
/// <summary>
/// Pins the PERMANENT legacy behaviour, not a bug awaiting a fix: the six-argument overload is
/// deliberately unchanged (deployed scripts keep their semantics) and answers from the stale
/// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in
/// place; delete this test only if the legacy overload itself is removed.
/// </summary>
[Fact]
public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
{
var (_, dcl, ctx) = Boot();
var call = ctx.WriteBatchAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
// No device update at all — and the wait still "matches".
Assert.True(await call);
}
[Fact]
public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
{
var (actor, dcl, ctx) = Boot();
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10));
// Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has
// not echoed anything yet.
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(CompleteTag, reset.TagPath);
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
// The cache still says True → the reset wait must NOT fast-path, and no batch may go out.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
Assert.False(call.IsCompleted);
// The device echoes the reset.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
// Only now the batch + trigger.
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(TriggerTag, batch.TriggerTagPath);
// TriggerValue may arrive boxed as bool or as its already-encoded string; normalise both sides.
Assert.Equal(AttributeValueCodec.Encode(true), AttributeValueCodec.Encode(batch.TriggerValue));
Assert.True(batch.Values.ContainsKey(BatchTag));
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
// Still pending: the cache now holds False, and nothing can complete the call until the
// TagValueUpdate below is sent — no race here, only a documented intermediate state.
Assert.False(call.IsCompleted);
// The device completes THIS request.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
var result = await call;
Assert.True(result.Matched);
Assert.Equal(HandshakePhase.Response, result.Phase);
Assert.Equal(true, result.Value);
Assert.Equal("Good", result.Quality);
}
[Fact]
public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
{
var (_, dcl, ctx) = Boot();
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromMilliseconds(700));
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
var result = await call;
Assert.False(result.Matched);
Assert.True(result.TimedOut);
Assert.True(result.ResetFailed);
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}
@@ -294,4 +294,80 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
Assert.True(result.TimedOut);
Assert.Null(result.Value);
}
// ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ──────────────
/// <summary>
/// The reset-aware overload must scope-resolve and codec-encode the reset key/value
/// exactly like every other accessor, must default <c>resetTimeout</c> to
/// <c>timeout</c>, and — when the reset echo never arrives — must return a
/// <see cref="HandshakeResult.ResetFailed"/> result WITHOUT writing the batch or
/// the trigger (the ExpectNoMsg at the end is that assertion).
/// </summary>
[Fact]
public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
// Composed scope "RightMESReceiver" — every key is prefixed before it leaves the accessor.
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
var call = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false);
// Leg 1 — the reset write.
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", reset.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), reset.Value);
probe.Reply(new SetStaticAttributeResponse(
reset.CorrelationId, "Pump1", reset.AttributeName, true, null, DateTimeOffset.UtcNow));
// Leg 2 — the reset echo wait, bounded by the defaulted resetTimeout.
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName);
Assert.Equal(AttributeValueCodec.Encode(false), resetWait.TargetValueEncoded);
Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout`
probe.Reply(new WaitForAttributeResponse(
resetWait.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
var result = await call;
Assert.True(result.ResetFailed);
Assert.False(result.Matched);
Assert.True(result.TimedOut);
// Nothing else was written: no batch, no trigger.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Gitea #36 review: a null <c>resetValue</c> would encode to null, which writes an empty
/// string on leg 1 and then hands the waiter a null target — "any change" — which fast-paths
/// on the stale cached value and reinstates exactly the race this overload exists to close.
/// The accessor rejects it before anything reaches the InstanceActor.
/// </summary>
[Fact]
public void WriteBatchAndWaitAsync_ResetAware_NullResetValue_ThrowsBeforeAnyWrite()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var acc = new AttributeAccessor(ctx, "RightMESReceiver");
// Block body (not an expression lambda) so this binds the Action overload: the throw is
// SYNCHRONOUS — a faulted task would leave nothing for Assert.Throws to catch.
var ex = Assert.Throws<ArgumentException>(() =>
{
_ = acc.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["MoveOutBatchID"] = 28950 },
"MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", null);
});
Assert.Equal("resetValue", ex.ParamName);
// Thrown before leg 1: the reset write never left the accessor.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}
@@ -818,4 +818,45 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
Assert.Null(logs[0].StepEventId);
Assert.Equal("script", logs[0].LogSource);
}
/// <summary>
/// Gitea #36: the reset-aware handshake is four traced steps in leg order — reset write,
/// reset-echo wait, batch write, response wait — each with its own row id, with no new
/// audit kinds (the Flow Debugger projection needs no change).
/// </summary>
[Fact]
public async Task WriteBatchResetAndWait_WhenTraced_EmitsFourStepRowsInLegOrder()
{
var probe = CreateTestProbe();
var collector = new FlowTraceCollector();
var ctx = CreateContext(probe.Ref, new CapturingAuditWriter(), traced: true, collector);
var call = ctx.WriteBatchResetAndWait(
new Dictionary<string, string?> { ["Recipe"] = "R-17" },
"Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3),
"Ack", "0", TimeSpan.FromSeconds(2));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.True(reset.Trace);
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, InstanceName, "Ack", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, "0", "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, "1", "Good", TimedOut: false));
Assert.True((await call).Matched);
var rows = collector.Snapshot();
Assert.Equal(
new (AuditKind Kind, string? Target)[]
{
(AuditKind.AttributeWrite, "Ack"),
(AuditKind.AttributeWait, "Ack"),
(AuditKind.AttributeWrite, "Trigger"),
(AuditKind.AttributeWait, "Ack"),
},
rows.Select(e => (Row(e).Kind, e.Target)).ToList());
Assert.Equal(4, rows.Select(e => e.EventId).Distinct().Count());
}
}
@@ -0,0 +1,225 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED
/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes
/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor
/// so the order and the "nothing after a failed reset" property are directly observable.
/// </summary>
public class WriteBatchResetAndWaitTests : TestKit
{
private const string Instance = "Plant.Reactor1";
private static readonly IReadOnlyDictionary<string, string?> Values =
new Dictionary<string, string?> { ["BatchID"] = "28950" };
private static ScriptRuntimeContext CreateContext(IActorRef instanceActor) =>
new(instanceActor,
new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance),
// 30s: the legs Ask the InstanceActor with their own bounds (the batch's internal 35s,
// the waits' own timeouts); a short ambient Ask timeout would race those and make the
// reset/response phases indistinguishable from an AskTimeoutException.
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(30),
instanceName: Instance, logger: NullLogger.Instance);
[Fact]
public async Task Legs_RunInOrder_AndReportTheResponsePhase()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
// Leg 1: the reset write, and nothing else yet.
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
Assert.Equal("MoveOutCompleteFlag", reset.AttributeName);
Assert.Equal("False", reset.Value);
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
// Leg 2: the reset-echo wait targets the RESET value and the RESET timeout.
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName);
Assert.Equal("False", resetWait.TargetValueEncoded);
Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout);
Assert.False(resetWait.RequireGoodQuality);
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
// Leg 3: only now the batch + trigger.
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("MoveOutFlag", batch.TriggerAttribute);
Assert.Equal("True", batch.TriggerEncodedValue);
Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]);
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
// Leg 4: the response wait with the RESPONSE timeout.
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName);
Assert.Equal("True", respWait.TargetValueEncoded);
Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout);
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
var result = await call;
Assert.True(result.Matched);
Assert.Equal(HandshakePhase.Response, result.Phase);
Assert.False(result.TimedOut);
Assert.False(result.ResetFailed);
Assert.Equal(true, result.Value);
Assert.Equal("Good", result.Quality);
Assert.Null(result.ErrorMessage);
}
[Fact]
public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true));
var result = await call;
Assert.False(result.Matched);
Assert.True(result.TimedOut);
Assert.Equal(HandshakePhase.Reset, result.Phase);
Assert.True(result.ResetFailed);
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
// The property that fixes #36: no batch, no trigger, after a reset that was never echoed.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public async Task ResponseTimeout_ReportsResponsePhase()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true));
var result = await call;
Assert.False(result.Matched);
Assert.True(result.TimedOut);
Assert.Equal(HandshakePhase.Response, result.Phase);
Assert.False(result.ResetFailed);
// The message names the attribute that did not answer and the bound it blew.
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
Assert.Contains("25", result.ErrorMessage);
}
/// <summary>
/// A reset wait that fails WITHOUT timing out (a predicate blew up in the waiter) surfaces the
/// actor's own message verbatim rather than a synthesized one, and still writes nothing further.
/// </summary>
[Fact]
public async Task ResetWaitError_PassesTheActorsMessageThrough()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(
resetWait.CorrelationId, Matched: false, null, null, TimedOut: false, ErrorMessage: "predicate threw"));
var result = await call;
Assert.False(result.Matched);
Assert.False(result.TimedOut);
Assert.True(result.ResetFailed);
Assert.Equal("predicate threw", result.ErrorMessage);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// The batch leg keeps throwing after a successful reset — a device-rejected batch is a hard
/// failure, not a <see cref="HandshakeResult"/> the script could mistake for a timeout.
/// </summary>
[Fact]
public async Task BatchFailureAfterASuccessfulReset_StillThrows()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, false, "gateway rejected"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
Assert.Contains("gateway rejected", ex.Message);
Assert.Contains("Batch write failed", ex.Message);
}
[Fact]
public async Task ResetWriteFailure_Throws_LikeTheBatchLeg()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchResetAndWait(
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
Assert.Contains("device rejected", ex.Message);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary>
[Fact]
public async Task LegacyOverload_StillWritesTheBatchFirst()
{
var probe = CreateTestProbe();
var ctx = CreateContext(probe.Ref);
var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25));
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
var wait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
Assert.True(await call);
}
}