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.
Live read-only check: a production CompactLogix lists the module tag ABB_OmniCore:I in
@tags and libplctag reads ABB_OmniCore:I.ConnectionFaulted without complaint, but the
driver refused the path as malformed (identifier rule had no colon) and reported the
tag BadCommunicationError. Colon-separated identifier/slot parts are now legal in the
controller-scope base segment only (never past it, never under Program: scope).
Live read-only check against production CompactLogix 5069-L320ERS2 presses:
- The controller @tags listing names each program (Program:UN01_Press, a system symbol)
but none of its tags; those are listed only by Program:<name>.@tags. The enumerator read
@tags alone, so every program-scoped tag was unbrowseable — including the PackML state
(Program:UN01_Press.Out_eState) and all 51 alarm bits the Ignition project reads. The
enumerator now reads each program's listing and decodes it with that program as scope;
an unreadable program listing is skipped, not fatal.
- The Symbol Object decoder ignored the dimension bits + dim words, so every discovered
array (DINT[32], REAL[32], STRING[50], UDT[23] on the two controllers checked) browsed as
a scalar. IsArray/ElementCount are now populated (multi-dim flattened). A 1-D single
element entry stays scalar: ab_server reports every scalar that way, a real controller
sets no dimension bits on scalars.
Pinned by entries captured off the controllers (CipSymbolObjectRealControllerCaptureTests,
AbCipProgramScopeEnumerationTests).
Live read-only check against production CompactLogix 5069-L320ERS2 rev 34.11 presses
(Z-35162 / 10.209.70.160): every @udt/<id> template the controller returned decoded to
null, so controller-discovered UDTs never fanned out. The decoder assumed a 12-byte
header led by the member count and [info][array][offset] blocks with ';'-terminated
names; libplctag actually returns a 14-byte header (u16 id, u32 def words, u32 instance
size, u16 member count, u16 handle), [info][type][offset] blocks, and NUL-terminated
names after a 'Name;<tail>' type name. All three test builders restated the old
assumption, so the unit suite agreed with itself (the #468 trap again); they now share a
TemplateBufferBuilder pinned by real captures (CipTemplateObjectWireCaptureTests).
Discovery follow-ons that the working decoder exposed:
- STRING (and user string types, LEN DINT + DATA SINT[n]) browse as one String variable,
top-level and nested, not as a LEN/DATA folder.
- Controller-generated members (ZZZZZZZZZZ* BOOL hosts, __* AOI backing) are not browsed.
- A UDT array is not fanned out into element-free Tag.Member paths.
Reverses the conservative call from the previous commit at the user's direction,
and they were right: "surface it on a counter first" left the wire lying. An
MXAccess event can carry Good quality while its payload is a kind MxValueDecoder
has no mapping for, so the old behaviour published null-under-Good — which a
client cannot tell apart from a genuine null reading. A value the gateway DID
send would be consumed as a value it did not, with the only evidence in a log
nobody is tailing. Bad data is the honest wire representation of lost data.
BadTypeMismatch is an existing StatusCodeMap constant, deliberately reused rather
than written inline: StatusCodeParityTests finds status constants by reflecting
over Good/Uncertain/Bad-prefixed uint fields and checking them against the pinned
SDK, so an inline literal would be invisible to that guard (Gitea #497, where 16
wrong constants had accumulated across 6 drivers exactly this way).
Logging is now once per DISTINCT value kind rather than once per pump. The set is
bounded by the MxValue oneof enum so a broken producer still cannot flood, but a
second unrepresentable kind appearing later gets its own line — warn-once-per-pump
would have silently swallowed it, which is the same class of bug as the null this
commit fixes.
The test's fixture matters more than its assertion: the undecodable event carries
Quality = 192 (OPC DA Good), so a decoder that gave up silently would publish Good
and fail. Paired with a positive control — a decodable value at the same quality
must still publish Good — so it cannot be satisfied by a pump that marks
everything Bad. Falsified by reverting the status to Good: the test reds, and only
that test.
Verified: 331 Galaxy tests green, Core.Abstractions 298 (the parity guard),
solution builds 0 errors. Docs corrected in the two places that recorded the old
decision as deliberate.
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.
Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.
Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.
⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.
Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.
It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.
Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.
A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.
Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.
Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.
Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
Driver.Mqtt.Contracts-004/-005/-006/-007.
-004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false"
— what a CSV import produces — silently became the default TRUE, the INVERSE of the
authored intent. Both mappers now read it through TryReadBoolStrict (the qos
absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring
side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this
same parse through TagConfigParseGate, so the save is blocked with the inspector's
wording — pinned by a new AdminUI parity theory pair.
-005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran —
the options are bound by JsonSerializer, and nothing in the four MQTT projects calls
Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's
[Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade
the non-positive value its own remarks document as falling back to 1 MiB), so wiring
them up would have broken deliberate behaviour. Deleted, with each property naming
where its bound actually lives. The one escape they papered over is closed: an
out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's
constructor now clamps it exactly as the AdminUI driver form already persists.
-006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a
timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable.
An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same
authored instant encoded differently on the two nodes of a redundant pair; it is now
read as UTC.
-007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so
the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus
'/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode
returns an error rather than throwing, and that contract does not change.
IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.
- Write-back section: switch + form text stating it is observation injection, that the
Agent's AllowPut is an independent second gate, and that it does not by itself make a
node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
states — an omitted block would leave a previously-authored true standing, so the switch
could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.
No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.