Compare commits

...

9 Commits

Author SHA1 Message Date
Joseph Doherty 6a22d28098 test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)
Pass 2 (2026-10-08 00:21Z, df776170): 17/17. Browse 3162=3162 tags, 20 UDT
templates identical, 200 tags read with 0 unexpected divergences (6 live
values compared structurally + every still element), 1 Forward Open and 1
acked Forward Close per native phase. Latency native p95 105.8 ms vs
libplctag 139.5 ms (limit 153.4) PASS. Pass 1 failures and their fixes are
recorded in the gate-results doc; pass 2 had no independent pcap.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:25:32 -04:00
Joseph Doherty df77617051 docs(abcip): Large Forward Open refusal targets and ConnectionSize honouring (T21 review r1 M6)
The 5069-L3x supports the Large open, as the press proved. The narrow/refusing case is a pre-v20 1769-L3x or a bridge. AbCipDeviceOptions.ConnectionSize: the native transport honours it on the wire; libplctag ignores it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:34 -04:00
Joseph Doherty 6ae6287360 fix(ab): orphan release clears the wrapper handle; rooting fallback documented and pinned (T21 review r1 M1/M2)
M2: after plc_tag_destroy, the release writes 0 back to the wrapper's nativeTagHandle, so a repeat release is a no-op instead of touching a destroyed id. The runtimes keep the destroyed handle for tests and diagnostics.

M1: the rooting fallback is now described accurately, as an unbounded leak (one native tag per failed create), not a bounded one. A test seam (ForceRootingForTest) pins that the fallback roots the tag and destroys nothing.

Both pins are red with their line removed. The orphan tests share a non-parallel collection, because the counters and the seam are process-wide.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 43b81b6643 test(abcip): live tags keep a per-element value check; close barrier and GC/RTT attribution (T21 review r1 I1/M9/M8/M4)
I1: a live verdict no longer waives the value check. Only elements that moved between the two libplctag reads are excused.
- An element equal in both libplctag reads must also be equal through native; otherwise **NO: stable element i**.
- A moved element must not read NaN or infinity on native unless libplctag saw it too; otherwise **NO: non-finite element i**.
- Live tags log their full raw bytes and every element, and the summary reports live tags as N of the total.
- The offline pin adds the pass-1 Real[5] shape: a stable element shifted on native fails. It is red with the element check disabled.

M9: after each native phase (differential and latency), wait for the session close. With AB_DIFF_COUNT_OPENS=1, assert exactly one Forward Close was sent, using the new CipConnection counters; acked is logged.

M8: each latency pass logs its GC collection deltas (gen0/1/2) for both transports, plus native per-exchange round-trip min/mean/max.

M4: the latency test also asserts that no libplctag orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty c90b1a8214 feat(ab-transport): Forward Close and per-exchange round-trip gate diagnostics (T21 review r1 M8/M9)
CipConnection gains two process-wide gate-diagnostic counters next to ForwardOpenCount: ForwardClosesSent (incremented as the request goes on the wire) and ForwardClosesAcked (incremented when the reply arrives within the 1 s budget).

It also records round-trip statistics for completed exchanges: ResetExchangeStats() clears them and ReadExchangeStats() returns count, min, max and mean. The cost is two Stopwatch reads and a few interlocked operations per request. No behaviour changes.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 82a0c298ce test(abcip): read-parity volatility probe; live tags compared structurally (T21 press pass 1)
In T21 press pass 1, two REAL tags diverged only because their live values moved
between the sequential libplctag and native phases (WW_M4ActualTemp 384.887 ->
385.138).

The libplctag phase now reads every selected tag a second time, paced, before
native starts (TransportObservations.ProbeReads). A tag whose two libplctag reads
both succeed and differ is classed live. For a live tag, parity still requires:
- the same status (and the native-TypeMismatch rule);
- no decode error;
- the same raw length;
- the same CIP type code when both sides expose one (native records it now;
  libplctag.NET 1.5.2 does not);
- the same decoded shape.
Value equality is not required. Live tags get their own list and a "libplctag
probe" table column, and are counted as neither expected nor unexpected
divergences.

A tag that reads stable on libplctag but differs on native is still an unexpected
divergence, and a missing or failed probe never makes a tag live. The offline pin
A_live_value_is_compared_structurally_and_a_stable_one_by_value goes red with the
live branch disabled.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:50:41 -04:00
Joseph Doherty 6cd6876229 fix(ab): destroy the native libplctag tag a failed initialisation leaves behind (T21 press pass 1 crash)
The T21 press pass 1 test host died at exit with "A callback was made on a garbage
collected delegate of type ...plctag+callback_func_ex::Invoke". The cause is in
libplctag.NET 1.5.2. When a tag's create fails, for example with NotFound,
InitializeAsync throws with the callback still registered. Dispose() destroys a tag
only if it is initialised, so the native tag outlives its wrapper. The GC then frees
the delegate, and libplctag's atexit plc_tag_shutdown raises DESTROYED into it.
Reproduced against ab_server: the production LibplctagTagRuntime with a NotFound tag
exits 134 with that exact message, and exits 0 with this fix.

A timed-out create unregisters its callback but never destroys the handle either,
so that native tag keeps reconnecting for the life of the process.

The fix adds LibplctagOrphanRelease (AbCip and AbLegacy). After a failed
initialise/read/write that leaves the tag uninitialised, it reads the handle through
the pinned wrapper's private fields, unregisters the callback and destroys the tag.
If those fields cannot be read, it roots the tag for the life of the process
instead. It is applied in both tag runtimes, the symbol-listing reader and the
template reader. GetStatus keeps the status the tag held before it was destroyed.

Pins: offline refused-connection tests in AbCip.Tests and AbLegacy.Tests, a live
NotFound test on ab_server, and a reflection-shape pin. All go red with the release
disabled. The differential harness also asserts that no orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:49:03 -04:00
Joseph Doherty c306c6f674 fix(abcip): CompactLogix native default connection size matches libplctag's Large Forward Open (found by T21 latency harness) 2026-10-07 19:34:30 -04:00
Joseph Doherty 2dc55ecb8f test(abcip): read-only poll-latency comparison harness for the T21 press gate (proven on ab_server) 2026-10-07 19:26:23 -04:00
28 changed files with 2319 additions and 85 deletions
+4 -1
View File
@@ -115,7 +115,10 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- `ConnectionSize` / `Timeout` are honoured per pooled connection, and the **first** driver/device to open
a given device key wins.
a given device key wins. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
device overrides the family default.
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
**unverified**.
+9 -9
View File
@@ -116,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
### 5. CompactLogix narrow ConnectionSize cap
### 5. CompactLogix connection size and the regular-open fallback
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
any size, so a refusing controller is never exercised here.
### 6. BOOL-within-DINT read-modify-write (#181)
@@ -168,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
| Add-On Instructions | no | **yes** | yes |
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
| EtherNet/IP embedded-switch behaviour | no | no | yes |
| Redundant chassis failover (1756-RM) | no | no | yes |
| Motion control timing | no | no | yes |
@@ -208,7 +208,7 @@ project is authored.
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
@@ -185,6 +185,46 @@ see the same split on the press.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
### Latency harness proven on ab_server (T21 Step 1)
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|---|---|---|---|---|
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
tags, repeated to 100), with a capture in the container's network namespace:
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|---|---|---|---|---|
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
| 5 | — | — | — | aborted in the latency libplctag phase |
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
as designed. A real controller reassembles TCP; ab_server does not.
The T21 press result is recorded under `## Latency (T21)` and is not filled in here.
## AdminUI /run (T19)
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
@@ -260,8 +300,160 @@ falsified by reverting that wiring: 2/2 tests fail.
## Real press (T21)
_(pending)_
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
native Forward Opens = 1.
Failures, and what they led to:
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
compared by status, length, type, shape and **every element that held still**.
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
diagnostics added.
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
fallback (`c306c6f6`).
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
**17/17 passed.**
| Check | Result |
|---|---|
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
| Native Forward Opens | 1 per native phase |
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
| Sessions | libplctag and native never connected at the same time |
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
⚠️ **Pass 2 has no independent packet capture**: the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
Live tags (value moved between the two libplctag reads; every element that held still matched native):
```
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
```
Test summary:
```
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Total tests: 17
Passed: 17
```
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
## Latency (T21)
_(pending)_
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
libplctag 95.2) — see above.
Per-pass detail (pass 2):
```
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
```
@@ -0,0 +1,307 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
[xUnit.net 00:01:15.30] should be less than or equal to
[xUnit.net 00:01:15.30] 109.06115000000001d
[xUnit.net 00:01:15.30] but was
[xUnit.net 00:01:15.30] 142.765d
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Additional Info:
[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
[xUnit.net 00:01:15.30] Stack Trace:
[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] Output:
[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
[xUnit.net 00:01:15.30] |---|---|---|---|
[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
Error Message:
Shouldly.ShouldAssertException : natP95
should be less than or equal to
109.06115000000001d
but was
142.765d
Additional Info:
native p95 must be ≤ libplctag p95 × 1.10
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
| native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
| Pass | libplctag ms | native ms | native requests |
|---|---|---|---|
| 1 | 95.0 | 95.4 | 2 |
| 2 | 94.4 | 92.4 | 2 |
| 3 | 95.8 | 96.8 | 2 |
| 4 | 95.0 | 94.7 | 2 |
| 5 | 98.8 | 93.7 | 2 |
| 6 | 92.4 | 101.0 | 2 |
| 7 | 94.1 | 94.1 | 2 |
| 8 | 97.0 | 96.7 | 2 |
| 9 | 93.4 | 91.2 | 2 |
| 10 | 97.7 | 91.0 | 2 |
| 11 | 94.6 | 91.1 | 2 |
| 12 | 96.5 | 92.0 | 2 |
| 13 | 94.1 | 97.6 | 2 |
| 14 | 96.1 | 94.0 | 2 |
| 15 | 90.8 | 93.3 | 2 |
| 16 | 95.6 | 93.7 | 2 |
| 17 | 93.9 | 100.6 | 2 |
| 18 | 92.4 | 121.6 | 2 |
| 19 | 97.5 | 92.7 | 2 |
| 20 | 95.2 | 93.8 | 2 |
| 21 | 96.8 | 90.4 | 2 |
| 22 | 98.6 | 99.1 | 2 |
| 23 | 92.1 | 146.1 | 2 |
| 24 | 99.1 | 142.8 | 2 |
| 25 | 96.6 | 91.7 | 2 |
| 26 | 96.9 | 91.4 | 2 |
| 27 | 179.2 | 92.0 | 2 |
| 28 | 98.5 | 91.7 | 2 |
| 29 | 95.0 | 88.8 | 2 |
| 30 | 95.0 | 93.4 | 2 |
[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
[xUnit.net 00:01:15.35] should be empty but had
[xUnit.net 00:01:15.35] 2
[xUnit.net 00:01:15.35] items and was
[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
[xUnit.net 00:01:15.35] Stack Trace:
[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] Output:
[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
[xUnit.net 00:01:15.35] |---|---|---|---|---|
[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35]
[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Process terminated.
A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
Error Message:
Shouldly.ShouldAssertException : unexpected
should be empty but had
2
items and was
["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Selection by origin: array 9, udt member 8, scalar 8
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
| `__CONTAINER` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
| `test` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
Unexpected divergences: 2; expected divergences: 0.
- UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
- UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
Test Run Failed.
Total tests: 14
Passed: 12
Failed: 2
Total time: 1.2601 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.2143 Minutes
@@ -1,3 +1,4 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
@@ -23,9 +24,55 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
internal static int ForwardOpenCount;
/// <summary>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
internal static int ForwardClosesSent;
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
internal static int ForwardClosesAcked;
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
internal static int RequestsSent;
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
private static long _exchangeCount;
private static long _exchangeTotalTicks;
private static long _exchangeMinTicks = long.MaxValue;
private static long _exchangeMaxTicks;
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static void ResetExchangeStats()
{
Interlocked.Exchange(ref _exchangeCount, 0);
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
}
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static ExchangeStats ReadExchangeStats()
{
var count = Interlocked.Read(ref _exchangeCount);
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
}
private static void RecordExchange(long ticks)
{
Interlocked.Increment(ref _exchangeCount);
Interlocked.Add(ref _exchangeTotalTicks, ticks);
long seen;
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
}
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
internal static int OpenAttempts;
@@ -246,14 +293,17 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
var started = Stopwatch.GetTimestamp();
try
{
return _o.Routing switch
var reply = _o.Routing switch
{
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
};
RecordExchange(Stopwatch.GetTimestamp() - started);
return reply;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
@@ -346,9 +396,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
{
var budget = TimeSpan.FromSeconds(1);
using var cts = new CancellationTokenSource(budget, _time);
Interlocked.Increment(ref ForwardClosesSent);
await open.Client.SendRRDataAsync(
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
budget, cts.Token).ConfigureAwait(false);
Interlocked.Increment(ref ForwardClosesAcked);
}
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
{
@@ -129,11 +129,9 @@ public sealed class AbCipDriverOptions
/// touching every other device on the same family.</param>
/// <param name="ConnectionSize">Per-device override for the Forward Open
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
/// versions or a custom tag-attribute path; current builds use the family profile default at
/// the wire layer regardless.</param>
/// profile's <c>DefaultConnectionSize</c>. The native transport honours it on the wire (its Forward
/// Open size). The libplctag transport ignores it: the libplctag 1.5.2 wrapper has no
/// <c>ConnectionSize</c> property, so libplctag opens with its own size for the <c>plc</c> attribute.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
@@ -0,0 +1,128 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable).</summary>
internal static int HandleOf(Tag tag) =>
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -12,12 +12,13 @@ internal static class LibplctagListingReader
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
return await LibplctagStatusTranslation.Wrap(async () =>
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
})).ConfigureAwait(false);
}
/// <summary>
@@ -14,6 +14,15 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
/// <param name="p">The tag creation parameters.</param>
public LibplctagTagRuntime(AbCipTagCreateParams p)
@@ -42,18 +51,52 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
public int GetStatus() =>
(int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
@@ -21,12 +21,13 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
var tag = _tag;
return await LibplctagStatusTranslation.Wrap(async () =>
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
})).ConfigureAwait(false);
}
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
@@ -41,7 +41,12 @@ public sealed record AbCipPlcFamilyProfile(
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
// Large Forward Open, matching libplctag: `plc=compactlogix` opens Large (0x5B, 4002) and the
// native transport must not pack into a third of the window (T21 latency harness: 504 cost ~3x
// the round trips per poll). A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses the Large open
// is covered by CipConnection's fallback — a regular open at min(size, 504) inside the same
// throttled attempt. An explicit ConnectionSize (driver options or per device) still wins.
DefaultConnectionSize: 4002,
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
@@ -13,6 +13,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
/// <param name="p">The parameters for tag creation.</param>
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
@@ -38,18 +47,52 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public int GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus());
public int GetStatus() =>
(int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
@@ -0,0 +1,128 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable).</summary>
internal static int HandleOf(Tag tag) =>
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -44,7 +44,7 @@ public static class KnownProfiles
public static readonly AbServerProfile CompactLogix = new(
Family: AbCipPlcFamily.CompactLogix,
ComposeProfile: "compactlogix",
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
Notes: "Large Forward Open (4002) like libplctag since T21; ab_server accepts any size, so the regular-open fallback is unit-tested only.");
/// <summary>Gets the Micro800 profile.</summary>
public static readonly AbServerProfile Micro800 = new(
@@ -11,7 +11,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin);
/// <summary>What one transport observed for one tag.</summary>
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null)
/// <param name="TypeCode">The CIP type code the reply carried, where the transport exposes it (native only: libplctag.NET
/// 1.5.2 does not surface it).</param>
internal sealed record ReadObservation(
int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null, ushort? TypeCode = null)
{
public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
}
@@ -27,6 +30,13 @@ internal sealed class TransportObservations
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
public int TemplatesTruncated { get; set; }
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
/// <summary>
/// libplctag only: a SECOND paced read of every selected tag, taken after the first pass and before native
/// starts (T21 press pass 1). A tag whose two reads differ is live — its value moves on its own — and is
/// compared with native structurally, not by value. Empty on the native side.
/// </summary>
public Dictionary<string, ReadObservation> ProbeReads { get; } = new(StringComparer.Ordinal);
}
/// <summary>The outcome of one whole differential run.</summary>
@@ -40,12 +50,27 @@ internal sealed class DifferentialResult
/// <summary>Native Forward Opens counted between the counter reset and the anchor release.</summary>
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the
/// same window as <see cref="NativeForwardOpens"/> — the anchor's release included (T21 review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native session was released and drained (null when unobservable).</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>TCP connections to the endpoint before libplctag started, and once its handles were disposed.</summary>
public required int? ConnectionsBaseline { get; init; }
public required int? ConnectionsBeforeNative { get; init; }
public required TimeSpan LibplctagDrainWait { get; init; }
/// <summary>The endpoint's resolved addresses, and this machine's connections to it before the run (null when
/// unobservable). Reused by the latency comparison's barriers, which must not start alongside a session.</summary>
public required IReadOnlySet<IPAddress> Addresses { get; init; }
public required IReadOnlySet<IPEndPoint>? BaselineEndpoints { get; init; }
}
/// <summary>Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget.</summary>
internal readonly record struct NativeCloses(int Sent, int Acked);
/// <summary>
/// The run stopped at its first connection-level failure, or when the native side opened more than once.
/// Thrown, never swallowed: every live test then fails with this message.
@@ -98,11 +123,14 @@ internal static class DifferentialRun
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
var drain = Stopwatch.StartNew();
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, addresses, baseline, ct).ConfigureAwait(false);
var beforeNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "libplctag", ct).ConfigureAwait(false);
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
// ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ----
var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false);
return new DifferentialResult
{
@@ -111,9 +139,13 @@ internal static class DifferentialRun
Native = native,
Selection = selection,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
ConnectionsBaseline = baseline?.Count,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
Addresses = addresses,
BaselineEndpoints = baseline,
};
}
@@ -123,35 +155,76 @@ internal static class DifferentialRun
/// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
/// every libplctag operation on ONE session — the mirror of the native anchor lease.
/// </summary>
internal static async Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
internal static Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
DifferentialTarget target, CancellationToken ct)
{
const string phase = "libplctag";
var lib = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
using var anchor = factories.Tags.Create(target.Params("@tags"));
return WithLibplctagAnchorAsync(phase, target, "@tags", async () =>
{
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
var selection = Select(target, lib);
await ReadTagsAsync(phase, target, factories.Tags, selection, lib.Reads, afterEach: null, ct).ConfigureAwait(false);
// Volatility probe: read every tag again, paced, so a value that moves on its own (a live analog) is
// recognised as live rather than reported as a transport divergence when native reads it later.
await ReadTagsAsync(phase, target, factories.Tags, selection, lib.ProbeReads, afterEach: null, ct).ConfigureAwait(false);
return ((TransportObservations)lib, selection);
}, ct);
}
/// <summary>
/// Holds one libplctag handle on <paramref name="anchorTag"/> for the whole of <paramref name="body"/>, so every
/// libplctag operation in it shares ONE session and ONE Forward Open. A failure to create the anchor aborts
/// before <paramref name="body"/> runs; the anchor is disposed when <paramref name="body"/> ends, either way.
/// Shared by the differential (anchor <c>@tags</c>) and the latency comparison (anchor = its first tag).
/// </summary>
internal static async Task<T> WithLibplctagAnchorAsync<T>(
string phase, DifferentialTarget target, string anchorTag, Func<Task<T>> body, CancellationToken ct)
{
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
using var anchor = factories.Tags.Create(target.Params(anchorTag));
try
{
await anchor.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw Abort(phase, "anchor session (@tags)", ex);
throw Abort(phase, $"anchor session ({anchorTag})", ex);
}
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
var selection = Select(target, lib);
await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false);
return (lib, selection);
return await body().ConfigureAwait(false);
}
/// <summary>The native phase alone; internal so the offline abort test can drive it against a fake endpoint.</summary>
internal static async Task<(TransportObservations Observations, int ForwardOpens)> RunNativePhaseAsync(
internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync(
DifferentialTarget target, IReadOnlyList<SelectedTag> selection, CancellationToken ct)
{
const string phase = "native";
var native = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
{
await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
return (native, forwardOpens, forwardCloses);
}
/// <summary>
/// Runs <paramref name="body"/> with every native operation sharing ONE connection, held open by an anchor
/// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one
/// more attempt, and <paramref name="body"/> receives a check to call after each operation that aborts the
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
/// <c>finally</c>. Shared by the differential and the latency comparison.
/// </summary>
/// <returns>The body's result, the Forward Opens counted between the reset and the anchor release, and the
/// Forward Closes counted between the reset and the END of the anchor release (which awaits the close).</returns>
internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync<T>(
string phase, DifferentialTarget target, Func<Action<string>, Task<T>> body)
{
var anchorParams = target.Params("@tags");
void CheckOpens(string operation)
@@ -164,28 +237,33 @@ internal static class DifferentialRun
}
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0);
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
T result;
int forwardOpens;
NativeCloses forwardCloses;
try
{
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
try
{
await BrowseAsync(phase, target, factories.Enumerators, native, CheckOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, CheckOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native, CheckOpens, ct).ConfigureAwait(false);
result = await body(CheckOpens).ConfigureAwait(false);
}
finally
{
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
forwardCloses = new NativeCloses(
Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked));
}
}
finally
{
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
}
return (native, forwardOpens);
return (result, forwardOpens, forwardCloses);
}
private static async Task BrowseAsync(
@@ -357,7 +435,7 @@ internal static class DifferentialRun
private static async Task ReadTagsAsync(
string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
TransportObservations into, Action<string>? afterEach, CancellationToken ct)
Dictionary<string, ReadObservation> into, Action<string>? afterEach, CancellationToken ct)
{
foreach (var tag in selection)
{
@@ -396,7 +474,8 @@ internal static class DifferentialRun
decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3)
}
}
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError);
into[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError,
(runtime as NativeAbCipTagRuntime)?.TypeCode);
}
finally
{
@@ -422,11 +501,11 @@ internal static class DifferentialRun
_ => true,
};
private static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
internal static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " +
"Stopped on the first connection-level failure; nothing was retried.", ex);
private static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
internal static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask;
/// <summary>Resolves the endpoint once, bounded (an IP literal needs no lookup).</summary>
@@ -448,14 +527,16 @@ internal static class DifferentialRun
}
/// <summary>
/// libplctag closes its session on a background thread some time after the last handle is destroyed.
/// Wait until no TCP connection to the endpoint remains that was not already there before the run
/// (compared by local endpoint, not by count — review r1 M6), so the two transports are never
/// A transport closes its session asynchronously (libplctag on a background thread some time after the last
/// handle is destroyed). Wait until no TCP connection to the endpoint remains that was not already there
/// before the run (compared by local endpoint, not by count — review r1 M6), so two sessions are never
/// connected to the controller at the same time.
/// </summary>
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, CancellationToken ct)
/// <param name="whose">Names the session waited for, in the abort message.</param>
/// <returns>The connection count observed when the next phase may start (null when unobservable).</returns>
internal static async Task<int?> WaitForSessionsToCloseAsync(
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, string whose,
CancellationToken ct)
{
if (baseline is null)
{
@@ -471,8 +552,8 @@ internal static class DifferentialRun
if (added.Count == 0) return now.Count;
if (deadline.Elapsed > LibplctagDrainBudget)
throw new DifferentialAbortedException(
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
$"handles were disposed (local {string.Join(", ", added)}); refusing to start the native phase alongside it.");
$"{whose}'s session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after it was " +
$"released (local {string.Join(", ", added)}); refusing to start the next phase alongside it.");
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
}
}
@@ -514,6 +595,65 @@ internal static class DifferentialRun
? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
: Equals(a, b);
/// <summary>
/// True when the libplctag volatility probe saw the value move by itself: both libplctag reads succeeded and
/// their bytes differ. A missing or failed probe is never live, so such a tag keeps the full value comparison.
/// </summary>
internal static bool IsLive(ReadObservation lib, ReadObservation? probe) =>
probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null
&& !lib.Raw.AsSpan().SequenceEqual(probe.Raw);
/// <summary>
/// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise
/// over the decoded values:
/// <list type="bullet">
/// <item>an element EQUAL in both libplctag reads did not move, so native must read it equal too —
/// otherwise <c>**NO: stable element i**</c> (a stride, offset or byte-order defect lands here);</item>
/// <item>an element that moved is excused its value, but native must not read a NaN/∞ that neither
/// libplctag read saw — <c>**NO: non-finite element i**</c>.</item>
/// </list>
/// Null means the check passed. Shapes must already agree between libplctag and native.
/// </summary>
internal static string? LiveElementMismatch(object? lib, object? probe, object? nat)
{
var l = Elements(lib);
var p = Elements(probe);
var n = Elements(nat);
if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it
if (l.Length != n.Length) return "**NO: shape**";
for (var i = 0; i < l.Length; i++)
{
if (Equals(l[i], p[i]))
{
if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**";
}
else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i]))
{
return $"**NO: non-finite element {i}**";
}
}
return null;
}
private static object?[] Elements(object? v) => v is Array a ? a.Cast<object?>().ToArray() : [v];
private static bool IsFinite(object? v) => v switch
{
float f => float.IsFinite(f),
double d => double.IsFinite(d),
_ => true,
};
/// <summary>The same decoded shape: both scalars of one CLR type, or arrays of one element type and length.</summary>
internal static bool SameShape(object? a, object? b) => (a, b) switch
{
(null, null) => true,
(Array x, Array y) => x.GetType() == y.GetType() && x.Length == y.Length,
(Array, _) or (_, Array) => false,
(not null, not null) => a.GetType() == b.GetType(),
_ => false,
};
/// <summary>Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3).</summary>
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
a is not null && b is not null
@@ -102,7 +102,8 @@ public sealed class DifferentialSafetyTests
[Fact]
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
{
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar);
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar,
DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar);
try
{
Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1");
@@ -115,10 +116,29 @@ public sealed class DifferentialSafetyTests
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null);
var t = DifferentialTarget.FromEnvironment()!;
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null);
var t =DifferentialTarget.FromEnvironment()!;
t.MaxTags.ShouldBe(500);
t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates);
t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs);
t.LatencyTags.ShouldBe(100);
t.LatencyPasses.ShouldBe(30);
t.LatencyPassGapMs.ShouldBe(200);
// Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings.
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0");
var lat = DifferentialTarget.FromEnvironment()!;
(lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0));
}
finally
{
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <item><c>AB_DIFF_MAX_TEMPLATES</c> — cap on template reads per transport, default 50, at most 500.</item>
/// <item><c>AB_DIFF_PACE_MS</c> — delay before every harness-issued request, default 20 ms; 0 for ab_server.</item>
/// <item><c>AB_DIFF_COUNT_OPENS</c> — <c>1</c> ⇒ assert exactly one native Forward Open over the run.</item>
/// <item><c>AB_LAT_TAGS</c> — latency comparison: tags read per pass, default 100, at most 200 (higher fails fast).</item>
/// <item><c>AB_LAT_PASSES</c> — latency comparison: passes per transport, default 30, at most 50.</item>
/// <item><c>AB_LAT_PASS_GAP_MS</c> — latency comparison: pause between passes, default 200 ms, at most 10000.</item>
/// </list>
/// </summary>
internal sealed record DifferentialTarget(
@@ -25,7 +28,10 @@ internal sealed record DifferentialTarget(
int MaxTags,
bool CountOpens,
int MaxTemplates = DifferentialTarget.DefaultMaxTemplates,
int PaceMs = DifferentialTarget.DefaultPaceMs)
int PaceMs = DifferentialTarget.DefaultPaceMs,
int LatencyTags = DifferentialTarget.DefaultLatencyTags,
int LatencyPasses = DifferentialTarget.DefaultLatencyPasses,
int LatencyPassGapMs = DifferentialTarget.DefaultLatencyPassGapMs)
{
public const string EndpointVar = "AB_DIFF_ENDPOINT";
public const string PathVar = "AB_DIFF_PATH";
@@ -35,6 +41,16 @@ internal sealed record DifferentialTarget(
public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
public const string MaxTemplatesVar = "AB_DIFF_MAX_TEMPLATES";
public const string PaceVar = "AB_DIFF_PACE_MS";
public const string LatencyTagsVar = "AB_LAT_TAGS";
public const string LatencyPassesVar = "AB_LAT_PASSES";
public const string LatencyPassGapVar = "AB_LAT_PASS_GAP_MS";
/// <summary>Latency comparison ceilings: a production press is the target, and each pass is one burst.</summary>
public const int LatencyTagsCeiling = 200;
public const int LatencyPassesCeiling = 50;
public const int DefaultLatencyTags = 100;
public const int DefaultLatencyPasses = 30;
public const int DefaultLatencyPassGapMs = 200;
/// <summary>Hard ceiling on <c>AB_DIFF_MAX_TAGS</c> / <c>AB_DIFF_MAX_TEMPLATES</c>: a production press is the target.</summary>
public const int Ceiling = 500;
@@ -86,8 +102,13 @@ internal sealed record DifferentialTarget(
if (tags is { Count: > Ceiling })
throw new InvalidOperationException($"{TagsVar} names {tags.Count} tags; the ceiling is {Ceiling}.");
var latencyTags = BoundedInt(LatencyTagsVar, DefaultLatencyTags, 1, LatencyTagsCeiling);
var latencyPasses = BoundedInt(LatencyPassesVar, DefaultLatencyPasses, 1, LatencyPassesCeiling);
var latencyGap = BoundedInt(LatencyPassGapVar, DefaultLatencyPassGapMs, 0, 10_000);
var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace);
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace,
latencyTags, latencyPasses, latencyGap);
}
/// <summary>Reads an integer knob; out of range FAILS rather than clamping, so a typo never silently widens a press run.</summary>
@@ -0,0 +1,134 @@
using System.Globalization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>
/// Task 21 latency comparison: a fixed set of atomic tags polled through libplctag, then through the native
/// transport, each pass issuing every read at once. Pass criterion: native p95 ≤ libplctag p95 × 1.10.
/// <para>Gated by the same variables as the differential (<c>AB_DIFF_ENDPOINT</c> absent ⇒ skip), in the same
/// serialized collection, and read-only under the same source/IL guard
/// (<see cref="TransportDifferentialTests.The_harness_is_read_only_by_construction"/>). It runs AFTER the
/// differential's one run, reusing its browse for tag selection, so the target sees one listing per transport,
/// not two. Knobs: <c>AB_LAT_TAGS</c>, <c>AB_LAT_PASSES</c>, <c>AB_LAT_PASS_GAP_MS</c> (see
/// <see cref="DifferentialTarget"/>).</para>
/// </summary>
[Collection(DifferentialCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
{
private const double Tolerance = 1.10;
private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(10);
[Fact]
public async Task Native_poll_p95_is_within_ten_percent_of_libplctag()
{
var target = DifferentialTarget.FromEnvironment();
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
var differential = await fixture.GetAsync();
using var cts = new CancellationTokenSource(RunBudget);
var r = await PollLatencyRun.ExecuteAsync(target, differential, cts.Token);
Log($"## Latency — {r.Target}");
Log($"Tags per pass: {r.Tags.Count} ({r.DistinctTags} distinct" +
(r.Repeated ? $", REPEATED cyclically to reach {DifferentialTarget.LatencyTagsVar}={r.Target.LatencyTags}" : "") +
$"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms.");
Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}")));
if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}");
Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " +
$"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
Log("");
var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50);
var libP95 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 95);
var natP50 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 50);
var natP95 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 95);
var limit = libP95 * Tolerance;
Log("| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |");
Log("|---|---|---|---|---|---|---|---|");
Log(Row("libplctag", r.Libplctag, libP50, libP95));
Log(Row("native", r.Native, natP50, natP95));
Log("");
Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " +
(natP95 <= limit ? "PASS" : "**FAIL**"));
Log("");
// Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the
// round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall.
Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |");
Log("|---|---|---|---|---|---|---|");
for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++)
{
var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null;
var nat = i < r.Native.Count ? r.Native[i] : null;
Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " +
$"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " +
$"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |");
}
foreach (var p in r.Problems) Log($"- PROBLEM: {p}");
r.Problems.ShouldBeEmpty();
r.Libplctag.Count.ShouldBe(r.Target.LatencyPasses);
r.Native.Count.ShouldBe(r.Target.LatencyPasses);
r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed");
r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed");
if (r.Target.CountOpens)
{
r.NativeForwardOpens.ShouldBe(1);
r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close");
}
if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports");
// Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them.
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}");
}
/// <summary>Offline: the tag pick takes atomic scalars only, and repeats a short set deliberately and visibly.</summary>
[Fact]
public void Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly()
{
var browsed = new TransportObservations();
browsed.Browse.Add(new AbCipDiscoveredTag("D", null, AbCipDataType.DInt, false));
browsed.Browse.Add(new AbCipDiscoveredTag("R", null, AbCipDataType.Real, false));
browsed.Browse.Add(new AbCipDiscoveredTag("S", null, AbCipDataType.String, false));
browsed.Browse.Add(new AbCipDiscoveredTag("T", null, AbCipDataType.Dt, false));
browsed.Browse.Add(new AbCipDiscoveredTag("A", null, AbCipDataType.DInt, false, ElementCount: 4, IsArray: true));
browsed.Browse.Add(new AbCipDiscoveredTag("Sys", null, AbCipDataType.DInt, false, IsSystemTag: true));
var target = new DifferentialTarget("127.0.0.1", 1, "1,0", AbCipPlcFamily.CompactLogix, null, 200, false, LatencyTags: 5);
var (tags, distinct, repeated) = PollLatencyRun.PickTags(target, browsed);
distinct.ShouldBe(2);
repeated.ShouldBeTrue();
tags.Select(t => t.Name).ShouldBe(["D", "R", "D", "R", "D"]);
var (one, oneDistinct, oneRepeated) = PollLatencyRun.PickTags(target with { LatencyTags = 1 }, browsed);
one.ShouldHaveSingleItem().Name.ShouldBe("D");
oneDistinct.ShouldBe(1);
oneRepeated.ShouldBeFalse();
PollLatencyRun.Percentile([TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2)], 50)
.ShouldBe(2);
PollLatencyRun.Percentile(Enumerable.Range(1, 20).Select(i => TimeSpan.FromMilliseconds(i)), 95).ShouldBe(19);
}
private static string Row(string name, IReadOnlyList<LatencyPass> passes, double p50, double p95)
{
var walls = passes.Select(p => p.Wall.TotalMilliseconds).ToList();
var requests = passes.Where(p => p.Requests is not null).Select(p => p.Requests!.Value).Order().ToList();
var medianRequests = requests.Count == 0 ? "n/a" : requests[requests.Count / 2].ToString(CultureInfo.InvariantCulture);
return $"| {name} | {passes.Count} | {Ms(p50)} | {Ms(p95)} | {Ms(walls.Min())} | {Ms(walls.Max())} | {medianRequests} | " +
$"{passes.Sum(p => p.TagFailures)} |";
}
private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) =>
s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}";
private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture);
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
}
@@ -0,0 +1,268 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>One timed burst: every tag read once, concurrently.</summary>
/// <param name="Requests">Native only: <see cref="CipConnection.RequestsSent"/> delta over the pass (keep-alives included).</param>
/// <param name="TagFailures">Reads that ended with a tag-level status (a connection-level one aborts instead).</param>
/// <param name="Gc">Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here).</param>
/// <param name="Rtt">Native only: round trip of each CIP exchange in the pass, from <see cref="CipConnection"/> (a slow
/// controller shows here; a slow pass with fast exchanges points at the client).</param>
internal sealed record LatencyPass(
int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null);
/// <summary>GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports).</summary>
internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2)
{
public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2);
public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}";
}
/// <summary>The outcome of the latency comparison.</summary>
internal sealed class LatencyResult
{
public required DifferentialTarget Target { get; init; }
/// <summary>The fixed set read every pass, in order (a name appears more than once only when <see cref="Repeated"/>).</summary>
public required IReadOnlyList<SelectedTag> Tags { get; init; }
public required int DistinctTags { get; init; }
public required bool Repeated { get; init; }
/// <summary>Names libplctag could not read at setup (tag-level status): dropped from BOTH transports' sets.</summary>
public required IReadOnlyList<string> Dropped { get; init; }
public required IReadOnlyList<LatencyPass> Libplctag { get; init; }
public required IReadOnlyList<LatencyPass> Native { get; init; }
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native phase's session was released and drained.</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>Native requests spent creating the runtimes (one read each, before any timed pass).</summary>
public required int NativeSetupRequests { get; init; }
public required List<string> Problems { get; init; }
}
/// <summary>
/// Task 21's poll-latency comparison, read-only and built on the differential's machinery: the same
/// endpoint parsing, the same anchors (one libplctag handle, one native lease), the same open-attempt budget,
/// the same connection barrier and the same stop-on-first-connection-failure rule.
/// <para>Order: the differential's run (its browse supplies the tags — no second listing), a barrier until its
/// native session is gone, every libplctag pass with every handle then disposed, a barrier until libplctag's
/// session is gone, then every native pass under one anchor lease. The two transports never overlap.</para>
/// <para>Per transport: create and read every tag once (untimed, paced by <c>AB_DIFF_PACE_MS</c>), then
/// <c>AB_LAT_PASSES</c> timed passes, each issuing all <c>AB_LAT_TAGS</c> reads at once (<c>Task.WhenAll</c>)
/// so the native shared scheduler can pack them into Multiple Service Packets as libplctag packs internally,
/// with <c>AB_LAT_PASS_GAP_MS</c> of quiet between passes.</para>
/// </summary>
internal static class PollLatencyRun
{
private static readonly HashSet<AbCipDataType> Atomic =
[
AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt, AbCipDataType.LInt,
AbCipDataType.USInt, AbCipDataType.UInt, AbCipDataType.UDInt, AbCipDataType.ULInt,
AbCipDataType.Real, AbCipDataType.LReal,
];
/// <summary>
/// The fixed tag set: atomic, non-array scalars, from the differential's libplctag browse — or, when
/// <c>AB_DIFF_TAGS</c> is set, those of its names whose browsed type is an atomic scalar. When fewer
/// distinct tags exist than <c>AB_LAT_TAGS</c>, the set is REPEATED cyclically up to the count (each
/// repeat is its own runtime/handle on the same name) and reported as such — never silently.
/// </summary>
internal static (IReadOnlyList<SelectedTag> Tags, int Distinct, bool Repeated) PickTags(
DifferentialTarget target, TransportObservations browsed)
{
var candidates = DifferentialRun.Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed)
.Where(t => !t.IsArray && t.Type is { } type && Atomic.Contains(type))
.DistinctBy(t => t.Name)
.Take(target.LatencyTags)
.ToList();
if (candidates.Count == 0 || candidates.Count >= target.LatencyTags) return (candidates, candidates.Count, false);
var repeated = Enumerable.Range(0, target.LatencyTags).Select(i => candidates[i % candidates.Count]).ToList();
return (repeated, candidates.Count, true);
}
public static async Task<LatencyResult> ExecuteAsync(DifferentialTarget target, DifferentialResult differential, CancellationToken ct)
{
var (picked, distinct, repeated) = PickTags(target, differential.Libplctag);
if (picked.Count == 0)
throw new InvalidOperationException("The browse offered no atomic scalar tags to time; nothing was read.");
// The differential's native session must be gone before libplctag connects.
await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "the differential's native", ct).ConfigureAwait(false);
// ---- libplctag: setup, passes, every handle disposed (anchor last). ----
var dropped = new List<string>();
var libFactory = AbCipTransportFactories.For(AbCipTransport.Libplctag).Tags;
// Anchor on a tag the differential already read Ok through libplctag, so the anchor is never the refusal.
var anchorTag = picked.FirstOrDefault(t => differential.Libplctag.Reads.TryGetValue(t.Name, out var o) && o.Succeeded)
?? picked[0];
var (libPasses, tags) = await DifferentialRun.WithLibplctagAnchorAsync("latency libplctag", target, anchorTag.Name, async () =>
{
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
try
{
foreach (var tag in picked)
{
var rt = await CreateAsync("latency libplctag", target, libFactory, tag, ct).ConfigureAwait(false);
if (rt is null) { dropped.Add(tag.Name); continue; }
runtimes.Add((tag, rt));
}
var passes = await RunPassesAsync("latency libplctag", target, runtimes, countRequests: false, afterEach: null, ct)
.ConfigureAwait(false);
return (passes, (IReadOnlyList<SelectedTag>)runtimes.Select(r => r.Tag).ToList());
}
finally
{
foreach (var (_, rt) in runtimes) rt.Dispose();
}
}, ct).ConfigureAwait(false);
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "libplctag", ct).ConfigureAwait(false);
// ---- native: the same (post-drop) set, under one anchor lease. ----
var problems = new List<string>();
var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens =>
{
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
try
{
var before = Volatile.Read(ref CipConnection.RequestsSent);
foreach (var tag in tags)
{
var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false);
checkOpens($"setup of '{tag.Name}'");
if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; }
runtimes.Add((tag, rt));
}
var setup = Volatile.Read(ref CipConnection.RequestsSent) - before;
var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct)
.ConfigureAwait(false);
return (passes, setup);
}
finally
{
foreach (var (_, rt) in runtimes) rt.Dispose();
}
}).ConfigureAwait(false);
// ---- Barrier: the native session must be gone before the run reports (review r1 M9). ----
var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false);
return new LatencyResult
{
Target = target,
Tags = tags,
DistinctTags = distinct,
Repeated = repeated,
Dropped = dropped,
Libplctag = libPasses,
Native = nativePasses,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
NativeSetupRequests = setupRequests,
Problems = problems,
};
}
/// <summary>Create + first read of one tag. Null on a tag-level refusal; a connection-level failure aborts.</summary>
private static async Task<IAbCipTagRuntime?> CreateAsync(
string phase, DifferentialTarget target, IAbCipTagFactory factory, SelectedTag tag, CancellationToken ct)
{
var rt = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type));
try
{
await DifferentialRun.PaceAsync(target, ct).ConfigureAwait(false);
await rt.InitializeAsync(ct).ConfigureAwait(false);
if (rt.GetStatus() == (int)AbStatus.Ok) return rt;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
if (DifferentialRun.IsConnectionLevel(ex))
{
rt.Dispose();
throw DifferentialRun.Abort(phase, $"setup of '{tag.Name}'", ex);
}
}
catch
{
rt.Dispose();
throw;
}
rt.Dispose();
return null;
}
private static async Task<List<LatencyPass>> RunPassesAsync(
string phase, DifferentialTarget target, List<(SelectedTag Tag, IAbCipTagRuntime Runtime)> runtimes,
bool countRequests, Action<string>? afterEach, CancellationToken ct)
{
var passes = new List<LatencyPass>(target.LatencyPasses);
for (var p = 1; p <= target.LatencyPasses; p++)
{
if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false);
var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent);
CipConnection.ResetExchangeStats();
var gcBefore = GcDelta.Now();
var clock = Stopwatch.StartNew();
var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false);
clock.Stop();
var gc = GcDelta.Now() - gcBefore;
var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null;
var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore;
var failures = 0;
for (var i = 0; i < outcomes.Length; i++)
{
if (outcomes[i] is { } ex)
{
if (DifferentialRun.IsConnectionLevel(ex))
throw DifferentialRun.Abort(phase, $"pass {p}, tag '{runtimes[i].Tag.Name}'", ex);
failures++;
}
else if (runtimes[i].Runtime.GetStatus() != (int)AbStatus.Ok)
{
failures++;
}
}
afterEach?.Invoke($"pass {p}");
passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt));
}
return passes;
}
private static async Task<Exception?> ReadOnceAsync(IAbCipTagRuntime runtime, CancellationToken ct)
{
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
return ex;
}
}
/// <summary>Nearest-rank percentile (p in (0,100]) of the pass wall times, in milliseconds.</summary>
internal static double Percentile(IEnumerable<TimeSpan> walls, double p)
{
var sorted = walls.Select(w => w.TotalMilliseconds).Order().ToList();
if (sorted.Count == 0) throw new InvalidOperationException("No passes to take a percentile of.");
var rank = (int)Math.Ceiling(p / 100.0 * sorted.Count);
return sorted[Math.Clamp(rank, 1, sorted.Count) - 1];
}
}
@@ -95,25 +95,37 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}");
Log("Selection by origin: " + string.Join(", ",
r.Selection.GroupBy(t => t.Origin).Select(g => $"{g.Key} {g.Count()}")));
Log("| Tag | Type | libplctag status | native status | Equal? |");
Log("|---|---|---|---|---|");
Log("| Tag | Type | libplctag status | native status | libplctag probe | Equal? |");
Log("|---|---|---|---|---|---|");
var unexpected = new List<string>();
var expected = new List<string>();
var live = new List<string>();
foreach (var tag in r.Selection)
{
var lib = r.Libplctag.Reads[tag.Name];
var nat = r.Native.Reads[tag.Name];
var verdict = Compare(tag, lib, nat);
if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}");
else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})");
var probe = r.Libplctag.ProbeReads.GetValueOrDefault(tag.Name);
var verdict = Compare(tag, lib, nat, probe);
switch (Classify(verdict))
{
case Verdict.Expected: expected.Add($"{tag.Name}: {verdict}"); break;
case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break;
case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe):
unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})");
break;
case Verdict.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break;
}
Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |");
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {ProbeColumn(lib, probe)} | {verdict} |");
}
Log("");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}.");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}; " +
$"live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every " +
$"element that held still): {live.Count} of {r.Selection.Count}.");
foreach (var e in expected) Log($"- expected: {e}");
foreach (var l in live) Log($"- live: {l}");
foreach (var u in unexpected) Log($"- UNEXPECTED: {u}");
var coverage = DifferentialRun.SelectionCoverageProblems(r.Target, r.Libplctag, r.Selection);
@@ -138,6 +150,21 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
}
/// <summary>
/// T21 press pass 1 ended in "A callback was made on a garbage collected delegate": libplctag tags whose
/// initialisation failed (NotFound tags, refused latency setups) were left native-side with a live callback.
/// Every libplctag tag this harness creates goes through the driver's adapters, which now destroy such a tag
/// (<see cref="LibplctagOrphanRelease"/>); this pins that none had to fall back to being rooted instead.
/// </summary>
[Fact]
public async Task No_failed_libplctag_tag_is_left_with_a_live_native_callback()
{
await RunOrSkipAsync();
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
}
[Fact]
public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run()
{
@@ -149,6 +176,25 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.NativeForwardOpens.ShouldBe(1);
}
/// <summary>
/// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped
/// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to
/// the target left once the anchor is released.
/// </summary>
[Fact]
public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close()
{
var r = await RunOrSkipAsync();
Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline);
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " +
"trusted when the differential runs alone.");
r.NativeForwardCloses.Sent.ShouldBe(1);
}
/// <summary>
/// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any
/// source file of this harness names a write-capable member, and, independently of the source text,
@@ -169,6 +215,10 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var dir = Path.GetDirectoryName(ThisFile())!;
var sources = Directory.GetFiles(dir, "*.cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
// Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope,
// so moving either out of this folder (or namespace) fails the guard instead of silently escaping it.
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs");
var textHits = (from f in sources
let text = File.ReadAllText(f)
from token in forbidden
@@ -179,6 +229,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests));
harnessTypes.ShouldContain(typeof(PollLatencyRun));
var callHits = (from t in harnessTypes
from m in MethodsOf(t)
from callee in CalleesOf(m)
@@ -217,13 +269,89 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**");
}
/// <summary>
/// Offline pin of the live-drift handling (T21 press pass 1): a value that moved between the two libplctag
/// reads is compared structurally, while a value libplctag read stably and native read differently is still
/// an unexpected divergence. Removing the live handling turns the first assertion into "**NO: raw**".
/// </summary>
[Fact]
public void A_live_value_is_compared_structurally_and_a_stable_one_by_value()
{
var real = new SelectedTag("WW_M4ActualTemp", AbCipDataType.Real, 1, false, "scalar");
ReadObservation Obs(float v) => new((int)AbStatus.Ok, null, BitConverter.GetBytes(v), v);
var lib = Obs(384.88684f);
var probe = Obs(384.95f);
var nat = Obs(385.13782f) with { TypeCode = 0xCA };
// Live: the libplctag value moved by itself, so a different native value is not a divergence.
DifferentialRun.IsLive(lib, probe).ShouldBeTrue();
Compare(real, lib, nat, probe).ShouldBe("live");
Classify(Compare(real, lib, nat, probe)).ShouldBe(Verdict.Live);
// ...but everything structural still has to match.
Compare(real, lib, nat with { Status = (int)AbStatus.NotFound, Error = "NotFound" }, probe).ShouldBe("**NO: status**");
Compare(real, lib, nat with { Raw = new byte[8] }, probe).ShouldBe("**NO: raw length**");
Compare(real, lib, nat with { Raw = null }, probe).ShouldBe("**NO: raw length**");
Compare(real, lib with { TypeCode = 0xC4 }, nat, probe).ShouldBe("**NO: type code**");
Compare(real, lib, nat with { Decoded = 385 }, probe).ShouldBe("**NO: shape**");
Compare(real, lib, nat with { Decoded = new[] { 385f } }, probe).ShouldBe("**NO: shape**");
Compare(real, lib, nat with { DecodeError = "boom" }, probe).ShouldBe("**NO: decode**");
Classify("**NO: raw length**").ShouldBe(Verdict.Unexpected);
var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
var arrLib = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { 1f, 2f });
var arrProbe = arrLib with { Raw = [1, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 1.1f, 2f } };
// Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1.
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live");
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe)
.ShouldBe("**NO: stable element 1**");
Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**");
// Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect
// that shifts the stable element must FAIL even though the tag is live.
var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member");
ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v);
var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f);
var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f);
Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live");
var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe);
shifted.ShouldBe("**NO: stable element 0**");
Classify(shifted).ShouldBe(Verdict.Unexpected);
Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe)
.ShouldBe("**NO: non-finite element 1**");
Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**");
// Stable: the two libplctag reads agree, so native reading anything else is a real decoding divergence.
var stableProbe = lib with { };
DifferentialRun.IsLive(lib, stableProbe).ShouldBeFalse();
Compare(real, lib, nat, stableProbe).ShouldBe("**NO: raw**");
Classify(Compare(real, lib, nat, stableProbe)).ShouldBe(Verdict.Unexpected);
Compare(real, lib, lib with { Decoded = 1f }, stableProbe).ShouldBe("**NO: value**");
Compare(real, lib, lib with { }, stableProbe).ShouldBe("yes");
Classify("yes").ShouldBe(Verdict.Equal);
// A missing or failed probe is never live: the full value comparison applies.
Compare(real, lib, nat, null).ShouldBe("**NO: raw**");
Compare(real, lib, nat, new ReadObservation((int)AbStatus.Timeout, "Timeout", null, null)).ShouldBe("**NO: raw**");
}
private async Task<DifferentialResult> RunOrSkipAsync()
{
if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason);
return await fixture.GetAsync();
}
internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat)
internal enum Verdict { Equal, Expected, Live, Unexpected }
/// <summary>Which list a verdict string belongs to. Live tags are neither expected nor unexpected divergences.</summary>
internal static Verdict Classify(string verdict) =>
verdict == "yes" ? Verdict.Equal
: verdict == "live" ? Verdict.Live
: verdict.StartsWith("expected", StringComparison.Ordinal) ? Verdict.Expected
: Verdict.Unexpected;
/// <param name="libProbe">libplctag's second (volatility-probe) read of the tag; null ⇒ treated as stable.</param>
internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat, ReadObservation? libProbe = null)
{
// Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway.
if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok)
@@ -232,21 +360,44 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
if (!lib.Succeeded || !nat.Succeeded)
return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**";
if (lib.DecodeError is not null || nat.DecodeError is not null) return "**NO: decode**";
if (DifferentialRun.IsLive(lib, libProbe))
{
// The value moved between libplctag's own two reads, so native reading a third value proves nothing.
// Everything that does not depend on the value must still agree.
if (nat.Raw is null || nat.Raw.Length != lib.Raw!.Length) return "**NO: raw length**";
if (lib.TypeCode is { } lt && nat.TypeCode is { } nt && lt != nt) return "**NO: type code**";
if (tag.Type is null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise
if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**";
// Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still
// must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live".
return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "live";
}
if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**";
if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**";
return "yes";
}
private static string Detail(ReadObservation o) =>
o.Error ?? o.DecodeError ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}";
/// <param name="full">Every raw byte and every element (live tags: the evidence must be re-checkable by hand).</param>
private static string Detail(ReadObservation o, bool full = false) =>
o.Error ?? o.DecodeError ??
$"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}";
private static string Hex(byte[]? raw) =>
raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : "");
private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) =>
probe is null ? "-"
: DifferentialRun.IsLive(lib, probe) ? "**live**"
: probe.Succeeded && lib.Succeeded ? "stable"
: StatusName(probe.Status);
private static string Show(object? v) => v switch
private static string Hex(byte[]? raw, bool full = false) =>
raw is null ? "-"
: full || raw.Length <= 32 ? Convert.ToHexString(raw)
: Convert.ToHexString(raw.AsSpan(0, 32)) + "…";
private static string Show(object? v, bool full = false) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(full ? int.MaxValue : 8).Select(x => Show(x))) +
(!full && a.Length > 8 ? ",…" : "") + "]",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
@@ -0,0 +1,43 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// T21 press pass 1 crash, the variant that killed the test host: a tag the controller does not have. libplctag
/// raises <c>Created</c> with NotFound, so libplctag.NET 1.5.2 throws WITHOUT unregistering its callback, and its
/// <c>Dispose()</c> skips the uninitialised tag. Before the fix the native tag survived with a live callback into a
/// delegate the GC was free to collect; libplctag's <c>atexit</c> shutdown then raised DESTROYED into it ("A callback
/// was made on a garbage collected delegate"). This pins that the native tag is destroyed instead.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class LibplctagOrphanReleaseLiveTests
{
[AbServerFact]
public async Task A_tag_the_controller_lacks_leaves_no_native_tag_behind()
{
var fixture = new AbServerFixture();
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
Gateway: fixture.Host, Port: fixture.Port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "T21_NoSuchTag", Timeout: TimeSpan.FromSeconds(5)));
try
{
var ex = await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0);
var exists = plctag.plc_tag_lock(handle) == 0;
if (exists) plctag.plc_tag_unlock(handle);
exists.ShouldBeFalse("a NotFound tag must not survive with its callback registered");
}
finally
{
rt.Dispose();
}
}
}
@@ -202,4 +202,26 @@ public sealed class AbCipDriverProbeTests
result.Ok.ShouldBeTrue();
result.Message!.ShouldContain("probe tag not found");
}
/// <summary>
/// T21: the probe builds its handle from the family profile, so a CompactLogix device with no
/// ConnectionSize opens Large (4002) like libplctag; an explicit device ConnectionSize still wins.
/// </summary>
[Theory]
[InlineData("", 4002)]
[InlineData(",\"connectionSize\":504", 504)]
public async Task CompactLogix_probe_uses_the_profile_default_unless_the_device_overrides_it(string extra, int expected)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var factory = new FakeAbCipTagFactory();
await new AbCipDriverProbe(factory).ProbeAsync(
$"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"CompactLogix\"{extra}}}]}}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
factory.Tags.Values.ShouldHaveSingleItem().CreationParams.ConnectionSize.ShouldBe(expected);
}
}
@@ -106,7 +106,7 @@ public sealed class AbCipDriverTests
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(4002); // Large, as libplctag opens it
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
@@ -117,6 +117,29 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
}
/// <summary>
/// T21: a CompactLogix device with no override opens Large (4002), matching libplctag's
/// <c>plc=compactlogix</c>; the per-device override still wins.
/// </summary>
[Theory]
[InlineData(null, 4002)]
[InlineData(504, 504)]
public async Task CompactLogix_ConnectionSize_defaults_to_large_unless_overridden(int? overrideSize, int expected)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.CompactLogix, ConnectionSize: overrideSize)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(expected);
}
/// <summary>Verifies that AllowPacking and ConnectionSize round-trip correctly through JSON parsing.</summary>
[Fact]
public void AbCipDriverFactoryExtensions_ParseOptions_round_trips_AllowPacking_and_ConnectionSize()
@@ -40,22 +40,26 @@ public sealed class AbCipPlcFamilyTests
// ---- CompactLogix ----
/// <summary>Verifies that the CompactLogix profile uses a narrower connection size than ControlLogix.</summary>
/// <summary>
/// Verifies that the CompactLogix profile defaults to a Large Forward Open (4002), matching what
/// libplctag opens for <c>plc=compactlogix</c>. A target that refuses Large falls back to a regular
/// 504 open inside CipConnection (pinned by the Ab.Transport CipConnectionTests fallback tests).
/// </summary>
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
public void CompactLogix_profile_defaults_to_a_large_forward_open_like_libplctag()
{
var p = AbCipPlcFamilyProfile.CompactLogix;
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
p.DefaultConnectionSize.ShouldBe(4002); // T21: 504 cost the native transport ~3x the round trips per poll
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(500);
}
/// <summary>Verifies that a CompactLogix device initializes with a narrow connection size.</summary>
/// <summary>Verifies that a CompactLogix device initializes with the Large connection size libplctag opens (T21).</summary>
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
public async Task CompactLogix_device_initialises_with_large_ConnectionSize()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
@@ -65,7 +69,7 @@ public sealed class AbCipPlcFamilyTests
await drv.InitializeAsync("{}", CancellationToken.None);
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
profile.DefaultConnectionSize.ShouldBe(4002);
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
}
@@ -0,0 +1,97 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// T21 press pass 1: a libplctag tag whose initialisation FAILED used to leave its native tag behind, because
/// libplctag.NET 1.5.2's <c>Dispose()</c> only destroys an initialised tag. Its callback delegate then died with
/// the wrapper, and libplctag's <c>atexit</c> shutdown raised DESTROYED into it: "A callback was made on a garbage
/// collected delegate". These pin that a failed initialisation destroys the native tag before it propagates.
/// The NotFound variant (the one that crashed, callback still registered) needs a controller and lives in
/// <c>AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests</c>; this offline one is the refused-connection
/// variant, whose native tag otherwise keeps reconnecting in the background for the life of the process.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class LibplctagOrphanReleaseCollection
{
/// <summary>The release counters and the rooting seam are process-wide: nothing else may run alongside.</summary>
public const string Name = "libplctag orphan release";
}
[Collection(LibplctagOrphanReleaseCollection.Name)]
public sealed class LibplctagOrphanReleaseTests
{
private static AbCipTagCreateParams Refused(string tagName = "TestDINT") => new(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: tagName, Timeout: TimeSpan.FromMilliseconds(300));
[Fact]
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle()
{
// A libplctag bump that renames Tag._tag / NativeTagWrapper.nativeTagHandle disables the release (the tag is
// then rooted for the process lifetime instead). Fail here so the bump is reviewed against the release.
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
}
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{
// Port 1 on loopback refuses the connection; libplctag keeps retrying until the timeout.
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "TestDINT", Timeout: TimeSpan.FromMilliseconds(300)));
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
NativeTagExists(handle).ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
// The status the tag held is kept, not replaced by the destroyed handle's NotFound.
rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
// Review r1 M2: the wrapper no longer holds the destroyed id, so a repeat release cannot touch it.
rt.WrapperHandle.ShouldBe(0);
}
finally
{
rt.Dispose();
}
}
/// <summary>
/// Review r1 M1: when the handle cannot be read (a renamed wrapper field), the failed tag is ROOTED — its callback
/// delegate can then never be collected — and nothing is destroyed.
/// </summary>
[Fact]
public async Task Without_a_readable_handle_the_failed_tag_is_rooted_instead()
{
var rooted = LibplctagOrphanRelease.RootedCount;
var released = LibplctagOrphanRelease.Released;
var rt = new LibplctagTagRuntime(Refused());
LibplctagOrphanRelease.ForceRootingForTest = true;
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
LibplctagOrphanRelease.RootedCount.ShouldBe(rooted + 1);
LibplctagOrphanRelease.Released.ShouldBe(released);
}
finally
{
LibplctagOrphanRelease.ForceRootingForTest = false;
// Clean up the native tag this test deliberately leaked (its callback was unregistered by the timeout).
var handle = rt.NativeHandle;
if (handle > 0) plctag.plc_tag_destroy(handle);
rt.Dispose();
}
}
/// <summary>plc_tag_lock answers PLCTAG_ERR_NOT_FOUND for a handle the library no longer holds.</summary>
internal static bool NativeTagExists(int handle)
{
if (plctag.plc_tag_lock(handle) != 0) return false;
plctag.plc_tag_unlock(handle);
return true;
}
}
@@ -0,0 +1,41 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// T21 press pass 1, AbLegacy side: a failed libplctag initialisation must destroy the native tag it created
/// (libplctag.NET 1.5.2's <c>Dispose()</c> skips an uninitialised tag, leaving a native tag whose callback can fire
/// into a collected delegate and kill the process). See <c>LibplctagOrphanRelease</c>.
/// </summary>
public sealed class LibplctagLegacyOrphanReleaseTests
{
[Fact]
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() =>
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{
var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "slc500",
TagName: "N7:0", Timeout: TimeSpan.FromMilliseconds(300)));
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
var exists = plctag.plc_tag_lock(handle) == 0;
if (exists) plctag.plc_tag_unlock(handle);
exists.ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
rt.WrapperHandle.ShouldBe(0); // review r1 M2: a repeat release cannot touch the destroyed id
}
finally
{
rt.Dispose();
}
}
}