Commit Graph

920 Commits

Author SHA1 Message Date
Joseph Doherty 29c829b540 test(mtconnect): a stale-session pump may finish before the restart retires it
Since the T-1 generation guard a pump stops on its own once its session generation
is gone, so the STAB-20 test's 'the retired pump is retained' assertion raced it
(intermittent failure). The test now accepts either outcome - retained for shutdown
to join, or already finished - which is what the retention exists to guarantee.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:45:54 -04:00
Joseph Doherty 994c0b3b19 refactor(mtconnect): delete dead parsed fields (R06:T-3)
MTConnectObservation.SampleRate was parsed per observation and never read;
MTConnectTagDefinition.MtType/MtSubType/Units were carried (factory blob DTO,
tags[] DTO, probe inference) and only MtCategory was ever read. Deleted. Stored
configs and TagConfig blobs still carrying mtType/mtSubType/units keep loading
(System.Text.Json skips unmapped members) - pinned by a new test. The AdminUI tag
editor reads those keys straight off the blob and is unaffected.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:42:32 -04:00
Joseph Doherty b548cc46e6 perf(mtconnect): read through the live pump index; instance-id check on the /current fallback (R06:T-4, T-2)
- T-4: every ReadAsync batch fetched and parsed the whole device /current and
  built a fresh index even while the /sample pump held a live one. A read is now
  answered from the pump's index (read only - the pump stays its single writer)
  while the current-generation pump is running, has delivered since it started and
  is not degraded; /current stays the fallback, and its per-batch index is built
  over a tag map frozen once per binding instead of per call. A served read clears
  the read-path degradation a failed /current left.
- T-2: the /current fallback checks the answer's instanceId; a change (an Agent
  restart seen by a browse-only driver) drops the cached /probe model and raises
  rediscovery once per distinct id, only while the read's session is still installed.
- The pump also acts on no verdict (Degrade / latch) once its session generation
  is gone, so an abandoned pump cannot write health over the new session (T-1 follow-through).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:39:58 -04:00
Joseph Doherty 77bfadbdda fix(mtconnect): generation guard on the pump's cursor publish, chunks and re-baseline (R06:T-1)
A pump the lifecycle retired or abandoned that finished after the restart
installed the new session published its stale cursor onto it (PublishAgentCursor
only checked for a null or identical session), so the next pump read its first
chunk as an Agent restart: index cleared, a Bad flicker on every subscribed tag,
a needless re-baseline. The pump now carries the session generation it was
started against; the cursor publish is checked under _subscriptionLock (where the
session install and generation bump are atomic) and a superseded pump also stops
folding late chunks / re-baselines into the new session's index.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:36:24 -04:00
Joseph Doherty fcdc44f9f7 feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)
- AttributesAsync was a stub returning nothing; it now reads NodeClass /
  DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them
  through UpstreamAttributeMapping, the same mapping discovery registers with (moved
  into the shared .Session leaf; the driver forwards to it). Objects have no value
  attributes.
- Certificate auth was refused "in v1". The browse now builds its identity through
  OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it),
  including the secret: resolution of Password / UserCertificatePassword through the
  host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret:
  ref with no store throws instead of being sent as the literal (the browse used to
  send a secret: Password verbatim). The identity is built before any network I/O.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:33:08 -04:00
Joseph Doherty d98f4a5bf1 fix(opcuaclient): wire ProbeTimeoutSeconds into the Test Connect probe (R06:O-4)
The knob was authored by the AdminUI and parsed, but consumed by nothing. It now
caps the probe deadline (shorter of the caller's budget and the config; never
extends it; 1 s floor), the same rule as the Galaxy probe.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:28:20 -04:00
Joseph Doherty cba3b2e960 fix(opcuaclient): writes feed health; a good keep-alive clears a stale Degraded
Health contract: a write response (per-node refusals included) is the server
answering, so it restores Healthy and advances LastSuccessfulRead; a write with no
reply (transport failure / timeout) writes Degraded with the stamp preserved; a
caller-cancelled write touches nothing. A good keep-alive with no outage or dead
subscription latched now clears a Degraded left by a failed read/write instead of
only moving the stamp (a subscription-only driver otherwise stayed Degraded until
its next re-initialise). All writes are generation-stamped.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:26:43 -04:00
Joseph Doherty 66d100a9e9 perf(opcuaclient): split the read gate from subscription management; no gate on writes or the discovery browse (R06:O-6)
One SemaphoreSlim serialised reads, writes, subscribes, acks and the whole recursive
discovery browse, so a stalled write (up to the operation timeout) blocked every
read and subscription create, and a discovery browse would block the data plane for
its full duration. Reads/acks keep the read gate; writes and DiscoverAsync are
ungated (the SDK session is safe for concurrent service calls, the host bounds write
concurrency); subscription management takes its own gate. Dispose drains both
(DisposeAsync's drain no longer over-releases on a timed-out wait).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:24:40 -04:00
Joseph Doherty afafd483dd fix(opcuaclient): an acknowledge with no reply fails with its transport status (R06:O-8)
AcknowledgeAsync swallowed every non-cancellation transport failure (and returned
normally when the session was lost while the ack queued), so the host logged the
Part 9 ack as applied while upstream still showed it unacknowledged. Both now throw
(ServiceResultException with the transport status / BadNotConnected). A reply,
including per-ack refusals, still returns normally and counts as the server answering.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:21:19 -04:00
Joseph Doherty 6c3a754be9 fix(opcuaclient): lifecycle generation, close the local session on init throw, one teardown budget (R06:O-2, O-9)
- Every InitializeAsync/ShutdownAsync bumps a lifecycle generation. The session,
  namespace map and endpoint are installed under _probeLock only while the init's
  generation is current, so an init the host abandoned that finishes after the next
  shutdown discards its session instead of resurrecting it. Every health write
  (lifecycle, keep-alive, publish-error, read path) is generation-stamped, so a late
  writer can no longer overwrite newer state.
- O-2: the init catch closes and disposes the LOCAL session it opened (it closed the
  still-null Session property, orphaning an activated session + keep-alive timer).
- O-9: ShutdownAsync spends ONE budget (4 s, inside the host's 5 s PostStop budget) on
  wire calls: every subscription delete concurrently, then the session close with
  what remains; waits are abandoned when the budget fires even if the SDK ignores the
  token. It used to be 5 s per subscription, serially.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:19:48 -04:00
Joseph Doherty 4d0d5d229d fix(opcuaclient): remove a half-built data subscription when its create throws (R06:O-1)
SubscribeAsync left the SDK Subscription on the session (server-side when
CreateAsync succeeded, item handlers attached, tracked by nothing) when
CreateAsync/CreateItemsAsync threw, so the host's per-tick reconcile retry
accumulated one live subscription per retry. Mirror the alarm path: detach
the item handlers, bounded RemoveSubscriptionAsync, rethrow.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:12:03 -04:00
Joseph Doherty 0d49eaf77b Merge lane TwinCAT: TwinCAT driver remediation (#739)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:04:51 -04:00
Joseph Doherty a8d313ed5a fix(twincat): review round 2 - teardown confined to its generation; write health; re-arm epoch
- 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
2026-10-22 15:00:23 -04:00
Joseph Doherty 09e0fe1841 fix(focas): a returned status never ends a device's batch; round-2 minors (#740 review round 2)
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
2026-10-22 14:55:37 -04:00
Joseph Doherty 991d277c03 Merge commit '7bcb6331' into remed/lane-TwinCAT 2026-10-22 14:53:01 -04:00
Joseph Doherty 95b75c2e58 Merge local master 7bcb6331 into remed/lane-FOCAS
Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:51:47 -04:00
Joseph Doherty f179829ca6 fix(twincat): review round 1 - PLC-not-in-RUN degrades, per-device health, retry backoff
- 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
2026-10-22 14:47:24 -04:00
Joseph Doherty de54b23b1a test(focas): wire-level cases for fixed-tree health and the legacy alarm layout (#740 review I2)
Against focas-mock:
- H15: a local TCP relay in front of the shared mock takes "the CNC" away and brings it
  back without stopping the fixture. A FIXED-only driver goes Degraded with
  LastSuccessfulRead frozen and its value Bad, then returns to Healthy with Good values
  (the positive control).
- H20: the mock answers cnc_rdalmmsg2 in the 80-byte legacy layout. Nineteen alarms
  (1,520 B = 20 x 76) now decode as the 19 seeded alarms; the old length test gives 20,
  verified red. A 3-alarm reply is the control.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:44:42 -04:00
Joseph Doherty fd64fed217 fix(focas): a stalled device fails fast for the rest of its batch; generation-stamp health (#740 review round 1)
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
2026-10-22 14:44:42 -04:00
Joseph Doherty 8de5947e2d test(twincat): XAR-gated wire cases for H11/H12/H18/U9
Native-subscribe isolation of an unknown symbol, LastSuccessfulRead advance
from pushes alone, a WORD bit write flipping exactly one bit (bit 16
refused), a whole-array REAL write round-trip (length mismatch refused) and a
STRING(80) write round-trip (overflow refused). Every writing case restores
the seed it touched. Skipped without the TwinCAT XAR fixture.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:30:05 -04:00
Joseph Doherty 6201bafea4 test(twincat): pass the test token to the abandoned-init wait
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:28:53 -04:00
Joseph Doherty 8ffa6178b5 test(focas): integration case for re-initialise under live wire reads (R07:H13)
Against the focas-mock fixture: the fixed-tree loop is running and reads are in flight
while the driver re-initialises five times. No reader throws and reads keep returning
Good. After shutdown, a fresh driver still connects and reads.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:27:38 -04:00
Joseph Doherty 2724d7dae8 fix(focas): a CNC refusal is per-tag Bad, not a driver Degraded (lane health checklist)
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
2026-10-22 14:26:28 -04:00
Joseph Doherty 9beeed7a76 feat(twincat): implement IBatchSubscribable (R02:P1)
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
2026-10-22 14:25:20 -04:00
Joseph Doherty cbacaee4ee fix(twincat): bounded, race-safe lifecycle; drop counter; post-unsubscribe purge (L8)
- 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
2026-10-22 14:24:10 -04:00
Joseph Doherty 29b566d51b fix(focas): disambiguate the cnc_rdalmmsg2 record layout at 1,520-byte multiples (R07:H20)
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
2026-10-22 14:24:05 -04:00
Joseph Doherty 4c4dfaadcc fix(focas): recycle the device handle under ConnectGate and drain it first (R07:L6)
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
2026-10-22 14:21:02 -04:00
Joseph Doherty 256915b7e4 perf(focas): read devices concurrently within a batch (R07:P9)
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
2026-10-22 14:18:16 -04:00
Joseph Doherty cc140593a0 fix(focas): refuse String on PMC/PARAM/MACRO in the factory, editor and CLI (R07:U8)
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
2026-10-22 14:15:35 -04:00
Joseph Doherty f17ba8e258 feat(twincat): array and STRING writes through the codecs (U9)
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
2026-10-22 14:15:29 -04:00
Joseph Doherty 6423ed272f fix(focas): the fixed-tree loop reports device silence to driver health (R07:H15)
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
2026-10-22 14:11:25 -04:00
Joseph Doherty 40c7493829 fix(twincat): BOOL-within-word writes RMW the parent at its own width (H18)
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
2026-10-22 14:10:12 -04:00
Joseph Doherty 12b981dc1b fix(focas): carry macro/parameter numbers to the CNC unnarrowed (R07:H14)
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
2026-10-22 14:07:42 -04:00
Joseph Doherty 4083b4aada fix(twincat): warn once per bad tag; a device refusal is not a comms fault (P10)
CompleteRead logged a Warning for every non-Good status on every poll and
latched Degraded for all of them. Now a bad tag warns once per bad episode
(re-armed when it reads Good, and on re-initialise), a comms-class status
(BadCommunicationError / BadTimeout) degrades with LastSuccessfulRead
preserved, and any other status - the device answered and refused this tag -
advances LastSuccessfulRead with no state change. Read-path exception
handlers now go through MarkCommsFault, so they never downgrade Faulted.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:07:03 -04:00
Joseph Doherty 3e0606af58 Merge lane E: script runtime safety + scripted alarms (#734)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:03:54 -04:00
Joseph Doherty ecdd6dbecb fix(twincat): native/probe evidence advances LastSuccessfulRead; probe failures degrade (H12)
In native-notification mode LastSuccessfulRead never moved past the init
stamp, so one Degraded write on a connection older than 90 s latched the
comms-loss watchdog into a full reconnect (every notification on every device
deleted and re-registered). Conversely an unreachable AMS router was swallowed
by the probe and never left Healthy.

- a native push and a successful probe now stamp LastSuccessfulRead (throttled
  to one health record per 250 ms) and restore Healthy unless a device is still
  probe-unreachable;
- 3 consecutive failed probe ticks write Degraded with the stamp preserved;
- a NotSupportedException write is BadNotSupported with no health write (#611);
- a reconnect replay the PLC refuses republishes the refusal's status without
  degrading (it used to report BadCommunicationError + Degraded on every
  reconnect for a correctly-isolated type-mismatch tag).

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:03:28 -04:00
Joseph Doherty a32894e93b fix(focas): build-and-swap the device table; make the lifecycle overlap-safe (R07:H13)
_devices was a plain Dictionary cleared and refilled in place by InitializeAsync /
ShutdownAsync while the retained poll engine, the fixed-tree and alarm loops and the
actor's health tick enumerated it ("Collection was modified"). It is now built whole and
published by reference swap, and a release swaps it out to empty before tearing it down.

Lane A's runtime overlaps lifecycle calls, so the driver now tolerates both shapes:
- a lifecycle generation is bumped by every Initialize/Shutdown; an initialise that finds
  itself superseded (abandoned by the runtime, then a shutdown ran) publishes nothing,
  starts no loops, writes no health and throws OperationCanceledException;
- a shutdown overlapping another (dispose beside a still-running shutdown) waits for the
  first's teardown via a release tail instead of returning on an empty table;
- released devices are Retired: EnsureConnectedAsync refuses to reconnect them, and a
  connect that completes after retirement disposes what it opened. Clients are disposed
  under the device ConnectGate (bounded).

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:03:08 -04:00
Joseph Doherty 17f4bf6d33 fix(twincat): isolate native registrations per tag and per device (H11)
Native subscribe was all-or-nothing: any registration failure other than a
type refusal disposed every registration and rethrew, so one typo'd symbol or
one unreachable PLC in a multi-device driver served zero data, churned
register/delete every reconcile tick, and left health Healthy.

Now each registration fails on its own: a refusal (the device answered: symbol
not found, PLC not ready, type mismatch) publishes its status with no health
write; any other per-tag failure publishes BadCommunicationError and degrades;
a connect failure marks the device unreachable for the rest of the call and
stores its tags as dead intents. Every failed tag is kept as a dead intent that
the probe-tick retry and the reconnect replay re-register. The ADS client now
classifies a failed symbol resolve / AddDeviceNotification by status instead of
throwing InvalidOperationException for all of them. Cancellation still tears
the partial subscription down.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:00:38 -04:00
Joseph Doherty de21a54949 fix(twincat): publish BadNodeIdUnknown for unresolvable native-subscribe references (U12)
The native subscribe path silently skipped a reference it could not resolve
(unknown RawPath, or a tag routed to an unconfigured device), leaving the node
on whatever status the server seeded. It now publishes one BadNodeIdUnknown
snapshot, matching the poll path.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:57:01 -04:00
Joseph Doherty 0d367590db test(galaxy): isolate process-wide telemetry tests; bounded stream-cancel wait
GalaxyTelemetryTests (ActivityListener) and GatewayGalaxyDataWriterFailClosedTests
(MeterListener, filtered only by meter and instrument name) observe every Galaxy span or
measurement in the process. They now run in a collection with parallelization disabled
(F-FU6), because a parallel driver test, or its background probe seed, polluted their counts.
The stream-cancel assertion in GalaxyDriverShutdownTests goes back to a bounded wait: the
instantaneous IsCompleted check introduced in round 1 was racy.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:47:34 -04:00
Joseph Doherty 3e60a925dc fix(galaxy): background probe seed; generation-checked registrations
Review fix round 2 for lane F:
- N1: the init platform seed no longer runs inline under the init token. Galaxy's Tier-A
  Initialize budget is 5 s, and a cold gateway DiscoverHierarchy can spend all of it, so the
  OperationCanceledException rethrow failed init, opened the Initialize breaker and cost a full
  rebuild per retry. InitializeAsync now only resolves the platform source (no network) and
  queues a background seed tied to a per-runtime CTS that the teardown cancels. A result that
  lands after its runtime was torn down is discarded.
- N2: the seed worker serves the runtime that is live when each pass STARTS, so a request made
  under a new runtime while a stale pass is in flight always gets a pass of its own.
- N3: SubscribeAsync and the subscribe-once read capture the runtime generation before
  SubscribeBulk and register only if it is unchanged, atomically with the teardown's registry
  Clear (_registryGate). On a mismatch they release the handles and fail the call. Re-subscribe
  Rebind uses the same gate, and the production rebuild clears the registry unconditionally.
- PerPlatformProbeWatcher re-checks its stop after the SubscribeBulk await, atomically with
  the record and publish. A pass that straddles StopAsync releases the handles it acquired
  instead of writing the probe entry back into a cleared registry.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:47:34 -04:00
Joseph Doherty ef2eebe6b6 fix(scripting): review minors: configured limit in summaries, dispose gate/CTS, validator, docs (fix round 1)
- ScriptLogRateLimiter.LimitPerWindow. The ScriptLogTopicSink and
  VirtualTagActor summaries now print the configured limit, not
  DefaultLimitPerWindow. Tests assert "limit N/window" for a non-default
  N.
- CalculationDriver.Dispose cancels, then takes _evalGate (bounded 2 s)
  and disposes the gate and _evalCts. The pump and timer ticks enter the
  gate through TryEnterEvalGateAsync, which stops cleanly once the
  driver is disposed. Test: Dispose with a 6 s runaway run in flight
  returns promptly, disposes both, and a late trigger is harmless.
- ScriptedAlarmEngine captures _shutdownToken once and disposes
  _shutdownCts in Dispose when every drained task has completed. A task
  stuck past the grace leaves the CTS to the GC. Lifecycle tests assert
  the disposal.
- DraftValidator: CalculationNoEffectiveTrigger is skipped for a
  change-triggered tag whose scriptId does not resolve. That case is
  already CalculationScriptMissing or CalculationScriptNotFound, and
  there are no reads to count. Both existing tests now assert the
  absence (red before the change).
- docs/VirtualTags.md: System.Threading is denied as a whole namespace,
  with an allow-list; the type list is now accurate (Unsafe,
  RuntimeHelpers, and the ForbiddenMembers on System.Type); the resource
  limits section covers the run guard and the allocation budget.
  docs/ScriptEditor.md: accurate deny-list and a new "Runtime safety"
  section covering timeout, refusal, quarantine after 3 timeouts,
  allocation budget, lifting, and rate-limit summaries that appear only
  on the source's next entry after a burst then quiet.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:41:15 -04:00
Joseph Doherty 8a0acc4649 Merge master into remed/lane-F
Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:15 -04:00
Joseph Doherty 77ca10aa37 test(galaxy): de-flake writer-lane and supervisor tests; align EffectiveTimeout doc
The writer concurrency fake parks on its release signal (the 300 ms delay could release the
gate early on a starved pool), the supervisor test's cross-thread hold flag is Volatile, and
the EffectiveTimeout doc now states the 1 s floor the code and tests implement.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:11 -04:00
Joseph Doherty 3456756cd1 fix(galaxy): seed the probe set at init, refuse pumps mid-teardown, bound the close
Review fix round 1 for lane F:
- The ScanState probe watcher is seeded at InitializeAsync from a platform-only
  DiscoverHierarchy query (IGalaxyPlatformSource / GatewayGalaxyPlatformSource over the owned
  repository client) and re-seeded on every deploy-watcher rediscovery. The runtime never
  calls DiscoverAsync on a live driver, so per-platform host status was unreachable in
  production. A failed fetch logs a Warning and never fails init; re-seeds coalesce so a
  deploy during an in-flight pass still gets a pass that starts after it.
- EnsureEventPumpStarted refuses while the runtime is stopping, BEFORE reusing the live pump
  (that pump belongs to the dying subscriber), and the production rebuild disposes any stale
  pump before wiring the new subscriber.
- TeardownRuntimeAsync closes the MX session under the caller's token linked with a 5 s
  SessionCloseDeadline, so a blackholed gateway cannot hold DriverInstanceActor.PostStop for 30 s.
- A runtime generation counter, bumped by every teardown, is re-checked before each Rebind
  in ResubscribeAllAsync, so a replay-gap re-snapshot that straddles a teardown cannot
  resurrect handles into the cleared registry.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:11 -04:00
Joseph Doherty 05a0e51fc1 fix(galaxy): GalaxyMxSession.CloseAsync(ct) bounds the gateway CloseSession
The SDK's DisposeAsync closes with no token, i.e. under the 30 s DefaultCallTimeout, so a
blackholed gateway parked the driver's teardown for the whole of it. CloseAsync(ct) runs the
close under the caller's token, drops the channel on a cancelled/failed close (the gateway
reaps the session on its idle lease) and leaves the session re-openable. A close followed by
the final dispose closes the gateway session once.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:29:11 -04:00
Joseph Doherty 6aa5edaf30 fix(scripting): only timeouts are quarantine strikes; alarm Bad annotation once per bucket change (fix round 1 I1, I3)
- I1 ScriptRunGuard: a run refused because the previous one is still
  executing is a Bad publish plus otopcua.script.run_refused, and no longer
  a quarantine strike. Only a timeout (and an allocation breach) counts;
  a refusal neither adds to nor resets the timeout streak; a completed run
  (value or throw) resets it. On a 1 Hz trigger with a 2 s budget a single
  4 s overrun is one timeout plus two refusals and must not quarantine.
  Tests updated across the guard, the alarm engine, the Calculation
  evaluator and the VT evaluator, plus
  Refusals_neither_count_toward_nor_reset_the_timeout_streak.
- I3 ScriptedAlarmEngine: the quality bucket is tracked only after the
  predicate completes (or in the inputs-not-ready path), so the tracked
  bucket is the last one annotated. A refused or quarantined predicate
  used to re-track the input bucket Good before the attempt and then flip
  it Bad, emitting QualityChanged on every push. The run-guard tests now
  count emissions (exactly 1 across N pushes, refused and quarantined
  cases), with a healthy sibling's dispatch count as the positive control
  that every push was processed. Both were red before the fix.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:26:12 -04:00
Joseph Doherty 909b061d3a Merge branch 'master' into remed/lane-E 2026-10-22 10:48:41 -04:00
Joseph Doherty 4d3fc9c816 fix(runtime): restart-safe alarm host, mux last-value replay, VT log rate limit, Calculation remediation (E4)
- R02:H1 ScriptedAlarmHostActor gains a factory Props
  (Func<(upstream, engine)>) so every incarnation builds and owns its own
  engine; a supervision restart no longer re-creates the actor around a
  disposed engine. The legacy instance-taking Props stays source-compatible
  and is now restart-safe in the weaker sense (PreRestart marks the stop as
  a restart, PostStop then detaches instead of disposing). DriverHostActor
  still spawns through the legacy overload (not owned by this lane — F3).
- R02:H3 OnAlarmCommand runs every engine op under a 10 s token linked to
  the actor's lifetime (DefaultCommandTimeout / Props commandTimeout) and
  logs a Warning on timeout instead of suspending the mailbox on a stalled
  store.
- R02:L3 DependencyMuxTagUpstreamSource.RetainOnly(loaded dep-ref union),
  called after every successful load.
- R02:P6 VirtualTagActor's per-failure ScriptLogEntry is rate-limited per
  tag (ScriptLogRateLimiter, 20/min + one summary); the Bad publish and the
  metric are untouched.
- R07:H6 DependencyMuxActor retains the last (value, status, ts) per
  ever-registered ref (through unregisters) and replays them in one batch to
  a RegisterInterest(ReplayRetained: true) — VirtualTagActor, the Calculation
  mux adapter and the alarm host opt in; the historization recorder does not
  (a replay there would record a stale sample per deploy). CalculationDriver
  records inputs regardless of _running so a replay landing before
  InitializeAsync still satisfies the change-gate.
- R07:P4 CalculationEvaluator negative-caches compile failures by source
  hash (one Roslyn attempt + one Warning per source until redeploy); the
  driver's script-log entry fires on the Good→Bad transition or a change of
  reason, matching the once-per-transition Bad publish.
- R07:H8 array results dedup structurally.
- R07:L2 ShutdownAsync drops the compiled scripts (collectible ALCs).
- R07:U5 DraftValidator rule CalculationNoEffectiveTrigger: a tag with no
  timer whose change trigger is off or whose script has no literal
  ctx.GetTag read can never evaluate.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 10:27:49 -04:00
Joseph Doherty 6dedf5158f feat(galaxy): wire DiscoverPageSize and ProbeTimeoutSeconds; concurrent first-touch write lanes
Review 2026-10-21 G-5, G-6, G-8 ("wire them all").

- G-5: GatewayGalaxyHierarchySource now pages the DiscoverHierarchy RPC
  itself (DiscoverHierarchyRawAsync) at Repository.DiscoverPageSize. The
  mxaccessgw client's typed DiscoverHierarchyAsync overloads hard-code a
  5000-row page and DiscoverHierarchyOptions carries no page size, so the
  authored knob reached nothing; the raw request takes page_size on the
  wire, so it is honoured here (non-positive → 5000). The loop keeps the
  client's repeated-page-token guard.
- G-6: GalaxyDriverProbe reads ProbeTimeoutSeconds from the config and
  runs under min(caller budget, configured cap) — the knob can shorten a
  probe, never extend it past the host's budget. The TCP preflight now
  shares that deadline instead of hanging on the caller's token alone.
- G-8: GatewayGalaxyDataWriter runs a batch as one lane per distinct tag,
  concurrently and bounded at 8 (MaxConcurrentWriteLanes); same-tag
  writes keep their authored order inside one lane and results land at
  their request's index. Acks stay serial by design.
- GalaxyDriverForm: help text now says what the two knobs actually do.

Refs #735

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:41:47 -04:00