# Native AB transport gate results ## ab_server suites (T17) Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time (AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=

`; AbLegacy `~/otopcua-ablegacy`, `AB_LEGACY_COMPOSE_PROFILE=

`); every stack was brought down afterwards. - **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's. - **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`. Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the 6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests (`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself on both transports when the slc500 port (`:44819`) is down. | Profile | Libplctag | Native | Native ⊇ Libplctag | |---|---|---|---| | controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | | compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | | micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) | | guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes | | slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes | | micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes | | plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes | **Native-only failures found and fixed:** - `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that with encapsulation status 1. Every native session failed. - `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id (`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends libplctag's identity. **Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths (non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite. **micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against `--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That defect is pre-existing and libplctag-side; it is recorded, not fixed, here. ## Differential vs ab_server (T18) Run 2026-10-07 on branch `feat/native-ab-transport`, harness `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server` (`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35): ```bash AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \ --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed" ``` Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix` (the defaults; libplctag attribute `compactlogix`, connection size 504). **Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.** | Check | Result | |---|---| | Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets | | Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise | | Read parity | 9/9 tags equal on status, raw buffer and decoded value | | Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** | | TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed | | Read-only guard (offline, runs without the endpoint) | pass | | Comparison falsifiability (offline) | pass | | Tag | Type | libplctag status | native status | Equal? | |---|---|---|---|---| | `Motor_Run` | Bool | Ok | Ok | yes | | `Line1_Count` | DInt | Ok | Ok | yes | | `Pump_1_Speed` | Real | Ok | Ok | yes | | `TestString` | String | Ok | Ok | yes | | `TestSINT` | SInt | Ok | Ok | yes | | `TestBOOL` | Bool | Ok | Ok | yes | | `TestREAL` | Real | Ok | Ok | yes | | `TestDINT` | DInt | Ok | Ok | yes | | `TestArray` | DInt[16] | Ok | Ok | yes | **The values were made non-zero first.** An all-zero simulator would make raw and value equality vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program outside the repo. The harness itself never writes. Seeded values: - `TestDINT` = −123456789 - `TestREAL` = 3.14159 - `Pump_1_Speed` = 1234.5 - `Line1_Count` = 987654 - `TestArray[i]` = (i−8)·100003+7, which includes negatives - `TestString` = `"Hello T18"` ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0. Their parity is therefore weaker evidence. **Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace (`nicolaka/netshoot`), and the capture was decoded with tshark: | TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) | |---|---|---|---|---|---|---| | 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 | | 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 | - Exactly two sessions, with no overlap: a 13 ms gap between them. - Zero write services on the wire: no 0x4D, 0x53 or 0x10. - The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`. **Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal. `NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open. **Transport fixes found by the differential: none.** One harness defect was found and fixed before this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session capture above. ### Review round 1 re-run (press-safety fixes) Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1` kept the sibling suites' discovery probes off the network: ```bash AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \ --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed" ``` **Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.** | Check | Result | |---|---| | Settings | pace 20 ms, max tags 200, max templates 50 | | Browse | 9 = 9 | | Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require | | Reads | 9/9 equal | | Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags | | Native Forward Opens | **1** | | TCP connections to the target before native | 0 (baseline 0) | **Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark): | TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) | |---|---|---|---|---|---| | 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 | | 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 | - No discovery probes. - Zero write services: no 0x4D, 0x53, 0x4B or 0x10. - The two sessions are 23 ms apart and do not overlap. **The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large* Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will see the same split on the press. **What changed for press safety (review r1):** - **Stop on the first connection-level failure, on either transport.** That covers a refused connect, RegisterSession or Forward Open, a timeout, and a lost session. Any status other than NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the failing operation and status. Nothing is retried. - **The native phase is capped at one open attempt.** It sets the new process-wide `CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an enumerator that swallows a per-program failure. - **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry, template and tag. - **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly **one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget turns the connect count red. - **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse, each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not paced. - **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500; a higher value fails fast instead of being clamped. - **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members but the selection holds none. - **Template parity** fails when browse found structured tags but no template id was compared, and treats an undecodable shape on either side as a mismatch. - **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while `AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip. 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 `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture **17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences (6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms ×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt). Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly): | Session | Order | Window (s) | CIP services seen (requests) | Forward Close target | |---|---|---|---|---| | libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 | | native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 | | libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 | | native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 | - **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to anything but the Connection Manager). - **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once. - Every session opened exactly one Large Forward Open and closed it with one Forward Close. ## 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 on `10.100.0.35:44818`. Every container ran the same freshly built image (`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The modal was driven with the Chrome tools against `http://localhost:9200/raw`. **Authoring (UI).** - Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster. - Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held `"transport":"Native"`, a string. - Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport defect is fixed live, not only in unit tests. - Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect returned `OK · 62 ms`. - Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt). - Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path. - Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`. - RawPath: `t19-abcip/Device1plc1/TestDINT`. **Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged: ``` AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory ``` CLI read on central-1 (`:4840`) and central-2 (`:4841`): ``` Node: ns=2;s=t19-abcip/Device1plc1/TestDINT Value: 4242 Status: 0x00000000 ``` The simulator value was then changed to 5150 through the libplctag CLI. The native-served node followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`). **Leg 2: inverted gate (Native, dead port).** - Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819 (`ss -ltn` count 0). - The node went `BadNoCommunication` (value retained). A CLI read failed with `ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad status rather than printing it. - The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`: ``` ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused ---> System.Net.Sockets.SocketException (111): Connection refused ``` - For contrast, the same dead port under libplctag logged different wording: ``` ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout ---> libplctag.LibPlcTagException: ErrorTimeout ``` So the switch changes behaviour and is not inert. **Leg 3: back to libplctag.** - Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held `"transport":"Libplctag"`. - The redeploy sealed with `acks=6`. Both centrals logged `initializing: transport=Libplctag tagFactory=LibplctagTagFactory`. - CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`. **Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register` took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is falsified by reverting that wiring: 2/2 tests fail. **Cleanup.** - The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed, `acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`. - The rig has no test driver pointing at the fixture. - The fixture was brought down (`docker compose --profile controllogix down`). - The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value does not persist. ## Real press (T21) 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** (pass 3 below re-ran it with one): 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) 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 | ```