b25ee10a14
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
480 lines
31 KiB
Markdown
480 lines
31 KiB
Markdown
# 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):
|
||
|
||
```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 |
|
||
|
||
```
|
||
|