CipConnection gains two process-wide gate-diagnostic counters next to ForwardOpenCount: ForwardClosesSent (incremented as the request goes on the wire) and ForwardClosesAcked (incremented when the reply arrives within the 1 s budget).
It also records round-trip statistics for completed exchanges: ResetExchangeStats() clears them and ReadExchangeStats() returns count, min, max and mean. The cost is two Stopwatch reads and a few interlocked operations per request. No behaviour changes.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
The T21 press pass 1 test host died at exit with "A callback was made on a garbage
collected delegate of type ...plctag+callback_func_ex::Invoke". The cause is in
libplctag.NET 1.5.2. When a tag's create fails, for example with NotFound,
InitializeAsync throws with the callback still registered. Dispose() destroys a tag
only if it is initialised, so the native tag outlives its wrapper. The GC then frees
the delegate, and libplctag's atexit plc_tag_shutdown raises DESTROYED into it.
Reproduced against ab_server: the production LibplctagTagRuntime with a NotFound tag
exits 134 with that exact message, and exits 0 with this fix.
A timed-out create unregisters its callback but never destroys the handle either,
so that native tag keeps reconnecting for the life of the process.
The fix adds LibplctagOrphanRelease (AbCip and AbLegacy). After a failed
initialise/read/write that leaves the tag uninitialised, it reads the handle through
the pinned wrapper's private fields, unregisters the callback and destroys the tag.
If those fields cannot be read, it roots the tag for the life of the process
instead. It is applied in both tag runtimes, the symbol-listing reader and the
template reader. GetStatus keeps the status the tag held before it was destroyed.
Pins: offline refused-connection tests in AbCip.Tests and AbLegacy.Tests, a live
NotFound test on ab_server, and a reflection-shape pin. All go red with the release
disabled. The differential harness also asserts that no orphan had to be rooted.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
- AbCip/AbLegacy driver forms gain a Transport InputSelect; FromOptions/ToOptions now carry
Transport, so saving any other field no longer silently reverts Native to libplctag.
- Both drivers log the constructed transport once per init.
- AbCip factory now takes the host ILoggerFactory (it ran on NullLogger in production, hiding
every AbCip log line); DriverFactoryBootstrap passes it.
- Gate results: AdminUI /run (T19) native, inverted (dead port) and libplctag legs.
The libplctag adapters assigned Tag.Path straight from the device's parsed
CipPath. A device address with no route (ab://host:44818/ - the normal
Micro800 form, and a MicroLogix with no route) parses to "", and libplctag
rejects an empty path attribute with ErrorBadData; omitting the attribute
(null) is what it accepts. So Micro800 could not connect on the default
libplctag transport.
This predates the native transport work and exists on master.
Add an internal LibplctagPath.Normalize helper per driver project (blank ->
null) and route all four Tag constructions through it (AbCip tag runtime,
symbol-list enumerator, template reader; AbLegacy tag runtime). The runtime
tag construction is extracted to an internal BuildTag so tests can inspect
Tag.Path without connecting. Tests fail with Normalize reverted to identity.
Update the Micro800 smoke-test comment: both transports are expected to pass.
ab_server answers every Execute PCCC with a fixed requestor id (pccc.c
PCCC_RESP_PREFIX: 07 3D F3 45 43 50 21) instead of echoing the request's, so
the R14-3 echo check rejected every native reply sent under the 0x1337 CIP
originator identity as BadReply. The pooled runtime now sends libplctag's
identity (PcccServices.RequestorVendor/RequestorSerial), which passes the echo
check against ab_server and against a real controller that echoes. Pinned by a
FakeEipServer round trip that answers with ab_server's fixed prefix.
ab_server's RegisterSession sanity check rejects any non-zero sender context
with encapsulation status 1 and a header-only reply, so every native session
against the simulator failed with BadCommunicationError. libplctag sends zero
(logix-register-session.hex); the codec test now matches that capture byte for
byte instead of excluding the context, and a client test pins the zero on the wire.
Removes the 'use UseNativeNotifications=false for arrays' workaround and
documents the raw-bytes array model shared by poll and notification: element
checks, STRING arrays, multi-dim flattening, 0-based publication, leading
sub-range for a shorter arrayLength, refusals (struct/UDT arrays, too long,
scalar over array), the 128 KiB native cap (BadOutOfRange), the bit-path fix,
and the 2026-10-07 read-only live gate on Z33856 / Z33733.
Live on Z33856 a UInt tag over .ARRFAULTCODE (ARRAY [0..12] OF UINT) made the
Beckhoff read throw - BadCommunicationError and a Degraded driver - while the
native path reported BadTypeMismatch. CheckRead now refuses a scalar declared
over an array (by category, so an alias of an array type is caught), pointing
at the array-tag / element-path alternatives.
A native notification for MAIN.Flags.3 / .ADSSTATE_RUN.0 was registered on the
literal WORD.N path, which ADS has no symbol for: live on the TC 2.11 Elma
cleaner Z33856 it failed with DeviceSymbolNotFound, and that registration
error tore down the whole subscribe call. The read path already reads the
parent word; the notification now does the same - it registers the parent as
raw bytes at its resolved width and reads the bit (TwinCATBitAccess), refusing
a bit outside the parent or a non-integer parent with BadTypeMismatch.
An array tag subscribed natively used to register only its declared scalar
(publishing element 0 at Good) and was then refused wholesale with
BadTypeMismatch. The driver now hands the authored ArrayLength to the client,
which registers the whole array as raw bytes and decodes every notification.
- TwinCATTypeGuard.CheckArray plans the layout from the RESOLVED array type:
element checked like a scalar notification (width / float kind / string
kind); STRING(n)/WSTRING(n) elements stride n+1 / 2(n+1); multi-dim arrays
flattened in PLC memory order; an authored ArrayLength shorter than the PLC
array reads the leading sub-range, a longer one is refused; struct / pointer
/ nested-array elements, non-array symbols and VAR_IN_OUT refs are refused.
- CheckNotificationSize caps a native array at 128 KiB (largest registration
proven on a live TC 2.11 target); above it the tag is refused BadOutOfRange.
- TwinCATArrayCodec decodes the bytes into the element-typed CLR array.
- The poll read uses the same plan + codec instead of the Beckhoff typed array
read, which failed live for most real arrays (exact CLR element type, rank-N
for multi-dim, member arrays sliced by the under-reported TC2 symbol size).
- TwinCATNotificationRefusedException carries the refusal's status; the type
mismatch derives from it and the driver isolates either per tag.
Found by the read-only live gate against the TwinCAT 2.11 Elma cleaners
Z33856 / Z33733:
- The Beckhoff library reports a TC2 dotted MEMBER symbol's ByteSize as if the
PLC's byte count were a bit count (STRING(80) -> 11, INT -> 1, UDINT -> 1)
while DataType.ByteSize is right (81/2/4, raw SYM_INFOBYNAMEEX says 81). The
previous commit's typeof(byte[]) read sliced by the symbol size, so
.OPC_MACHINEDATA.PROCESSSTATUS read "ready for o" at Good; the notification
width check would also have refused every numeric member. The shape now takes
the resolved type's size, and string reads go by index group/offset (or a
fresh handle for 0xF005) at that width.
- P_INITINPUT_ACTION.SLOTID is "STRING(20) (VAR_IN_OUT)" - a 4-byte reference -
but resolves to a plain STRING(20); a String tag read 21 bytes at the pointer
as text at Good. The symbol's own VAR_IN_OUT marker now classifies it
NonScalar -> BadTypeMismatch.
libplctag decodes whatever bytes the controller returns as the authored type and never
checks the CIP type code. Live read-only check on a production press: WW_ActualVacuum is a
REAL (L5X + the controller's symbol table agree) but the legacy Kepware export in the
ignitionoee repo types it Short; read as INT it returned Good -17346 while the real value
was 5.37. A scalar atomic read (not an array, not a bit-in-DINT) whose buffer size is not
the authored type's width now reads BadTypeMismatch with a warning naming both. Widths
observed live: SINT/BOOL 1, INT 2, DINT/REAL 4, LINT 8. IAbCipTagRuntime.GetSize() is a
default-interface member (-1 = unknown, never checked). Same-width mismatches (DINT vs
REAL) stay undetectable without the type code libplctag strips.
Live read-only check against a production CompactLogix press: Program:UN01_Press._ModeNames
(STR0016[32], 4 elements requested) returned an 80-byte buffer with libplctag.NET 1.5.2's
ElementSize unset. The driver fell back to the static 88-byte STRING stride, read element
1 past the buffer, and the whole read failed (ErrorOutOfBounds -> BadCommunicationError).
When the buffer divides evenly by the element count, that quotient is now the stride;
the read returns [undefined, Production, Maintenance, Manual] as the controller holds.
Live read-only check against a production CompactLogix press: with one nonexistent tag
first in the read list, libplctag's create threw LibPlcTagException("ErrorNotFound"); the
driver counted it as a failed Forward-Open, opened the device's 05/STAB-8 connect-backoff
window, and every other cold tag on the device - all real (WW_InCycle, WW_ActualPress,
WW_WorkOrderNum) - read BadCommunicationError. In steady polling the bad tag re-opens
the window each attempt, so siblings ordered after it may never connect.
- A create that fails with a "controller answered" status (NotFound/NoMatch/BadDevice,
the probe's existing reachable classification) records backoff SUCCESS, not failure.
Genuine connection failures still open the window (falsification control test).
- Read exceptions map through the libplctag Status they carry: unknown tag ->
BadNodeIdUnknown, decode past the buffer -> BadOutOfRange, timeout -> BadTimeout,
instead of a blanket BadCommunicationError. One status-name parser
(AbCipStatusMapper.TryGetStatus) now serves both the read path and the probe.