Files
lmxopcua/docs/plans/2026-10-07-native-ab-transport-gate-results.md
T

31 KiB
Raw Blame History

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=<p>; AbLegacy ~/otopcua-ablegacy, AB_LEGACY_COMPOSE_PROFILE=<p>); 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):

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:

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.

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, pass 2.

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 |