Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning
today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads
is now bool? (null = default ON, never serialised, same encoding as
Transport); the form omits the key when the switch is on and writes only an
explicit false. Persisted coalesceReads:true keeps loading as on.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 M3. The indirect sweep disposed stale resolutions without holding
their gates, and any reader/writer that fetched a handle before waiting on its
gate could then use an instance a sibling had evicted and disposed - a
spurious comms fault and Degraded. The sweep now evicts under each key's gate,
and read/write re-resolve the handle once the gate is held (reconnecting
single-flight if it was evicted; a retired state never reconnects).
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 I3. The array encoder used the AUTHORED dataType's width while both
runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts
into a 16-byte float buffer and sent it all (garbage neighbours, answered
Good); Long/Float on N overran it.
- The write path resolves the element width from the data file the address
names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width.
- An authored type that does not match its file is refused BadTypeMismatch
before any runtime exists (warned once, no health write).
- The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an
Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig
still accepts the tag so persisted configs keep loading.
- M6: the skipped F1 parity test gains the shapes the write path refuses
(/bit, .sub, indirect, type/file mismatch, PLC-5 native).
Tests: one case per file type, a mismatch per pairing with nothing created,
and a positive control.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 I2 + M1/M2/M4/M7/M10.
- I2: one health writer (ApplyHealth) for reads AND writes, worst wins per
call: a comms fault => Degraded; otherwise any answer (Good value, device
refusal, Good or refused write) => Healthy; LastSuccessfulRead advances on
any answer. A refusal-only read used to leave a recovered driver Degraded,
and writes never touched health at all.
- M10: a no-reply write STATUS now degrades like the exception path did; a
Good/refused write records an answer on the device's backoff, a no-reply
one a fault (bit RMW included — it now reports its raw status).
- M2: a write inside the device's backoff window fails fast with
BadCommunicationError even when its handle is cached.
- M1: ShutdownAsync/Dispose write their final Unknown only while the epoch
they retired is still current (a newer initialise owns health otherwise).
- M4: an indirect bit write releases the previous index value's parent-word
handle (the per-word RMW gate is deliberately kept: removing a gate another
writer may hold would let two RMWs of one word race).
- M7: backoff decisions read an injectable Clock; the isolation tests drop
their stopwatch asserts and cross the window on a manual clock.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review m-2. A Calculation driver's own maxAllocBytesPerRun /
quarantineAfterConsecutiveTimeouts used to be clamped silently by
ScriptRunGuard. ResolveScripting now sanitizes the layered options
itself and returns the adjustments, and the factory logs one Warning
per clamped value, naming the driver. docs/ScriptEditor.md "Runtime
safety" gains the key table: defaults, the 1 MB allocation floor, the
1000 caps, NaN and disable semantics, and the per-driver override.
docs/Configuration.md has no Scripting section, so these keys have no
other home.
Refs #731#734
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC
STS, e.g. an element past the end of the file; or a CIP error) arrives as
ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a
no-reply comms fault. A coalesced block with one such member then failed as a
group every poll, and two refused tags in a row opened the device's fail-fast
window against a device that was answering.
- LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the
existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused
the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's).
- IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now
the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status
except Unsupported (BadReply / Partial included: a reply arrived).
- A coalesced block refused with ANY returned status falls back to per-member
reads in the same pass; the shape is remembered only after a repeat, and the
refused block's handle is always evicted (M5).
- A runtime status Unsupported is neutral (keeps the handle); the probe no
longer counts it as a fault.
- Refusal warnings log the status actually answered (M8).
Live: the new past-end-of-file ab_server case fails on libplctag with the old
translation and passes with it, on slc500/micrologix/plc5 x both transports.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
N1: a refusal of the DWORD-addressed request permanently settled the layout
as per-element, so a tag deployed before the online edit that creates its
BOOL array later read DWORD n and published byte 0 as Good. The native runtime
now keeps an explicit layout (Unsettled / Packed / PerElement) that ONLY a
reply type changes: 0xD3 settles packed (Bits[n/32], bit n%32), 0xC1 settles
per-element (Bits[n]). Refusals and comms faults leave it unsettled, so the
next read asks again. A reply whose type disagrees with the addressing it was
asked under is discarded and re-read once under the shown layout (it would be
some other element), also when an online edit re-types a settled array. A
scalar BOOL answered 0xD3 with no element bit, or not settled packed, is
refused BadTypeMismatch instead of decoding byte 0.
m1: the write-owned Degraded marker is set together with the CAS that installs
it, under one lock, so concurrent failing writes cannot leave it naming a
different instance (a good write then clears it); a refusal-only read batch
still keeps it write-owned.
m2/m3/m5: documented (breaker accounting of a superseded init; libplctag
throw-only NotImplemented assumption; the TypeMismatch write status word is
libplctag-only).
m4: test for a write on an unsettled layout (settles by a read first).
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- I1: the poll engine belongs to a teardown generation. ShutdownAsync/Dispose
retire the current engine and install a fresh one in the same swap as the
device/tag tables, then dispose the retired one (possibly in the background
after a cancelled or over-budget shutdown). A subscription made after the
next InitializeAsync can no longer land in an engine whose late dispose tail
would dispose its CTS without cancelling it (a loop no UnsubscribeAsync could
find). Poll handles are generation-qualified (TwinCATPollHandle) because each
engine numbers handles from 1; SubscribeAsync re-homes a subscription that
raced a retirement.
- M1: a write's status feeds per-device health - Good clears that device's
comms fault and advances LastSuccessfulRead, a comms status degrades, a
refusal keeps the state.
- M2: a symbol-version re-arm bumps a per-registration epoch; an in-flight
retry that fails afterwards no longer re-imposes the backoff.
- M3: a re-arm restarts the refusal backoff at 30 s.
- M4: documented that PLC-not-in-RUN detection needs the probe.
- M5: WSTRING and multi-dimensional array encode-plan tests; the cancelled-
shutdown test asserts Unknown health and awaits its background teardown.
- M6: an infinity spelled as a string ('Infinity', '-inf') passes like a
float/double infinity; a finite overflow stays BadOutOfRange.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
N1: linkDown is set only when NO reply arrived, i.e. a timeout, a transport exception or
a connect failure. A status the CNC returned proves it answered. EW_REJECT and every
unlisted rc map to BadCommunicationError, so a deterministic rc on one tag used to fail
every later tag of a reachable control on every poll. The comms-classed status still
degrades health. The coalescer already treated a returned refusal this way; it is
unchanged.
Tests: a returned EW_REJECT on tag 1 leaves tags 2 and 3 read and Good (red before). A
timeout still fails the rest without reading them (positive control).
Minors:
- m1: the _lifecycleLock doc now covers the health transitions it also guards.
- m2: the NoteFixedTreeFailed summary says a refusal credits the answer.
- m3: ReadAsync stamps a failed batch's exception with the generation the batch started
under, and HandlePollError degrades that generation only.
- m4: the fixed-tree "blip" absence check runs unconditionally under a wide budget, and
asserts that both ticks landed inside it.
- m5: direct unit cases for the coalescer link-down paths. A transport fault on the first
range fails the second range and the per-tag tag without reading them. A connect
failure costs one connect per batch. CncRelay.Up() retries the re-bind.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native
runtime now keeps the original element path until the first reply settles the
layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g.
a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to
Bits[n] and re-reads, and a write settles the layout by a read first, so it can
never decode or write element n/32 of an element-addressed array.
I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they
now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved).
ErrorNotImplemented - and the native runtime's own pre-send refusals - raise
AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT
advanced, no backoff record, not reachable for the probe or Test Connect.
M1: the bit RMW's parent read/write status words follow the health contract
(refusal: per-tag Bad + Warning; comms: evict the parent + Degraded).
M2: device Unsupported read refusals return Refused (advance
LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the
pre-send native type gate).
M3: a good write clears a Degraded a write comms fault recorded (a read-side
Degraded is still left for the read path).
M4: a superseded init throws AbCipInitializationSupersededException (not an
OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile.
M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the
unencodable-array test asserts health.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- IMPORTANT: a successful probe records the PLC's ADS state; a PLC not in Run
keeps the driver Degraded with 'PLC in {state}' while LastSuccessfulRead
still advances (the device answered). RecordDeviceAnswered never restores
Healthy while any device is out of Run.
- Health is per device, rolled up worst-of: a comms fault is remembered on the
device it was seen on and only that device answering clears it (device A
answering no longer clears device B's fault).
- Refused (non-comms) dead registrations back off 30 s doubling to 10 min and
are re-armed by a symbol-version change or retried by the reconnect replay;
comms-class failures keep the every-tick retry. A retry whose failure class
changes republishes the tag's status, once.
- Bit-RMW gates are kept across generations and teardowns (never cleared).
- ShutdownAsync honours its CancellationToken: it stops waiting, finishes the
bookkeeping and throws OperationCanceledException.
- Remarks on why a superseded InitializeAsync throws OperationCanceledException.
- REAL / LREAL values (scalar or array element) outside the target range are
OverflowException -> BadOutOfRange instead of being written as Infinity.
- String / array write encoding factored into AdsTwinCATClient.PlanStringWrite
/ PlanArrayWrite; the fake runs them so a driver-level test covers the real
encode path against a fake resolved symbol.
- Timing-fragile tests use condition waits / measured probe-tick windows; the
dispose-during-shutdown test asserts ordering with a 1 s bound.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 U7 (AbLegacy part): probe/read/write had no OperationCanceledException
arm, so cancelling a slow read printed an unhandled-exception stack trace. The
driver work now runs through AbLegacyCommandBase.RunCancellableAsync, which
reports an operator cancellation as "Cancelled." and still propagates anything
else. (The AbCip subscribe lock and SnapshotFormatter array rendering are
other lanes' files.)
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
I1: Task.WhenAll waits for the slowest device, so concurrency alone still let a device
that accepts TCP and then stalls cost (its tag count x call timeout) per batch. After the
first timeout, transport failure or comms-class status on a device, its remaining tags in
the batch now fail BadCommunicationError immediately, the same as the coalescer's
FailMembers, which now also stops after a failed range. FIXED: tags are still served
from the cache. A malformed address is BadNodeIdUnknown and does not count as a link
failure. Test: a 10-tag stalled device beside a healthy one completes in under 3 call
timeouts with the healthy value Good. The P9 test now also asserts per-client read
counts and that two reads overlapped across devices.
Minors:
- 1: a superseded init releases its table only if it was never published (flag).
- 2: a fixed-tree refusal credits LastSuccessfulRead; a value-path refusal after a comms
fault restores Healthy.
- 5: alarm/fixed/refusal health is reset inside the publish lock, after the generation
check.
- 6: FocasFixedTreeHealthTests wait on served-failure/refusal counters, not wall-clock.
- 8: data-path health writes (reads, coalescer, writes, poll errors, refusals) carry the
generation they started under and are dropped once a lifecycle call has moved on. Each
write is a check-and-set under the lifecycle lock.
Also adds a unit case where 20 vendor records with type 0 and empty messages stay vendor.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Dead config field (user decision: wire them all): the AdminUI form authors
probeTimeoutSeconds ("Admin UI probe timeout", default 5) but AbLegacyDriverProbe
never read it, so Test Connect always ran on the caller's budget. It now
tightens that budget (never raises it), the same rule as the Galaxy probe; a
non-positive value keeps the caller's budget.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 02 P1 / Wave 2 rule (d): AbLegacy runs PollGroupEngine but raised only
the per-tag OnDataChange, so every changed tag paid its own trip through the
actor chain. The engine's per-cycle batch is now raised as OnDataChangeBatch
(one per poll cycle), alongside the per-tag event for legacy consumers.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 P7 (rest): adjacent words on one data file were never coalesced
(one round trip per tag) although the array path proves the transport can read
a span. Within a device pass, scalar tags on one N/B/F/L file within 8
elements of each other (block <= 200 data bytes, under the 236-byte typed-read
limit) now ride one multi-element read and are decoded from their offsets with
exactly the CLR types the single-tag path returns (int / float / bool).
Only shapes the single path decodes identically are coalesced: indirect,
sub-element, array, file-number-less (S:/I:/O:) and width-mismatched tags keep
the single path. A block the device refuses (an authored element past the end
of the file, a native PLC-5 non-N file) falls back to per-tag reads for its
members and its shape is remembered; a comms fault fails the members together.
New driver-config key coalesceReads (default true) with an AdminUI switch
(Razor change: AbLegacyDriverForm, for the Wave 4 live check). Adds an
env-gated ab_server parity case (coalesced == per-tag values).
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
F10 follow-through: removing IDisposable outright broke a synchronous
drv.Dispose() call site outside this lane (Runtime.Tests
AbCipAlmaNativeAlarmEndToEndTests). The sync Dispose() is back, but it starts
the same teardown as DisposeAsync and returns instead of parking the caller on
DisposeAsync().AsTask().GetAwaiter().GetResult(); a background fault is
logged. The runtime still takes the IAsyncDisposable path (preferred, never
both).
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 P7 (High): reads walked every reference of every device serially
with no backoff, so a dead device cost Timeout per tag per poll (50 tags x 2 s
= 100 s) and starved every healthy device long enough to trip the comms-loss
watchdog against a reachable one.
- ReadAsync buckets references by device and reads up to 8 devices
concurrently; each device's tags are still read in order.
- Each device has a ConnectionBackoff (1 s doubling to 30 s). Two consecutive
comms faults mark it down: the rest of that pass and every later pass fail
fast with BadCommunicationError and touch no wire until the window expires or
any answer (data path or probe) resets it. One timing-out tag does not
blacken its answering siblings.
- Cold connects are single-flight per device (ConnectGate), replacing the
per-key creation locks; a cached handle never touches the gate.
- A status/exception proving the device ANSWERED with a deterministic refusal
(not found, not allowed, out of bounds, type/size mismatch) is per-tag Bad,
keeps its handle, warns once per tag and advances LastSuccessfulRead instead
of evicting and degrading. Writes classify the same way.
- Health is written once per read: Degraded if any device faulted, Healthy if
anything read Good, LastSuccessfulRead advanced when any device answered.
Adds an env-gated ab_server case with a blackholed sibling device.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
P11: AbCipUdtReadPlanner called AbCipUdtMemberLayout.TryBuild(parent.Members)
for every member reference on every poll - O(members^2) per parent per tick,
a dictionary allocation each time. GetOrBuild caches the layout (including a
"not groupable" verdict) in a ConditionalWeakTable keyed by the member list's
identity, which is immutable for the life of the tag table; a re-init's new
lists replace the entries and the old ones are collected with them.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
U10 (user decision: implement array encode now): config, the AdminUI and
discovery all advertised array tags Operate-writable, but both runtimes encoded
a scalar - an int[] threw InvalidCastException (BadTypeMismatch AND Degraded),
a scalar wrote element 0 natively, and libplctag re-sent its whole last-read
buffer with element 0 patched (stale elements 1..n-1).
- AbCipArrayCodec encodes count elements little-endian at the element width,
CONVERTING each element (ScadaBridge writes int[] to INT arrays; overflow is
BadOutOfRange, unconvertible BadTypeMismatch, both before anything is sent).
- The driver checks the shape before any runtime exists: not a list ->
BadTypeMismatch, length != elementCount -> BadOutOfRange, BOOL/STRING/
structure element types -> BadNotSupported; and, before sending, a controller
buffer that is not width x count -> BadTypeMismatch. Refusals warn once and
write no health.
- Native: ONE Write Tag of count elements with the controller's type code (the
client fragments a large one); libplctag: the codec's bytes copied into the
elem_count-sized buffer, refused TooSmall if it does not fit.
- IAbCipTagRuntime.EncodeArray (default: NotSupportedException).
Real-controller proof is a hardware userGate (see the lane report recipe).
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
U11: a scalar BOOL authored over Bits[n] requested element n and decoded
"any of bits 0-7 of the reply set", so Bits[n] read Good with the subscript
ignored. A Logix BOOL array is stored as DWORDs (0xD3) and its element segment
addresses them, so the native runtime now requests DWORD n / 32 and decodes bit
n % 32 when the controller answers 0xD3 (a plain BOOL reply still decodes its
byte). libplctag exposes no type code and so cannot say which DWORD it read: a
4-byte reply for such a tag is refused BadNotSupported (warned once) instead of
publishing a wrong value as Good. Real-controller proof is a hardware gate.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Applies the driver-lane health contract to the value path. Every non-Good read status used
to write Degraded on every poll, so one vacant macro (BadNoData), one EW_NUMBER address
(BadOutOfRange) or one fractional macro on an integer tag made a reachable CNC look sick
forever. Now:
- a comms-class status (BadCommunicationError / BadInternalError (EW_HANDLE) / BadTimeout)
still degrades, with LastSuccessfulRead preserved;
- any other non-Good status is a refusal: the tag reads Bad, one Warning is logged per tag
(re-armed when it next reads Good, cleared at init), driver state is untouched, and
LastSuccessfulRead advances because the CNC answered (CAS, since devices now read
concurrently);
- a write refused with NotSupportedException no longer degrades the driver.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The poll engine already builds a per-cycle batch; TwinCAT now forwards it as
OnDataChangeBatch, so DriverInstanceActor gets one message per poll cycle
instead of one per changed tag. Because the actor subscribes ONLY the batch
event of a batch-capable driver, every native-path data change (pushes, the
BadNodeIdUnknown / refusal / unavailable snapshots) is raised through a single
RaiseDataChange that fires both the per-tag event and a fresh one-item batch.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 U10 (user decision: implement array encode now). Both runtimes
encoded a scalar for an array tag: an int[] threw InvalidCastException into
BadTypeMismatch + Degraded, a scalar wrote element 0 natively, and libplctag's
SetInt32(0, ..) re-sent the stale last-read buffer for elements 1..n-1.
IAbLegacyTagRuntime gains EncodeArray; both runtimes write every element at
the element width (Int/AnalogInt 2 bytes, Long/Float 4). The driver converts
the written value before any runtime exists: each element is converted, not
exact-matched (ScadaBridge writes int[] to Int16 nodes), a length other than
the tag's element count is BadOutOfRange, an overflowing element is
BadOutOfRange, a non-array or non-numeric payload is BadTypeMismatch, and a Bit
array (bit-packed words; an encode would clobber neighbour bits) is
BadNotSupported. None of these touch health or the wire. Adds an env-gated
ab_server round trip for an N-file array write.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Program.cs binds ScriptingOptions from Scripting:* once, clamps every
numeric knob through ScriptingOptions.Sanitized (logging a Warning per
adjustment) and registers the one instance. It now reaches all three
script seams: RoslynVirtualTagEvaluator, the scripted-alarm engine
built in DriverHostActor (new trailing Props parameter, wired in
WithOtOpcUaRuntimeActors) and every Calculation driver (node options
as the base, per-driver config keys override one knob on a copy).
ScriptRunGuard sanitizes again in its ctor and computes the stuck
threshold with a saturating multiply, so an absurd
StuckOrphanQuarantineFactor can no longer throw OverflowException on
a refused run.
Refs #731#734
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- Device and tag tables are build-and-swap: InitializeAsync builds a fresh
generation and publishes it by one swap under a lifecycle lock, then
releases the replaced generation; teardown swaps in empty tables. A
retained poll loop never sees a table mid-Clear (it used to read
_tagsByRawPath while a re-init only ever added to it), and each device
set is released by exactly one caller.
- A lifecycle generation makes an InitializeAsync overtaken by a shutdown
(or a newer init) release what it built instead of installing it - the
Lane A abandoned-init-overlaps-shutdown case.
- ShutdownAsync joins all probe loops together within 2 s (it awaited each
unbounded - a probe wedged in the blocking ADS Connect hung the actor's
teardown) in parallel with the poll-engine dispose, the whole bounded at
4.5 s; DisposeAsync may run concurrently and returns at once. A connect
that completes on a released device disposes its client.
- Releasing a generation disposes its native registrations, so a direct
re-init no longer leaves live notifications bound to dead DeviceStates.
- Connect gates and RMW semaphores are no longer disposed under in-flight
users (no kernel handle is ever allocated); a fault on a released device
is not recorded as driver health.
- The notification channel counts DropWrite overflows
(ITwinCATClient.DroppedNotificationCount); the probe tick warns with the
delta. A queued value whose registration was disposed is purged in the
client dispatch loop and dropped driver-side for any client.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
H19: Format/InvalidCast/Overflow on a client value never touched the wire but
latched Degraded; a good write set Healthy and so undid a Degraded the poll
loop had just recorded for other tags (the batch read path is worst-wins for
exactly this reason).
- The three conversion failures map to BadTypeMismatch/BadOutOfRange with a
once-per-tag Warning and no health write (AbLegacy's rule).
- A good write (scalar or bit RMW) advances LastSuccessfulRead only - the
device answered - and leaves the state alone.
- A write comms fault (thrown, or a non-refusal status word, which used to
write no health at all) is Degraded with LastSuccessfulRead preserved and
never downgrades Faulted.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
ParseAlarms took the 76-byte vendor layout whenever Length % 76 == 0, so a legacy 80-byte
reply of exactly 19 records (1,520 B = 20 x 76) decoded as 20 garbage vendor records.
The response block carries no record count (its 16-byte header is length/class/path/
command/rc/payload length), so the layout is chosen from what is known: the record count
the request asked for (a reply never holds more), then each layout's field invariants on
every record. For legacy records, the int32 type/axis/length must fit a short and the
length must be at most 64. For vendor records, the i16 length must be at most 64. If both
still fit, the layout whose declared lengths match the decoded text wins. Vendor stays the
default when both readings are equally plausible, and unambiguous lengths are unchanged.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
H17: the .n read-modify-write hard-coded a DINT parent. On an INT or SINT host
(both legal in Logix, and reads of Flags.3 there already worked) the DINT
decode of a 2-byte buffer threw OutOfBounds, the generic catch evicted the BIT
tag's (non-existent) runtime and reported BadCommunicationError + Degraded.
The parent width now comes from the parent read's size (1 SINT / 2 INT /
4 DINT / 8 LINT; unknown keeps the DINT assumption). Narrow hosts use unsigned
arithmetic so a set top bit encodes without overflow; a bit past the host's
width is BadOutOfRange and a non-integer width BadTypeMismatch, both refused
before anything is written, warned once, no health write. A transport failure
in the RMW now evicts the parent runtime it actually used. Native end-to-end:
an INT host is written as ONE INT (type 0xC3, 2 bytes).
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The handle-recycle loop disposed the device client with no gate. It could null a client a
concurrent EnsureConnectedAsync had just installed (orphaning a live two-socket session),
or dispose one a poll was mid-read on (ObjectDisposedException -> BadCommunicationError +
Degraded on every recycle interval).
The recycle now takes the device's ConnectGate, detaches the client, and disposes it via
SynchronizedFocasClient.DisposeWhenIdleAsync, which waits (bounded by the call timeout plus
slack) for ops that already hold the client. The gate stays held through the drain, so no
fresh session opens beside the old one.
SynchronizedFocasClient no longer disposes its call gate. Releasing a disposed semaphore
threw out of reads that had otherwise completed. Ops that reach a disposed client now get
ObjectDisposedException and are never run on the dead socket.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 H16: an array of String/Timer/Counter/Control passed the mapper, so
every poll created a handle (one init read), the decode threw
NotSupportedException, the generic catch evicted the handle and latched
Degraded. FromTagConfig now rejects that combination (the skip carries the
reason, Inspect/deploy gate and the AdminUI model state it), and a runtime
NotSupportedException on read or write is BadNotSupported: handle kept, no
health write, one Warning per tag and operation. Write refusals (native PLC-5
and status-bit writes, libplctag translated addresses) now warn once per tag too.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
P8: on the native transport the bounded-parallel read window is also the cap
on services per Multiple Service Packet (the device's shared scheduler packs
whatever is queued, within the connection's request/reply byte budget), so the
default 8 cost a 1000-tag controller ~125 round trips per poll instead of ~10.
The effective window (AbCipDriver.ReadWindow) is now max(64, option) when the
native tag factory serves reads; libplctag and injected factories keep the
option exactly. Option doc + AdminUI help text updated (Razor: Wave 4 live
check).
P7 addendum: a work item now takes its window slot only after its runtime
exists. A cold create queues on its device's single-flight connect gate, and
holding a slot there let one dead device's backoff-expiry attempt occupy the
whole window for a full Timeout, stalling every other device's reads.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
ReadAsync walked the whole batch sequentially, so one SLOW CNC (TCP-accept-then-stall,
the live 31i-B case) cost Timeout per tag per poll while no other device was read.
ReadAsync now resolves every reference against one table snapshot, buckets the rest by
device and reads the devices with Task.WhenAll; each device's tags still go in order on
its own serialised socket (the PMC coalescing pre-pass runs per device, first). Results
land at their own indices.
Also precomputes each device's fixed-tree key prefix (it was built per reference, twice
per poll).
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review 07 L7: each index value an indirect tag (N7:[N7:0]) swept through left a
per-runtime and a creation SemaphoreSlim in the device's gate tables until
shutdown. A moved index now drops the stale resolution's gates along with its
handle, and sweeps any other stale resolution of the same tag a concurrent
reader cached, so the tables stay bounded by the authored set.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Found by the H13 stress test (200 re-inits under a concurrent reader): a read
that resolved the device just before the table swap could cold-create a
runtime into the retired state after its teardown had disposed it (leaked
native handle / lease), and could hit a per-tag SemaphoreSlim the teardown had
disposed, which the generic catch reported as a comms fault (Degraded).
- DeviceState.Retire marks the state retired before disposing handles; each
handle is removed-then-disposed exactly once. A creator re-checks the flag
after its TryAdd (fenced, Dekker-paired with Retire) and disposes the orphan.
- A failure on a retired device is the teardown's doing: BadCommunicationError
for that one call, no evict, no health signal.
- Per-tag/RMW gates are no longer disposed (async-only waits hold no handle),
matching the ConnectGate rationale.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
H10: the probe loop recorded EVERY exception - including the controller
answering NotFound for a typo'd or download-deleted Probe.ProbeTagPath - as a
Forward-Open failure. At a 5 s tick against a 30 s backoff cap ShouldAttempt
was never true again, so every cold or evicted data tag on a reachable device
read BadCommunicationError indefinitely and the host showed Stopped.
The probe now decides the backoff verdict and the host state separately:
- a device refusal (thrown or as a status word) is backoff success + host
Running + one Warning per device naming the probe tag; an initialised probe
runtime is kept so its lease is not re-opened every tick;
- a malformed probe tag name (AbCipTagConfigurationException) never reached
the device: no backoff signal, no host transition, one Warning;
- a genuine transport failure is unchanged (backoff failure, host Stopped).
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
FocasDataType.String was authorable on any address, but no area read returns text: PMC
fell through to a byte read, PARAM to Int32, MACRO to a double, and the String node
received a boxed number at Good.
One rule, FocasTagDefinitionFactory.DescribeDataTypeMismatch, now backs every path:
- FromTagConfig rejects the pair, so the driver skips + reports it (BadNodeIdUnknown,
never a Good number) and Inspect names the reason at deploy;
- FocasTagConfigModel.Validate refuses it on the typed fields;
- the AdminUI editor only offers String where it is readable and shows the rule's text
inline (kept visible for a loaded blob that already carries it);
- the CLI read/subscribe/probe/write commands refuse it at the boundary.
String stays valid on FIXED: paths (Identity/Program name/mode text) and is ignored on
ALARM: tags; an unparseable address keeps its own diagnosis.
Razor change (FocasTagConfigEditor.razor) needs the Wave 4 live check.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Array tags were advertised writable but every write threw InvalidCastException
in ConvertForWrite (reported BadTypeMismatch); STRING writes went through the
Beckhoff marshaller with no length check against STRING(n) and the host's
ANSI code page, which the read side deliberately avoids.
- Array write: TwinCATArrayCodec.ToWriteElements converts exactly ArrayLength
elements to the declared type (CLR lists convert - ScadaBridge writes int[]
to Int16 nodes; a length mismatch or out-of-range element is BadOutOfRange, a
scalar value BadTypeMismatch) before any wire traffic; the ADS client plans
the layout from the resolved PLC array (TwinCATTypeGuard.CheckArray, as the
read does), encodes with TwinCATArrayCodec.Encode - the exact inverse of
Decode - and writes the raw bytes (new ITwinCATClient.WriteArrayAsync).
- STRING / WSTRING write: encoded at the PLC's declared width by
TwinCATStringCodec.EncodeString/EncodeWString (Windows-1252 or UTF-8 per
target, NUL-padded, strict: an over-long value or unrepresentable character
is OverflowException -> BadOutOfRange) and written as raw bytes.
- TwinCAT CLI read/write gain --array-length (+ --separator) for the hardware
gate.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The fixed-tree loop swallowed every failure at Debug and never touched health, so a
driver serving only FIXED: tags (Identity/Axes/Program/Status, the typical FOCAS shape)
read Healthy on /hosts with every node Bad and the #527 watchdog could never fire.
The fixed tree is now a third health path beside the value and alarm paths, combined in
GetHealth (worst state, latest LastSuccessfulRead):
- an answered bootstrap / axis pass clears the device's failure and advances
LastSuccessfulRead;
- a transport failure degrades the driver once the device has been silent past the
same staleness budget that turns its values Bad (from its last answer, or from loop
start when it never answered), with LastSuccessfulRead frozen;
- a CNC refusal (non-OK rc) is not a comms loss: it clears the failure and credits nothing.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The bit write read and wrote the parent word as a 32-bit UDInt with no parent
check: a bit beyond a 16-bit parent was written into neighbouring bytes, and a
signed parent with its sign bit set read back >= 2^31, so the checked
conversion turned any bit write into BadOutOfRange.
The write now goes through ITwinCATClient.WriteBitAsync: the ADS client
resolves the parent symbol, checks the bit against it (TwinCATBitAccess.
CheckParent, as the notification path already did), reads exactly
parent.ByteSize raw bytes, flips one bit (TwinCATBitAccess.ApplyBitWrite) and
writes the bytes back by index group/offset or variable handle. A bit beyond
the parent is BadOutOfRange, a non-integer parent BadTypeMismatch, a short
parent read BadCommunicationError (never written back), all without a health
write. The per-parent RMW lock stays in the driver.
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
H1/L4: one classification (AbCipRefusals.IsDeviceRefusal) of the statuses
that prove the controller answered - NotFound, OutOfBounds, NotAllowed,
TooLarge, TooSmall, TypeMismatch, Unsupported - shared by the data path, the
Test-Connect probe (IsReachableStatus widened) and the native runtime.
- A refused read/write (thrown or as a status word) is per-tag Bad with a
once-per-tag Warning, keeps the runtime (no evict, so no lease/Forward-Open
churn for a sole lease holder), writes no Degraded and, since the device
answered, advances LastSuccessfulRead without touching the state.
- A refused cold create records the backoff as reachable, so siblings are no
longer throttled for up to 30 s by one Arr[999].
- The native runtime completes InitializeAsync on any refusal (it used to on
TypeMismatch/Unsupported only) and reports it on every later read.
- A refused READ of NotAllowed reads BadNotReadable, not BadNotWritable.
- Test Connect names the refusal ("probe tag refused: OutOfBounds").
0x1C/0x20/0xFF-0x2104 still fold into BadReply inside Driver.Ab.Transport
(Lane K follow-up); they remain comms faults until mapped there.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The capability matrix admits #0..99999 on 30i/31i/32i (and anything on Unknown), and
the FOCAS/2 request block has always carried 32-bit arguments, but WireFocasClient cast
every number to short: MACRO:99999 asked the CNC for #-31073 and published that variable
at Good under the authored tag. FocasWireClient's macro/parameter reads, WireMacro.Number
and WireParameter.DataNumber are now int; the request blocks come from MacroRequest /
ParameterRequest builders (byte-identical to the live 31i-B capture for #500).
Same class on PMC: a range whose last byte passes 65535 (Unknown series admits it) is now
refused BadOutOfRange instead of wrapping onto a different 16-bit address.
The wire client is in-process (the other end is the CNC itself), so this is not a
cross-repo contract change. Whether a real 30i honours cnc_rdmacro Arg1 > 32767 is the
hardware userGate.
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT