- ITwinCATClient.BrowseSymbolsAsync XML doc updated: states the implementation now
expands struct/UDT/FB symbols into atomic member leaves via TwinCATSymbolExpander;
callers receive only atomic/array leaves with full InstancePaths, never struct containers.
- AdsSymbolNode: cache IsStruct, Mapped, Children, ReadOnly as readonly fields computed
once in the ctor so repeated property access during recursive expansion doesn't
re-materialize or re-invoke MapSymbolType/IsSymbolWritable.
- BrowseSymbolsAsync: add operator-gated live risk note next to SymbolsLoadMode.Flat
warning that a real TC3 target may not populate SubSymbols in Flat mode, with
guidance to switch to VirtualTree if members don't surface — do not change mode now.
- TwinCATSymbolExpanderTests: simplify confusing `new string('.', 0)` no-op to `""`.
DisposeRuntimes() now disposes and clears _rmwLocks, _creationLocks, and
_runtimeLocks so ReinitializeAsync/ShutdownAsync cycles don't orphan their
SemaphoreSlim instances. Mirrors the TwinCAT _bitRmwLocks fix already shipped.
Adds `Bit_write_surfaces_device_rejection_status` to AbLegacyBitRmwTests,
verifying that a non-zero libplctag status returned by the parent-word write
in WriteBitInWordAsync propagates as a non-Good OPC UA StatusCode rather than
being silently swallowed. Added a minimal `WriteStatusOverride` hook to
FakeAbLegacyTag (test-project-only) so the read half of the RMW still
returns 0/Good while the write half returns the seeded error code.
M1: add missing (object) cast to UInt64 arm of DecodeScalarBlock switch expression,
matching the Int64 arm style and the comment that each arm is boxed explicitly.
M2: short-circuit Timer/Counter writes in WriteAsync to BadNotWritable before
WriteOneAsync, so transient equipment-tag refs (Writable=true from parser) return
the same status code as authored tags rejected at init — documented in the docs.
Adds 6 pure unit tests pinning the area-detection precondition the guard relies on.
EncodeScalarBlock Timer/Counter throws remain as the defensive backstop.
- DecodeRegisterArray: add String and BitInRegister cases replacing the
default:throw; each element decoded by reusing DecodeRegister on its
contiguous register slice → string[] / bool[]
- ModbusEquipmentTagParser.TryParse: read optional arrayLength key from
TagConfig JSON and thread it into ModbusTagDefinition.ArrayCount
(null when absent or zero, preserving scalar behaviour)
- ModbusArrayTests: 8 new tests covering the two decode cases and the
equipment-tag parser/resolver path; 285/285 green
Replaces the bare-TCP AbLegacyDriverProbe with a two-phase probe:
Phase 1 is the existing TCP preflight; Phase 2 initialises a
LibplctagLegacyTagRuntime (Protocol.ab_eip + per-family PlcType) to
open a real PCCC-over-EIP session, using AbLegacyProbeOptions.ProbeAddress
("S:0") as the probe tag. Status-code discrimination mirrors the AbCip
probe: ErrorNotFound/ErrorNoMatch/ErrorBadDevice → Ok=true "controller
reachable"; transport errors → Ok=false "handshake failed".
Adds AbLegacyDriverProbeTests (5 unit tests, all green, 168 total).
Replace the bare-TCP-only AbCipDriverProbe with a two-phase check:
Phase 1 keeps the existing TCP preflight; Phase 2 initialises a
LibplctagTagRuntime against the first device to open a real EIP session
and CIP Forward Open, so a live-but-rejecting CIP endpoint reads red
instead of a false-positive green.
Status mapping: ErrorNotFound / ErrorNoMatch / ErrorBadDevice → reachable
(controller answered CIP, probe tag absent); ErrorTimeout / ErrorBadConnection
/ ErrorBadGateway / ErrorWinsock / ErrorOpen / ErrorClose / ErrorRead /
ErrorWrite / ErrorBadReply / ErrorRemoteErr / ErrorPartial / ErrorAbort →
handshake failed. LibPlcTagException message text is used as a secondary
signal for the reachable-exception path. All other statuses default to
handshake-failed (conservative).
Add AbCipDriverProbeTests: invalid JSON, no devices, malformed host address,
closed-port TCP rejection, and black-hole timeout — all offline-determinable.
Happy path + CIP-error path covered live against the CIP sim.
Replace the bare TCP-connect return in OpcUaClientDriverProbe with a real
OPC UA GetEndpoints discovery handshake (mirroring SelectMatchingEndpointAsync
in the driver). TCP preflight still fast-fails closed ports; the handshake
confirms the remote is actually an OPC UA server, so a live-but-rejecting
non-OPC-UA process now reads RED instead of a false-healthy green.
Replace bare TCP-connect with a two-phase probe: Phase 1 keeps the
existing SocketException / timeout / generic preflight paths unchanged;
Phase 2 runs Plc.OpenAsync (COTP CR/CC + S7 setup-communication) so a
device that accepts TCP but is not an S7 PLC reads red instead of green.
A linked CTS distinguishes caller cancellation ("timed out") from the
S7netplus internal read-timeout OCE ("handshake failed: timed out").
Replace the bare TCP-connect probe in ModbusDriverProbe with a two-phase
check: TCP connect via ModbusTcpTransport (keeps the same SocketException /
timeout / generic error paths and messages), then a one-shot FC03 Read
Holding Registers (qty 1 @ addr 0). A normal response → Ok=true "Modbus
FC03 OK"; a Modbus exception PDU → Ok=true "Modbus FC03 OK (device
returned exception PDU)"; any other failure after TCP succeeds → Ok=false
"Reachable at host:port but Modbus FC03 handshake failed: …".
Add ModbusDriverProbeTests (6 tests) covering invalid JSON, missing
host/port, closed port, TCP-accept-then-close, canned MBAP happy path,
and Modbus exception PDU path. All 277 Modbus tests green.
Add IFocasClientFactory.EnsureUsable() — a config-time probe called by
FocasDriver.InitializeAsync before any background loops start. The
UnimplementedFocasClientFactory throws NotSupportedException immediately
(faulting the driver at init), eliminating the footgun where a driver on
the 'unimplemented' backend appeared Healthy then failed every read/write/
subscribe silently. WireFocasClientFactory and FakeFocasClientFactory are
no-ops. Backstop Create() throw remains in place.
Make MapDataType internal, split the combined Int64/UInt64 arm to return
DriverDataType.Int64 and DriverDataType.UInt64 respectively, and remove
the now-stale Driver.Modbus-007 caveat doc block and inline comment.
Add a Theory covering both cases; full suite 271/271 green.
The owning DriverInstanceActor re-subscribes alarms on every Connected
entry (DetachAlarmSource nulls its cached handle on Connected->Reconnecting
without calling UnsubscribeAlarmsAsync), and the driver object + its alarm
projection are reused across every in-place reconnect. Each SubscribeAsync
started a fresh, never-cancelled Task.Run poll loop and added it to _subs,
so N reconnects leaked N concurrent loops all polling the device and all
firing the same raise/clear transitions => duplicate alarm events + CPU/mem
growth.
Mirrors the Galaxy #399 fix (Clear-before-Add) but for live poll loops the
collapse must also CANCEL the superseded loops, not just drop references.
SubscribeAsync now snapshots existing subs under _subsLock, clears _subs,
adds the new sub, starts its loop, then retires each stale sub out-of-band
(RetireAsync: Cancel + await loop + Dispose CTS, fire-and-forget so the new
subscription's return isn't blocked on a poll interval). Snapshot+clear under
the same lock DisposeAsync uses guarantees no double-own / double-dispose.
There is exactly one consumer per driver instance (factory-per-actor), so
retiring all prior subscriptions before starting the new one is faithful.
Regression tests (TDD, fail->pass): subscribe twice then drive one device
raise; assert OnAlarmEvent fires exactly once (was twice with two leaked
loops).
GalaxyDriver's StreamAlarms feed is session-less and survives an in-place
reconnect, so DriverInstanceActor re-subscribed on every Connected re-entry
(after dropping its own cached handle without an Unsubscribe — sync teardown).
The re-subscribe was additive: _alarmSubscriptions.Add grew the list by one
untracked handle per reconnect cycle — a slow unbounded leak. Functionally
harmless (the gate is Count>0 and OnAlarmFeedTransition only reads [0], firing
once regardless), but it accumulated forever.
Fix: SubscribeAlarmsAsync clears the set before adding, collapsing to a single
live handle (under the existing _alarmHandlersLock, atomic w.r.t. the fan-out
reader). There is exactly one consumer per driver instance (factory-per-actor
lifecycle), so replacing the set with the latest handle is faithful. Chosen
over making the actor's sync DetachAlarmSource call UnsubscribeAlarmsAsync
async/fire-and-forget — disproportionate for a minor leak.
Regression test Re_subscribe_collapses_to_a_single_handle_no_accumulation
(TDD-verified: FAILS without the Clear — releasing the latest handle leaves
the feed open because stale handles remain; PASSES with the fix). Galaxy tests
263 pass / 3 skip; Runtime native-alarm 24 pass. Code-reviewed (approved).
Code-review refinement of the live-gw read-back helper: complete a
TaskCompletionSource<double?> from the pump instead of a captured local (explicit
cross-task visibility), pass bufferedUpdateIntervalMs:0 (Advise snapshot needs no
SetBufferedUpdateInterval), and document the Advise->OnDataChange filter. Live re-verified 2/2.
Skip-gated (MXGW_ENDPOINT + GALAXY_MXGW_API_KEY) like GatewayGalaxyAlarmFeedLiveTests.
Covers the two seams the unit suite can only fake in isolation:
- reopen: RecreateAsync + InvalidateHandleCaches re-establish write handles
- write: no-login supervisory write commits + persists (fresh-session read-back)
Live-verified 2/2 against 10.100.0.48:5120 (2026-06-14); skips cleanly in CI.