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