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

57 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 |

hw4 evidence batch — 2026-10-09 (Gitea #592; #582, #572, ab_server bit write)

Branch hw4-evidence, harness Differential/EvidenceTests.cs (same folder, same read-only source/IL guard, extra opt-in env gates so a standard Differential run never repeats them). Press: Z-35162, CompactLogix 5069-L320ERS2, VPN, pace 50 ms, one short-lived native session per leg (1 Forward Open, 1 Forward Close acked), reads only. A diagnostics-only tap (ConnectionDiagnostics.ExchangeObserver, null in production, exceptions swallowed) lets the harness see each CIP request and its raw reply; no packet capture was used because tcpdump needs sudo on this Mac.

Real-controller scalar writes (user-approved one-off, 2026-10-09)

Done by the controller session, not by this harness, through the AbCip CLI on the native transport against Z-35162. The tags were chosen because a census of the L5X export shows no routine references them and they are absent from the Galaxy map. Every write and read-back returned Good, and each tag was restored:

Tag Type Sequence
test2 DINT 0 -> 4242 -> 0
Program:UN01_Press.xxtest BOOL false -> true -> false
Program:ES03_EM03_Zone3._ff_test REAL 0 -> 42.5 -> 0

This is the first native write on a real controller: scalar DINT/BOOL/REAL Write Tag is proven on a CompactLogix. The user's write approval covered only that one-off and is consumed; every other leg of this batch was read-only. Still unproven on hardware: fragmented wide-struct writes (#567), array writes, STRING writes, UDT member writes.

#582 - mixed STRING / array / scalar Multiple Service Packet (PASS)

AB_EVID_MIX=1 AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_PACE_MS=50 -> EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet (raw output: hw4-mix-msp.txt). Five reads were issued concurrently on one native session, five times (>= 1 s apart), bracketed by sequential single reads of the same tags:

Tag Type Value (single before / all 5 batches / single after)
Program:UN01_Press.Sts_CycleTime_STR STRING '2:23:37' throughout (matches the L5X export)
Program:EM01_PowerUnit._SEQ_EP[1] DINT element 300 throughout
Program:EM01_PowerUnit._SEQ_EP DINT[32] [0,300,0 x30] throughout
test2 DINT 0
Program:UN01_Press.xxtest BOOL False
  • 5/5 attempts put all five reads in ONE channel send: a single 204 B Multiple Service Packet (service 0x0A) answered by a 273 B reply with embedded=5, outer status 0x00, embedded statuses all 0x00 with data lengths 92 B (STRING), 6 B, 130 B (32 x DINT + type header), 6 B, 3 B. No embedded 0x06, no fragment re-fetch, RequestsSent delta = 1 each time.
  • Every batch value equalled both bracketing singles, so reply packing/offsets decode correctly for a mix of 3-byte, 6-byte, 92-byte and 130-byte replies. The values are stable (static config/test tags), so equality is not a coincidence of drift.
  • The tags are non-zero where it matters (the string and the array element), so decode equality is not vacuous; test2 / xxtest are zero by design and are only there to pin the scalar slots.
  • On ab_server (loopback) the same harness showed the pump can wake mid-queue and split a batch (attempt 1 of 5: an MSP of 4 plus a bare read). That is a scheduling race, not a packing defect, which is why the leg repeats the batch and requires at least one whole-batch MSP; on the press all five attempts were whole.

#572 - does a real controller list symbol instances out of order? (observed: no)

AB_EVID_BROWSE=1 -> EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller (raw: hw4-listing-order.txt). One native @tags browse of Z-35162 through the production enumerator (default paging and pacing): 3162 tags, 41 listing pages (controller scope 8 pages, then each of the 15 programs, up to 6 pages for ES03_TemperatureControl).

  • 0 non-ascending pages. Within every page the instance ids were strictly ascending, and each page's first id was above the previous page's maximum (e.g. controller scope 1..105, 106..207, ... 670..671).
  • Instance ids are not contiguous: ids can be skipped (e.g. EM02_Clamp page ends ...,281,282,288,289; UN01_Press page 1 ends at 109 and page 2 starts at 111). The reader's "next start = highest id + 1" rule handles that and the out-of-order case alike; this is evidence only, no code changed.
  • One empty page (Program:Auxiliary, 0 entries) was observed and handled.

ab_server PCCC bit-file write (B3:0/5): the documented gap does not reproduce (no patch)

Provenance: AbLegacy.IntegrationTests/Docker/README.md ("Bit-file writes on ab_server", May 2026, libplctag + --plc=SLC500, before the native transport) recorded B3:0/5 -> 0x803D0000. New AbLegacyBitWriteTests (writes B3:0 = 0x0A0A, sets bit 5, clears bit 3, asserts the whole word each time so neighbour bits are proven preserved) against local ab_server (libplctag v2.7.0 + patches 0001/0002, OrbStack, AB_LEGACY_ENDPOINT pinned to a lane-local port):

Family Libplctag Native
SLC500 pass pass
MicroLogix pass pass
PLC-5 pass BadNotSupported (0x803D0000)
  • Finding: the originally documented gap does not reproduce on the current fixture and driver; the cause of the original failure is undetermined. No ab_server patch or Dockerfile change is needed. The driver does a per-parent-word read-modify-write (typed read, typed write of the word), not a masked FNC 0xAB write (ab_server does implement 0xAB for 16-bit elements).
  • The PLC-5 native 0x803D0000 is a separate, intended behaviour that merely shares the status code: all native PLC-5 writes are refused before the wire (#571, NativeAbLegacyTagRuntime.WriteRefusal). The test pins it. It is not the original observation.
  • No fix, so no revert-to-red proof; the test would fail if the word RMW clobbered neighbour bits.

#611 UDT member-write gate — 2026-10-09 (user-approved, native transport)

Target: Z-35162, controller tag _EmptyTemperatureSegment (UDT_TemperatureProfile_Segment; no routine, alias or trend references it in the 2026-09-11 L5X export; not in the Galaxy map). Tool: AbCip CLI from master 6c6800e5, ab://10.209.70.160/1,0, family CompactLogix, default (native) transport. Each leg: write, snapshot all ten members, restore, snapshot again.

Member Type Before Wrote Read back Restored Other 9 members
ID DINT 0 4242 4242 0 unchanged (0/false)
Enable BIT (BOOL member) false true true false unchanged
HoldTime REAL 0 42.5 42.5 0 unchanged

Every write and read returned Good (0x00000000); the final snapshot equals the pre-gate snapshot. Not covered: sibling-bit preservation inside one hidden host SINT (this UDT has a single BIT member) and the libplctag member-write path. Raw output: the session scratchpad hw4/gate611.txt.

AbLegacy on a real MicroLogix 1400 — 2026-10-09 (Z-33323, idle, user-directed)

Press selection: Z-33470 was mid-cycle (N10:150/0 = B3:0/4 = true) and was left alone; Z-33323 read idle on both, with the pump input I:0/0 off. The other North presses time out on TCP from the VPN (ICMP answers). AbLegacy CLI from master, ab://10.201.44.231/, -P MicroLogix.

=== 10.201.44.231
ST14:10    Value: "Z-33323" Status: 0x00000000 (Good) 
N10:150/0  Value: false Status: 0x00000000 (Good) 
B3:0/4     Value: false Status: 0x00000000 (Good) 
I:0/0      Value: <null> Status: 0x80050000 (BadCommunicationError) 
I:0/1      Value: <null> Status: 0x80050000 (BadCommunicationError) 
=== 10.201.44.249
ST14:10    Value: "Z-33470" Status: 0x00000000 (Good) 
N10:150/0  Value: true Status: 0x00000000 (Good) 
B3:0/4     Value: true Status: 0x00000000 (Good) 
I:0/0      Value: <null> Status: 0x80050000 (BadCommunicationError) 
I:0/1      Value: <null> Status: 0x80050000 (BadCommunicationError) 

IDLE CHECK N10:150/0=false 0x00000000 (Good)  B3:0/4=false 0x00000000 (Good) 
S:0          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 4 0x00000000 (Good) 
S:57         Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 1400 0x00000000 (Good) 
S:4          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 9952 0x00000000 (Good) 
N9:0         Int      | lib: 2331 0x00000000 (Good)                        | nat: 2331 0x00000000 (Good) 
N9:4         Int      | lib: 3867 0x00000000 (Good)                        | nat: 3867 0x00000000 (Good) 
N7:255       Int      | lib: 32000 0x00000000 (Good)                       | nat: 32000 0x00000000 (Good) 
F8:226       Float    | lib: 2.1 0x00000000 (Good)                         | nat: 2.1 0x00000000 (Good) 
F8:214       Float    | lib: 200 0x00000000 (Good)                         | nat: 200 0x00000000 (Good) 
B3:24        Int      | lib: 1 0x00000000 (Good)                           | nat: 1 0x00000000 (Good) 
B3:24/0      Bit      | lib: true 0x00000000 (Good)                        | nat: true 0x00000000 (Good) 
B3:28        Int      | lib: 2309 0x00000000 (Good)                        | nat: 2309 0x00000000 (Good) 
B3:28/8      Bit      | lib: true 0x00000000 (Good)                        | nat: true 0x00000000 (Good) 
B3:28/9      Bit      | lib: false 0x00000000 (Good)                       | nat: false 0x00000000 (Good) 
B3:132/14    Bit      | lib: true 0x00000000 (Good)                        | nat: true 0x00000000 (Good) 
T4:15.PRE    Int      | lib: 6000 0x00000000 (Good)                        | nat: 6000 0x00000000 (Good) 
T4:15.ACC    Int      | lib: 6000 0x00000000 (Good)                        | nat: 6000 0x00000000 (Good) 
T4:15.DN     Bit      | lib: true 0x00000000 (Good)                        | nat: true 0x00000000 (Good) 
T4:93.PRE    Int      | lib: 7000 0x00000000 (Good)                        | nat: 7000 0x00000000 (Good) 
T4:93.DN     Bit      | lib: false 0x00000000 (Good)                       | nat: false 0x00000000 (Good) 
T4:14.PRE    Int      | lib: 3600 0x00000000 (Good)                        | nat: 3600 0x00000000 (Good) 
T4:14.ACC    Int      | lib: 2922 0x00000000 (Good)                        | nat: 2922 0x00000000 (Good) 
T4:11.PRE    Int      | lib: 32767 0x00000000 (Good)                       | nat: 32767 0x00000000 (Good) 
T40:3.PRE    Int      | lib: 500 0x00000000 (Good)                         | nat: 500 0x00000000 (Good) 
T40:12.PRE   Int      | lib: 500 0x00000000 (Good)                         | nat: 500 0x00000000 (Good) 
C5:0.PRE     Int      | lib: 999 0x00000000 (Good)                         | nat: 999 0x00000000 (Good) 
C5:0.ACC     Int      | lib: 111 0x00000000 (Good)                         | nat: 111 0x00000000 (Good) 
C5:8.DN      Bit      | lib: true 0x00000000 (Good)                        | nat: true 0x00000000 (Good) 
C5:1.PRE     Int      | lib: 32767 0x00000000 (Good)                       | nat: 32767 0x00000000 (Good) 
R6:0.LEN     Int      | lib: 32 0x00000000 (Good)                          | nat: 32 0x00000000 (Good) 
R6:0.POS     Int      | lib: 0 0x00000000 (Good)                           | nat: 0 0x00000000 (Good) 
R6:0.DN      Bit      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: false 0x00000000 (Good) 
ST14:10      String   | lib: "Z-33323" 0x00000000 (Good)                   | nat: "Z-33323" 0x00000000 (Good) 
ST14:0       String   | lib: "EQ_TEST_LARGE_MOLD" 0x00000000 (Good)        | nat: "EQ_TEST_LARGE_MOLD" 0x00000000 (Good) 
O:0          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 0 0x00000000 (Good) 
O:0/3        Bit      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: false 0x00000000 (Good) 
I:0          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: -29376 0x00000000 (Good) 
I:0/0        Bit      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: false 0x00000000 (Good) 
IDLE RECHECK N10:150/0=false 0x00000000 (Good) 

pre: cycle=falseG pump=falseG
-- N7:23 write 4242: 0x00000000 (Good) -> N7:22=0G N7:23=4242G N7:24=0G
-- N7:23 restore 0: 0x00000000 (Good) -> N7:23=0G
-- F8:190 write 42.5: 0x00000000 (Good) -> F8:190=42.5G
-- F8:190 restore 0: 0x00000000 (Good) -> F8:190=0G
-- B3:200/5 set: 0x00000000 (Good) -> B3:200=32G (exp 32)
-- B3:200/3 set: 0x00000000 (Good) -> B3:200=40G (exp 40)
-- B3:200/5 clear: 0x00000000 (Good) -> B3:200=8G (exp 8)
-- B3:200/3 clear: 0x00000000 (Good) -> B3:200=0G (exp 0)
-- neighbours: B3:199=0G B3:201=0G
post: cycle=falseG pump=falseG N7:23=0G F8:190=0G B3:200=0G

2026-10-09 (later) — libplctag S/O/I + control status bits fixed (#659), re-read on Z-33323

Root cause, from libplctag v2.6.0 src/protocols/ab/pccc.c (the native core inside libplctag.NativeImport 1.0.41, pulled by the libplctag 1.5.2 package): parse_pccc_file_num (L881) requires a digit after the file letter and parse_pccc_file_type (L828-835) only treats S as the status file when a digit follows, so S:0/O:0/I:0 never parse; sub_element_lookup (L936-1064) has only con/len/pos for R files, and parse_pccc_bit_num (L1162) refuses a /bit on a non-2-byte element, so neither R6:0.DN nor R6:0/13 parses. The libplctag adapter now translates (LibplctagLegacyName): S:n→S2:n, O:n→O0:n, I:n→I1:n (the native encoder's implicit file numbers) and an unknown status bit → R6:0.CON/<bit>. Press idle beforehand (N10:150/0 = B3:0/4 = false, Native) and afterwards (both false again). AbLegacy CLI built from branch i659, reads only, one short session per read, ≥ 1.2 s apart:

S:0        Int     Libplctag  value=4                        status=0x00000000 (Good)
S:0        Int     Native     value=4                        status=0x00000000 (Good)
S:57       Int     Libplctag  value=1400                     status=0x00000000 (Good)
S:57       Int     Native     value=1400                     status=0x00000000 (Good)
S:4        Int     Libplctag  value=12688                    status=0x00000000 (Good)
S:4        Int     Native     value=-31956                   status=0x00000000 (Good)
O:0        Int     Libplctag  value=0                        status=0x00000000 (Good)
O:0        Int     Native     value=0                        status=0x00000000 (Good)
O:0/3      Bit     Libplctag  value=false                    status=0x00000000 (Good)
O:0/3      Bit     Native     value=false                    status=0x00000000 (Good)
I:0        Int     Libplctag  value=-29376                   status=0x00000000 (Good)
I:0        Int     Native     value=-29376                   status=0x00000000 (Good)
I:0/0      Bit     Libplctag  value=false                    status=0x00000000 (Good)
I:0/0      Bit     Native     value=false                    status=0x00000000 (Good)
I:0/1      Bit     Libplctag  value=false                    status=0x00000000 (Good)
I:0/1      Bit     Native     value=false                    status=0x00000000 (Good)
R6:0.DN    Bit     Libplctag  value=false                    status=0x00000000 (Good)
R6:0.DN    Bit     Native     value=false                    status=0x00000000 (Good)
R6:0.EN    Bit     Libplctag  value=false                    status=0x00000000 (Good)
R6:0.EN    Bit     Native     value=false                    status=0x00000000 (Good)
R6:0.LEN   Int     Libplctag  value=32                       status=0x00000000 (Good)
R6:0.LEN   Int     Native     value=32                       status=0x00000000 (Good)
N9:0       Int     Libplctag  value=2331                     status=0x00000000 (Good)
N9:0       Int     Native     value=2331                     status=0x00000000 (Good)
F8:226     Float   Libplctag  value=2.1                      status=0x00000000 (Good)
F8:226     Float   Native     value=2.1                      status=0x00000000 (Good)
B3:28      Int     Libplctag  value=2309                     status=0x00000000 (Good)
B3:28      Int     Native     value=2309                     status=0x00000000 (Good)
T4:15.PRE  Int     Libplctag  value=6000                     status=0x00000000 (Good)
T4:15.PRE  Int     Native     value=6000                     status=0x00000000 (Good)
C5:8.DN    Bit     Libplctag  value=true                     status=0x00000000 (Good)
C5:8.DN    Bit     Native     value=true                     status=0x00000000 (Good)
ST14:10    String  Libplctag  value="Z-33323"                status=0x00000000 (Good)
ST14:10    String  Native     value="Z-33323"                status=0x00000000 (Good)
05:31:09 I:0/6    Libplctag  Value:       true | Status:      0x00000000 (Good)
05:31:11 I:0/6    Native     Value:       true | Status:      0x00000000 (Good)
05:31:13 I:0/15   Libplctag  Value:       true | Status:      0x00000000 (Good)
05:31:16 I:0/15   Native     Value:       true | Status:      0x00000000 (Good)
05:31:18 I:0/7    Libplctag  Value:       false | Status:      0x00000000 (Good)
05:31:20 I:0/7    Native     Value:       false | Status:      0x00000000 (Good)
05:31:22 S:4      Libplctag  Value:       17831 | Status:      0x00000000 (Good)
05:31:24 S:4      Native     Value:       -26549 | Status:      0x00000000 (Good)
05:31:26 S:4      Libplctag  Value:       -4646 | Status:      0x00000000 (Good)
05:31:28 S:4      Native     Value:       17335 | Status:      0x00000000 (Good)

Every address reads identically on both transports. S:4 is the free-running clock, so its value differs read to read on each transport (both Good). I:0 = -29376 = 0x8D40, and bits /6 and /15 read true on both while /0, /1, /7 read false — the bit is extracted from the word libplctag read, not defaulted. ab_server has no S/O/I files, so this real-controller read is the only wire evidence; the translation is unit-pinned against a model of the pccc.c parser (LibplctagLegacyNameTests).

Caveats: R6:0.DN and R6:0.EN read false on both transports, so this read does not discriminate the control-word bit number (a wrong bit would very likely also read false) — the R bit mapping rests on the native table and the pccc.c parser model, not on this wire evidence. Writes: on libplctag every address the translation changes (S:/O:/I:, R/UA status bits) is refused BadNotSupported (#659 review), so this fix adds no libplctag write path.

hw5 — #566 idle-connection close and a native STRING write on Z-35162 — 2026-10-09 (user-approved)

Press: Z-35162, CompactLogix 5069-L320ERS2 rev 34.11, ab://10.209.70.160/1,0, family CompactLogix, Mac VPN. The user approved exactly two things: (A) one read-only idle-connection session for #566 and (B) native array and STRING writes on logic-unreferenced tags. Harness: AbCip.IntegrationTests/HardwareGate/ (its own namespace, outside the read-only Differential guard), both tests env-gated. No packet capture (tcpdump needs sudo); the diagnostics tap (ConnectionDiagnostics.ExchangeObserver, now also fed the Forward Open reply so the granted RPIs are visible) and the transport's exception text are the evidence.

A. #566 — what a real Logix does with a class-3 connection idle past its timeout: it drops TCP, no CIP status

AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_EVID_IDLE=1 AB_EVID_IDLE_SECONDS=60 → IdleConnectionEvidenceTests (raw: hw5-idle566.txt). One private pool, keep-alive pushed out to 1 h so nothing touched the connection, open-attempt budget 2, three Read Tag requests of test2 (DINT) and nothing else. 10:04:49Z–10:05:54Z UTC.

t (s) Event
0.26 Large Forward Open (0x5B) granted: O→T API = T→O API = 2 000 000 µs (the requested RPI, unchanged). Multiplier 1 (×8) ⇒ connection timeout 16 s
0.32 read 1: general status 0x00
0.32 – 60.33 idle, no traffic (3.75 × the timeout)
60.33 read 2: failed in 9 ms on the write: IOException: Broken pipe ← SocketException(Shutdown) → AbTransportException(BadConnection) → OPC UA BadCommunicationError (0x80050000). No CIP reply, no encapsulation status, ConnectionsClosedByTarget 0
62.47 read 3: fresh Large Forward Open granted (new O→T id), read general 0x00 — recovered on the next request, open attempts 2/2
62.56 lease released: Forward Close sent/acked 1/1
  • Answer to #566: neither 0x0107 nor 0x0203. The controller closed the TCP session; the next request's write hit a dead socket. EPIPE on the first write after the idle (not a successful write followed by EOF) means the close was already complete on the Mac's side, which is consistent with an RST (or FIN + RST) from the controller; without a capture the segment type and the exact moment within the 60 s window are not observed — only that it happened after the 16 s timeout and before 60 s.
  • The transport handles it correctly as it stands, so no production code changed. The failed write faults the session (EncapsulationClient.Fault, IsConnected false), the failing request alone fails BadConnection (fast, not an exchange timeout), the driver maps that to BadCommunicationError and evicts the runtime / reports Degraded for that poll, and the next send reopens through the Forward Open throttle. The 0x0107/0x0203 recycle (#566) stays: it is correct for a target that keeps TCP up, and harmless here (not exercised).
  • In production this path needs more than idleness. The keep-alive fires at half the expiry (8 s), so a connection the driver holds never idles 16 s; the shape above arises after a controller restart / re-download or a network gap.
  • Pinned offline: CipConnectionTests.A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens replays the shape (a FakeEipServer.ResetAllConnections RST with no request in flight): BadConnection in < 4 s, ConnectionsClosedByTarget 0, one Forward Open before / two after. Proven able to fail: with EnsureOpenAsync made to reuse a non-connected session it goes red (the reopen never happens).

B. Native writes: STRING PASS; array NOT RUN (no tag qualifies)

STRING — Program:ES03_TemperatureControl._StrTmp (PASS, restored byte-exact). Census: refs 0, extrefs 0, not in the Galaxy map, program not HMI/Wonderware; raw grep of SN3244.L5X finds the name only at its declaration (line 69426). AB_HW_WRITE_GATEWAY=ab://10.209.70.160/1,0 AB_HW_WRITE_APPROVED=1 AB_HW_WRITE_STRING_TAG=Program:ES03_TemperatureControl._StrTmp → StringWriteGateTests, through the production AbCipDriver on the native transport, 10:10:54Z–10:11:29Z UTC (raw: hw5-string-write.txt). A read-only dry run (AB_HW_WRITE_DRYRUN=1) first confirmed the preflight. The raw bytes come from the tap on each Read Tag Fragmented reply.

Step Status Value Raw (type, handle, LEN, DATA…)
snapshot 1 Good ES03_TemperatureControl:Fault A002 CE0F 1D000000 4553…6C74 00… (92 B)
snapshot 2 (+30 s) Good same identical to snapshot 1
preflight — — built-in STRING (0x02A0 / handle 0x0FCE / 88 B), LEN 29, every byte past LEN zero
write OTOPCUA-TEST Good (0x00000000) — —
read back Good OTOPCUA-TEST A002 CE0F 0C000000 4F544F504355412D54455354 00…
restore original Good — —
read back Good ES03_TemperatureControl:Fault byte-equal to snapshot 1
final snapshot (+2 s) Good same byte-equal to snapshot 1

The original matches the L5X export's value. The write is one unfragmented Write Tag (88 B of data plus the path is far below the 4000-byte request limit of the Large Forward Open).

Array — not run; no candidate passes the brief's own rule. The only logic-unreferenced, Read/Write atomic arrays in the census are Program:EM01_PowerUnit._SEQ_EP and Program:EM02_Clamp._SEQ_EP (DINT[32]), and the raw grep shows they are not unreferenced: aliases _SEQ_EP01_AutoCycle → _SEQ_EP[1] (EM01) and _SEQ_EP01_Jog / _SEQ_EP02_Auto / _SEQ_EP03_Stopping → _SEQ_EP[1..3] (EM02) are the step registers of the power-unit auto cycle and the clamp jog / auto / stopping sequencers, used by dozens of rungs (MOV(…,_SEQ_EP02_Auto), EQU(_SEQ_EP01_AutoCycle,300)…). The census counts references by tag name, so references through an alias were invisible to it. A whole-array write would rewrite live sequencer state, and even a single unaliased element fails the "name appears only at its declaration" check. The other arrays are referenced (_DisableStates, _ModeTransitions, _ModeNames, _VacuumCalibration_*, Stored, _monitorID), ExternalAccess="Read Only" (_SEQ_UP, Inp_CycleTime) or framework lookup tables (Inf_LookupFwkB). No array write was made. An element write needs a tag the user picks (or an unaliased element they approve explicitly).

#567 (fragmented wide-struct write) — still cannot be done here, and a DINT[32] would not have done it. AbCip has no whole-array write at the driver layer (array tags are read-only, DriverArrayWriteSupport); and at the transport layer a 128-byte array (or this 88-byte STRING) is a single Write Tag: LogixTagClient.WriteAsync fragments only when the request exceeds MaxRequestBytes (4000 B on the Large Forward Open this controller grants). A fragmented write needs a value of roughly 4 KB.

Native array-element write — 2026-10-09 (user-approved, Z-35162)

Program:EM01_PowerUnit._SEQ_EP (DINT[32]). L5X check: its only alias is _SEQ_EP01_AutoCycle -> _SEQ_EP[1]; no routine references _SEQ_EP directly (no index, COP or FLL); no cross-program reference. AbCip CLI (native) from master 98c00904, one element per read, two snapshots 30 s apart before writing. [1] = -1 is the idle auto-cycle step.

SNAP1: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
SNAP2: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
WRITE [31]=4242: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good)
AFTER: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4242
RESTORE [31]=0: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good)
FINAL: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0