- m6: a /current read that sees a new Agent instanceId now also publishes the new
process's cursor (generation-checked, captured with the read's session), so a
later subscription does not open at the dead instance's sequence and flicker
through a clear + re-baseline. Pinned in the T-2 test.
- Documented (code + docs/drivers/MTConnect.md): m1 the bounded stale-read window of
a stream stalled without failing; m4 a refused multi-pair PUT's retry may re-send
an already-applied pair (harmless); m5 a non-Agent 200 now reads reachable to the
ping; m8 a refused raised count takes one transient blip before the step-back.
- m10 confirmed: no host key is persisted (DriverHostStatus dropped, #521) - the
redacted HostName only changes an in-memory /hosts key for a query-bearing URI.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Coordinator ruling on F3: a write with no reply (transport failure / deadline)
sets Degraded with the stamp preserved and a type-only LastError, through a new
_writeDegraded flag folded into PublishHealthy. One success writer is shared:
RecordReadAnswered (a /current answer, an Agent refusal, a live-index read)
clears the read AND write degradations; RecordWriteAnswered clears the write
one; neither clears the other path's stream degradation. Tests both directions
(answered write clears it, good read clears it) plus the converse (an answered
write does not clear a failed read); red with each half removed.
m7: a good /current resets the refusal-dedup latch, so a refusal recurring after a
recovery is logged again (tested).
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- The pump's delivered/degraded writes (stream flag, live flag, PublishHealthy /
Degrade) now happen in TryMarkStreamDelivered / TryDegradeStream: one critical
section under _subscriptionLock that re-checks the session generation. A pump
wedged in a subscriber past its last check can no longer report a replaced
session's (down) stream Healthy and make the read path trust it.
- m3: the index and condition tracker are installed in the same lock as the
session + generation and captured under it (TryCaptureSessionState), so a stale
chunk cannot fold into the new session's index.
- Teardown also bumps the generation, so an abandoned pump cannot write over the
shutdown's health either.
- m2: an Agent restart clears the live flag before clearing the index, so reads
go to /current until the re-baseline lands.
Red test: the wedged-pump harness with the new stream failing, plus a positive
control (the current pump's chunk restores Healthy).
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Health contract: an MTConnectError document (NO_DEVICE, INVALID_REQUEST, ...) is the
Agent answering. The parsers now mark such an InvalidDataException
(MTConnectXml.IsAgentRefusal - the type is sealed, so a Data marker, every other
handler unchanged); ReadAsync reports the batch Bad but does not degrade the read
path, advances LastSuccessfulRead, clears a prior read degradation and warns once
per distinct refusal. An unparseable answer stays a comms fault.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The driver logged and surfaced the raw AgentUri (which may carry user:pass@) as
HostName (the /hosts key), in ~20 log lines and in exception messages that reach
DriverHealth.LastError, and the client's request-URI messages (timeouts, stream
end, not-a-stream, framing) embedded it too - while the Test-Connect probe
deliberately redacted it. New MTConnectUriRedaction (user-info, and for the
configured string its query, removed; a clean URI is unchanged so host keys do
not move); the driver computes its display form once per options document.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with
LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and
re-subscribed it every 90 s forever. Both access-denied arms now also record
an answer: Faulted is kept (worst state wins), the stamp advances, and the
sticky write fault still clears only on a real success. Pinned end to end in
Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog
is not reconnected across 2x the threshold; the control (every read timing
out) is.
Minors:
- m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now
asserted strictly, including the write-refusal case.
- m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list.
- m-3: a release that races an init publish also cancels the probe loop the
publish started after the release took its snapshot.
- m-4: a retained poll failing during a lifecycle operation writes no health.
- m-5: a multi-var chunk that fell back memoises its members refused alone.
- m-7: access-denied needs S7.Net's exact wording and no transport fault in
the chain.
- m-8: sync teardown clears the write-side Degraded owner; a sync Dispose
racing a running shutdown is tested.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The connectivity probe parsed the full /probe device model every 5 s and discarded
it. It now calls a new IMTConnectAgentClient.PingAsync: one GET /probe read with
ResponseHeadersRead and a status check; the body is dropped unread. Same failure
surface as ProbeAsync (the default implementation delegates to it). The probe-tick
tests now count pings and pin that no tick issues a full /probe; the shutdown
test's absence assertion gained a positive control.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Writes were one POST per slot although the Agent's PUT surface accepts several
id=value pairs. The slots surviving the local refusals now go out as one
WriteObservationsAsync(device, pairs) per device (new IMTConnectAgentClient method;
the default loops the single-pair call). A batch closes at another device or a
repeated DataItem so same-item writes keep request order; a refused multi-slot
request is retried per slot so one refused DataItem costs only its own slot.
Health contract: any Agent answer (accepted or refused) advances
LastSuccessfulRead with the state following the read / stream flags.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Subscription throughput was hard-capped at SampleCount / SampleIntervalMs with no
lag detection: a machine producing more fell behind the ring buffer forever,
re-baselining every buffer-length with every value backlog-stale.
- The parser carries the header's optional lastSequence; each chunk measures
lag = lastSequence + 1 - nextSequence and whether it was saturated (full count).
- Three consecutive saturated chunks with a positive (or unknown) lag reopen the
stream at the cursor reached with count doubled, up to 16x SampleCount (max
65536), through a new IMTConnectAgentClient.SampleAsync(from, count, ct) overload
(default implementation ignores count). A raised stream that delivers nothing
steps back to the last good count; at the ceiling one Warning per pump.
- MTConnectDriver implements IDriverDiagnostics: sampleCount, sampleLag,
sampleSaturatedChunks, sampleCountRaises on the /hosts bag. The cap is
documented on SampleCount.
Refs #743
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
N3 (critical): the node manager carries only NATIVE conditions' state over a
full rebuild; scripted conditions come back fresh and rely on the engine's
re-assert, which since the off-thread plan is handled in the window and lands
on the discarded instance. On a rebuild the condition replay scope is now
AddedConditions plus every enabled scripted condition in the new composition
(native kept conditions stay carried-over, not replayed). The test sink again
models the real rebuild: it clears non-native condition state only.
N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the
raw node and its UNS mirror to BadWaitingForInitialData without ensuring them,
so the window's value was not replayed and the two NodeIds disagreed. The
applier records re-shaped nodes (MaterialisationRecording.Reshaped); their
deferred value replays only once fitted to the new shape (VariableShape.TryFit:
as is, or converted invariantly; never an old-typed value - WriteValues assigns
the value verbatim with no type check), otherwise it is dropped at Debug and the
node keeps waiting. Status-only entries are not replayed onto a re-shaped node.
Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the
false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering
remark is outside this lane - F8); m4 the replay Debug log reports alarm-state
and alarm-quality counts again.
Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and
mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero
PCCC STS and CIP status into that code, including a routing hop's "unconnected
send timed out" / "connection lost" / "remote node missing", and native
BadReply covers truncated / mismatched / unmapped replies - so a powered-off
PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing,
the comms-loss watchdog unable to fire.
Three classes now, each pinned both ways:
- no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault;
- answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/
TooSmall/TypeMismatch/DeviceStateConflict): proves reachability;
- OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the
existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad,
handle kept, coalescing fallback, never fail-fast, never an answer. Option
(a): a device whose pass/call produced only opaque outcomes is Degraded with
LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC.
The probe and index reads classify the same way.
Minors:
- N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of
decoding the file at the wrong width as Good.
- N3: a swept indirect resolution is never reconnected under a forgotten gate;
that one call answers neutrally.
- N4: an index read answered Unsupported is neutral, not a transport fault.
- N5: ArrayFileMismatch checks indirect addresses (the file letter is static).
- Test nit: the evicted-while-waiting test signals gate arrival via a seam
instead of sleeping.
Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an
opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen.
Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
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
- 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
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
- The background probe reported the host Stopped whenever a read pass held
_gate past the probe deadline: contention is the connection being used,
not lost. A contended tick now gives no verdict (counted for tests); a
real probe failure still reports Stopped. A tick that races a teardown no
longer reopens a connection on an uninitialised driver.
- S7DriverProbe deserialised S7DriverOptions directly, whose Timeout is a
TimeSpan no config names, so "timeoutMs" was ignored and every handshake
waited the whole Test Connect budget. The handshake and test read are now
bounded by timeoutMs, capped by the caller's timeout.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
- 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
A span's block is the union of its members' ranges, so one member that runs
past the DB's real end (or over-declares a STRING) made the CPU refuse the
whole block, and every neighbour within it was Bad on every poll. The
planner's stated invariant (a grouped read cannot fault where per-tag reads
would succeed) was false; the multi-var path already fell back, spans did not.
- A refused multi-member span hands its members back; they are read one at a
time for that pass, bypassing the multi-var pass.
- _noGroup remembers the members refused on their own (or every member when
none was) and keeps them out of later plans, so the rest group again. It is
cleared by every initialize publish. Non-refusal span failures are
unchanged (every member stamped).
- The once-per-tag refusal Warning memo (R07:H4) is now also cleared by the
publish, and shutdown resets the write-side Degraded owner, as documented.
- Live gate: MW10 + MD254 (2 bytes past the 256-byte M area) merged into one
refused block; MW10 keeps reading 4097 on every pass.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
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
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
No read or write loop checked _plcDead between items: on a frozen-but-
established peer the first op blew its deadline and marked the handle dead,
and every remaining span / tag / write still went to the wire and could wait
its own deadline under _gate (a 20-span pass ~100 s), with Degraded and the
comms-loss watchdog's clock deferred until the pass ended.
- The span loop, the multi-var chunk loop, the per-tag loop and the write
loop stamp BadCommunicationError without I/O once _plcDead is set.
- A multi-var chunk that dies on a transport loss (socket / IO / timeout /
ConnectionError) marks the handle dead instead of falling back to one
doomed per-tag read per member. The framing class is deliberately excluded
there: snap7/S7.Net report a multi-var with one refused item as
ReadData(WrongNumberReceivedBytes) over a healthy stream.
- Live gate via an in-test freezable TCP proxy (counts request frames, not
seconds). Mutation shows S7.Net 0.20 already self-closes on a cancelled op,
so the gate pins the end-to-end property; the driver guard itself is proven
by the unit tests (an op that ignores its token keeps the socket open).
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
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
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
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
A deterministic PLC refusal (object does not exist, address out of range,
type inconsistent...) degraded driver health, and an all-refused driver (a
wrong DB number) sat Degraded with LastSuccessfulRead frozen, so the 90 s
comms-loss watchdog reconnected it forever against a reachable PLC.
- IsRefusal / IsAccessDenied classify over the whole exception chain. Live
probe on the snap7 fixture: S7.Net's READ paths raise the item error as a
BARE Exception ("Received error from PLC: ..."), its write paths as
PlcException(WriteData). The old PlcException-only match missed the read
shape entirely, so a real read access-denied was BadCommunicationError +
Degraded instead of the Faulted PUT/GET alert; the Test Connect probe had
the same blind spot.
- A refusal (read or write) is BadDeviceFailure with a once-per-tag Warning,
no health write and no reopen; it counts as answered, so it advances
LastSuccessfulRead and restores Healthy after a comms fault. An undecodable
buffer (S7ValueRangeException) also counts as answered.
- A write batch the CPU answered without a failure advances
LastSuccessfulRead and retires a Degraded the write side itself published.
- Tests that used ReadData as their generic "device fault" now use SendData
so their Degraded leg is still exercised.
Refs #742
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
- 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
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-6. The XML doc said the token is cancelled on timeout only.
ScriptRunGuard links it to the caller's token
(CreateLinkedTokenSource(ct)), so it is also cancelled on shutdown or
when the evaluating actor stops mid-run. The doc now says so.
Refs #731#734
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review m-5. The ctx.CancellationToken completion fix (d2cc0715) made
TryGetDotMembers include inherited instance members for EVERY receiver,
not only ctx. The visible side effect beyond the item's scope: struct
and enum receivers now also complete the System.ValueType / System.Enum
instance members they inherit (CompareTo, HasFlag, ...). These are
valid, compilable members, so the change is kept. This commit records
it here and in the code comment.
Refs #731#734
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 m-1. The bind moves from Program.cs into
Host.Configuration.ScriptingOptionsBinding.Bind, which binds the
section and clamps it. Program.cs calls it, and
ScriptingOptionsBindingTests drives it with in-memory configuration.
Every documented key takes a non-default value, so a misspelled
property or key goes red instead of silently falling back to the
defaults. The tests also cover a clamped bind (1e300 / 1024 / 0, three
adjustments), an absent section, and the sibling LogFilePath /
LogTopicMinLevel keys leaving the bind untouched.
Refs #731#734
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review I-2. Under factory Props a supervision restart of
ScriptedAlarmHostActor builds an empty engine, and nothing re-sent
ApplyScriptedAlarms. Until the next deploy the host had no mux interest
(inputs stopped), and its ownership filter silently dropped every
operator ack and shelve.
The host now tells its parent ScriptedAlarmHostRestarted from
PostRestart. DriverHostActor keeps the last plans it applied and
re-sends ApplyScriptedAlarms when the signal comes from the current
host, in Steady, Applying and Stale. This mirrors how a driver child's
ReprovisionRequested is answered. The OnAlarmsLoaded comment, which
assumed a re-apply that never happened, now says where it comes from.
DriverHostActorScriptedAlarmRestartTests deploys one scripted alarm
through a real host, crashes the alarm host, does NOT re-apply by hand,
and asserts that interest is re-registered, a dependency push activates
the alarm, and an operator ack reaches the engine. It is red without
the signal (times out waiting for the re-registration).
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
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
Overlapping InitializeAsync calls (the actor's fire-and-forget init plus a
retry tick) could leak an opened PLC connection and orphan a probe loop: no
gate guarded the lifecycle, only _gate for I/O.
- _lifecycleGate (the Modbus pattern) serialises Initialize / Reinitialize /
Shutdown / Dispose; teardown waits a bounded time, then tears down anyway.
- Every initialize and teardown starts a lifecycle generation. Health writes
(init, read/write passes, poll errors, shutdown) and the init publish carry
it and are dropped once it has moved on, so an abandoned init never
publishes over a later shutdown and a shutdown's final write never clobbers
a newer init. A superseded init disposes what it opened and throws.
- The init publish (options, tag tables, Plc, probe loop) happens under _gate;
the tag tables are built fresh and swapped by one reference write instead of
being cleared in place under live poll passes.
- Read/write passes re-check initialization under _gate, so a pass that waited
out a teardown cannot reopen a connection on a shut-down driver.
- The semaphores are no longer disposed: Dispose can race a running shutdown.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
N1: a full alarm-state snapshot also sets the condition's Quality, so it now
supersedes an earlier quality-only write for the same id in the deferred
buffer; a quality arriving after the state is kept and replays after it.
N2 / M1: the replay is not inert on a node the apply KEPT - an SDK-applied
Acknowledge/Confirm/Shelve or an optimistic client Write in the window would
be rolled back (with spurious Part 9 events). The replay is now scoped to
nodes the apply created: variables the applier ensured during this apply
(AddressSpaceApplier.BeginRecordingEnsuredVariables) or every variable after a
full rebuild; conditions in the applied composition but not the previous one
(also on a rebuild, which carries a kept condition's state over). Option (b),
a sink that reports whether a live write landed, needs the Commons
DeferredAddressSpaceSink seam outside this lane (follow-up F7).
M2: the benchmark remark now matches its Category=Benchmark trait.
M3: TestHooks and the seam-carrying constructor are internal; PropsForTests
produces the actor through an IIndirectActorProducer.
M4: pinned replay-nothing-and-drop-the-buffer after a failed plan.
M5: PreRestart answers every queued rebuild requester (degraded).
Refs #732
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review I-1. Context.Stop is asynchronous, so a removed child kept
publishing through the rest of the apply handler and its own stop.
Those bursts were forwarded after SourceRemoved and re-retained the
removed driver's last Good in the mux, where nothing would ever clear
it while a replay subscriber held the ref (a removed driver never
publishes again, and its PostStop Bad fan resolves no refs once the
index is rebuilt without it).
StopChild and HandleRestartDriver now retire the child (_retiredChildren
+ Context.Watch) before stopping it. ForwardBatchToMux drops a burst
whose Sender is retired, and Terminated (handled in Steady, Applying and
Stale) removes the entry. Akka delivers Terminated after the watched
child's last message, so the set is bounded and a live incarnation is
never filtered. ConnectivityChanged from a retired child is
deliberately not filtered, because the respawn path relies on its Bad
fan.
Tests (review m-3 folded in): a stale burst spoofed as the removed child,
held alive in PostStop by a new RecordingSubscribableDriver.ShutdownGate,
no longer re-retains (red with the guard disabled). New cases cover a
config-change respawn and an operator Restart, each with the new
driver's value replaying as the positive control. The arrange step now
waits for the real subscription set and fishes for both drivers'
values at once (two sequential fishes could discard the other burst).
Refs #731#734
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
The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed
Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the
actor retried the same init every 10 s forever, darkening every tag on the PLC.
Neither the editor nor the deploy gate checked any of it.
- S7AddressParser moves to S7.Contracts (same namespace) and a new
S7TagShapeRules there is the single authority on servable shapes.
- S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the
deploy gate (Inspect, with the specific reason) and the driver init refuse
the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of
throwing; the throwing RejectUnsupportedTagConfigs is gone.
- New rules on top of the old guards: a narrow type at the wrong access width
(every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3
silently read bit 0 of bytes 0..n).
- AdminUI test fixtures that used addresses the driver could never serve
(DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones.
Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT